679 lines
22 KiB
TypeScript
679 lines
22 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import {
|
||
Stack,
|
||
Typography,
|
||
FormControl,
|
||
Accordion,
|
||
AccordionSummary,
|
||
AccordionDetails,
|
||
useTheme,
|
||
TextField,
|
||
Autocomplete,
|
||
TableCell,
|
||
TableContainer,
|
||
Table,
|
||
TableHead,
|
||
TableRow,
|
||
Paper,
|
||
TableBody,
|
||
IconButton,
|
||
Checkbox,
|
||
FormControlLabel,
|
||
Tabs,
|
||
Tab,
|
||
Box,
|
||
} from "@mui/material";
|
||
import { observer } from "mobx-react-lite";
|
||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||
import {
|
||
DragDropContext,
|
||
Droppable,
|
||
Draggable,
|
||
DropResult,
|
||
} from "@hello-pangea/dnd";
|
||
|
||
import {
|
||
AnimatedCircleButton,
|
||
authInstance,
|
||
languageStore,
|
||
routeStore,
|
||
selectedCityStore,
|
||
stationsStore,
|
||
} from "@shared";
|
||
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
||
|
||
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||
const index = pos - 1;
|
||
const result = [...arr];
|
||
if (index >= result.length) {
|
||
result.push(value);
|
||
} else {
|
||
result.splice(index, 0, value);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
|
||
const result = Array.from(list);
|
||
const [removed] = result.splice(startIndex, 1);
|
||
result.splice(endIndex, 0, removed);
|
||
return result;
|
||
};
|
||
|
||
type Field<T> = {
|
||
label: string;
|
||
data: keyof T;
|
||
render?: (value: any) => React.ReactNode;
|
||
};
|
||
|
||
type LinkedItemsProps<T> = {
|
||
parentId: string | number;
|
||
fields: Field<T>[];
|
||
setItemsParent?: (items: T[]) => void;
|
||
type: "show" | "edit";
|
||
dragAllowed?: boolean;
|
||
onUpdate?: () => void;
|
||
dontRecurse?: boolean;
|
||
disableCreation?: boolean;
|
||
updatedLinkedItems?: T[];
|
||
refresh?: number;
|
||
};
|
||
|
||
export const LinkedItems = <
|
||
T extends { id: number; name: string; [key: string]: any }
|
||
>(
|
||
props: LinkedItemsProps<T>
|
||
) => {
|
||
const theme = useTheme();
|
||
|
||
return (
|
||
<>
|
||
<Accordion sx={{ width: "100%" }}>
|
||
<AccordionSummary
|
||
expandIcon={<ExpandMoreIcon />}
|
||
sx={{
|
||
background: theme.palette.background.paper,
|
||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||
width: "100%",
|
||
}}
|
||
>
|
||
<Typography variant="subtitle1" fontWeight="bold">
|
||
Привязанные остановки
|
||
</Typography>
|
||
</AccordionSummary>
|
||
|
||
<AccordionDetails
|
||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||
>
|
||
<Stack gap={2} width="100%">
|
||
<LinkedItemsContents {...props} />
|
||
</Stack>
|
||
</AccordionDetails>
|
||
</Accordion>
|
||
</>
|
||
);
|
||
};
|
||
|
||
const LinkedItemsContentsInner = <
|
||
T extends { id: number; name: string; [key: string]: any }
|
||
>({
|
||
parentId,
|
||
setItemsParent,
|
||
fields,
|
||
dragAllowed = false,
|
||
type,
|
||
onUpdate,
|
||
disableCreation = false,
|
||
updatedLinkedItems,
|
||
refresh,
|
||
}: LinkedItemsProps<T>) => {
|
||
const { language } = languageStore;
|
||
|
||
const [position, setPosition] = useState<number>(1);
|
||
const [allItems, setAllItems] = useState<T[]>([]);
|
||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||
const [activeTab, setActiveTab] = useState(0);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
|
||
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
|
||
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
|
||
|
||
useEffect(() => {}, [error]);
|
||
|
||
const parentResource = "route";
|
||
const childResource = "station";
|
||
|
||
const availableItems = allItems
|
||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||
.filter((item) => {
|
||
const selectedCityId = selectedCityStore.selectedCityId;
|
||
if (selectedCityId && "city_id" in item) {
|
||
return item.city_id === selectedCityId;
|
||
}
|
||
return true;
|
||
})
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
|
||
const filteredAvailableItems = availableItems.filter((item) => {
|
||
if (!searchQuery.trim()) return true;
|
||
const query = searchQuery.toLowerCase();
|
||
const name = String(item.name || "").toLowerCase();
|
||
const description = String(item.description || "").toLowerCase();
|
||
return name.includes(query) || description.includes(query);
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (updatedLinkedItems) {
|
||
setLinkedItems(updatedLinkedItems);
|
||
}
|
||
}, [updatedLinkedItems]);
|
||
|
||
useEffect(() => {
|
||
setItemsParent?.(linkedItems);
|
||
}, [linkedItems, setItemsParent]);
|
||
|
||
useEffect(() => {
|
||
setPosition(linkedItems.length + 1);
|
||
}, [linkedItems.length]);
|
||
|
||
const getStationTransfers = (stationId: number, fallbackTransfers?: any) => {
|
||
const { stationLists } = stationsStore;
|
||
for (const lang of ["ru", "en", "zh"] as const) {
|
||
const station = stationLists[lang].data.find(
|
||
(s: any) => s.id === stationId
|
||
);
|
||
if (station?.transfers) {
|
||
return station.transfers;
|
||
}
|
||
}
|
||
return fallbackTransfers;
|
||
};
|
||
|
||
const onDragEnd = (result: DropResult) => {
|
||
if (!result.destination) return;
|
||
|
||
const reorderedItems = reorder(
|
||
linkedItems,
|
||
result.source.index,
|
||
result.destination.index
|
||
);
|
||
|
||
setLinkedItems(reorderedItems);
|
||
|
||
authInstance
|
||
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||
stations: reorderedItems.map((item) => {
|
||
const transfers = getStationTransfers(item.id, item.transfers);
|
||
return {
|
||
...item,
|
||
transfers: transfers || item.transfers,
|
||
};
|
||
}),
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error updating station order:", error);
|
||
setError("Failed to update station order");
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (parentId) {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
authInstance
|
||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||
.then((response) => {
|
||
setLinkedItems(response?.data || []);
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error fetching linked items:", error);
|
||
setError("Failed to load linked stations");
|
||
setLinkedItems([]);
|
||
})
|
||
.finally(() => {
|
||
setIsLoading(false);
|
||
});
|
||
}
|
||
}, [parentId, language, refresh]);
|
||
|
||
useEffect(() => {
|
||
if (type === "edit") {
|
||
setError(null);
|
||
authInstance
|
||
.get(`/${childResource}`)
|
||
.then((response) => {
|
||
setAllItems(response?.data || []);
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error fetching all items:", error);
|
||
setError(null);
|
||
setAllItems([]);
|
||
});
|
||
}
|
||
}, [type]);
|
||
|
||
const linkItem = () => {
|
||
if (selectedItemId !== null) {
|
||
setError(null);
|
||
const selectedItem = allItems.find((item) => item.id === selectedItemId);
|
||
const requestData = {
|
||
stations: insertAtPosition(
|
||
linkedItems.map((item) => {
|
||
const transfers = getStationTransfers(item.id, item.transfers);
|
||
return {
|
||
...item,
|
||
transfers: transfers || item.transfers,
|
||
};
|
||
}),
|
||
position,
|
||
(() => {
|
||
if (!selectedItem) return { id: selectedItemId };
|
||
const transfers = getStationTransfers(
|
||
selectedItemId,
|
||
selectedItem.transfers
|
||
);
|
||
return {
|
||
...selectedItem,
|
||
transfers: transfers || selectedItem.transfers,
|
||
};
|
||
})()
|
||
),
|
||
};
|
||
|
||
setIsLinkingSingle(true);
|
||
authInstance
|
||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||
.then(() => {
|
||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||
if (newItem) {
|
||
const updatedList = insertAtPosition(
|
||
[...linkedItems],
|
||
position,
|
||
newItem
|
||
);
|
||
setLinkedItems(updatedList);
|
||
}
|
||
setSelectedItemId(null);
|
||
onUpdate?.();
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error linking item:", error);
|
||
setError("Failed to link station");
|
||
})
|
||
.finally(() => {
|
||
setIsLinkingSingle(false);
|
||
});
|
||
}
|
||
};
|
||
|
||
const deleteItem = (itemId: number) => {
|
||
setError(null);
|
||
setDetachingIds((prev) => {
|
||
const next = new Set(prev);
|
||
next.add(itemId);
|
||
return next;
|
||
});
|
||
authInstance
|
||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||
data: { [`${childResource}_id`]: itemId },
|
||
})
|
||
.then(() => {
|
||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||
onUpdate?.();
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error deleting item:", error);
|
||
setError("Failed to delete station");
|
||
})
|
||
.finally(() => {
|
||
setDetachingIds((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete(itemId);
|
||
return next;
|
||
});
|
||
});
|
||
};
|
||
|
||
const handleStationClick = (item: T) => {
|
||
routeStore.setSelectedStationId(item.id);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleCloseModal = () => {
|
||
setIsModalOpen(false);
|
||
};
|
||
|
||
const handleCheckboxChange = (itemId: number) => {
|
||
const newSelected = new Set(selectedItems);
|
||
if (newSelected.has(itemId)) {
|
||
newSelected.delete(itemId);
|
||
} else {
|
||
newSelected.add(itemId);
|
||
}
|
||
setSelectedItems(newSelected);
|
||
};
|
||
|
||
const handleBulkLink = () => {
|
||
if (selectedItems.size === 0) return;
|
||
|
||
setError(null);
|
||
setIsLinkingBulk(true);
|
||
const selectedStations = Array.from(selectedItems).map((id) => {
|
||
const item = allItems.find((item) => item.id === id);
|
||
if (!item) return { id };
|
||
const transfers = getStationTransfers(id, item.transfers);
|
||
return {
|
||
...item,
|
||
transfers: transfers || item.transfers,
|
||
};
|
||
});
|
||
const requestData = {
|
||
stations: [
|
||
...linkedItems.map((item) => {
|
||
const transfers = getStationTransfers(item.id, item.transfers);
|
||
return {
|
||
...item,
|
||
transfers: transfers || item.transfers,
|
||
};
|
||
}),
|
||
...selectedStations,
|
||
],
|
||
};
|
||
|
||
authInstance
|
||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||
.then(() => {
|
||
const newItems = allItems.filter((item) => selectedItems.has(item.id));
|
||
setLinkedItems([...linkedItems, ...newItems]);
|
||
setSelectedItems(new Set());
|
||
onUpdate?.();
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error linking items:", error);
|
||
setError("Failed to link stations");
|
||
})
|
||
.finally(() => {
|
||
setIsLinkingBulk(false);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{linkedItems?.length > 0 && (
|
||
<DragDropContext onDragEnd={onDragEnd}>
|
||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||
<Table sx={{ width: "100%" }}>
|
||
<TableHead>
|
||
<TableRow>
|
||
{type === "edit" && dragAllowed && (
|
||
<TableCell width="40px"></TableCell>
|
||
)}
|
||
<TableCell key="id" width="60px">
|
||
№
|
||
</TableCell>
|
||
{fields.map((field) => (
|
||
<TableCell key={String(field.data)}>
|
||
{field.label}
|
||
</TableCell>
|
||
))}
|
||
{type === "edit" && (
|
||
<TableCell width="120px">Действие</TableCell>
|
||
)}
|
||
</TableRow>
|
||
</TableHead>
|
||
|
||
<Droppable
|
||
droppableId="droppable-stations"
|
||
isDropDisabled={type !== "edit" || !dragAllowed}
|
||
>
|
||
{(provided) => (
|
||
<TableBody
|
||
ref={provided.innerRef}
|
||
{...provided.droppableProps}
|
||
>
|
||
{linkedItems.map((item, index) => (
|
||
<Draggable
|
||
key={item.id}
|
||
draggableId={"station-" + String(item.id)}
|
||
index={index}
|
||
isDragDisabled={type !== "edit" || !dragAllowed}
|
||
>
|
||
{(provided) => (
|
||
<TableRow
|
||
sx={{ cursor: "pointer" }}
|
||
ref={provided.innerRef}
|
||
{...provided.draggableProps}
|
||
hover
|
||
onClick={() => handleStationClick(item)}
|
||
>
|
||
{type === "edit" && dragAllowed && (
|
||
<TableCell {...provided.dragHandleProps}>
|
||
<IconButton size="small">
|
||
<DragIndicatorIcon />
|
||
</IconButton>
|
||
</TableCell>
|
||
)}
|
||
<TableCell>{index + 1}</TableCell>
|
||
{fields.map((field, idx) => (
|
||
<TableCell key={String(field.data) + String(idx)}>
|
||
{field.render
|
||
? field.render(item[field.data])
|
||
: item[field.data]}
|
||
</TableCell>
|
||
))}
|
||
{type === "edit" && (
|
||
<TableCell>
|
||
<AnimatedCircleButton
|
||
variant="outlined"
|
||
color="error"
|
||
size="small"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
deleteItem(item.id);
|
||
}}
|
||
disabled={detachingIds.has(item.id)}
|
||
loading={detachingIds.has(item.id)}
|
||
>
|
||
Отвязать
|
||
</AnimatedCircleButton>
|
||
</TableCell>
|
||
)}
|
||
</TableRow>
|
||
)}
|
||
</Draggable>
|
||
))}
|
||
{provided.placeholder}
|
||
</TableBody>
|
||
)}
|
||
</Droppable>
|
||
</Table>
|
||
</TableContainer>
|
||
</DragDropContext>
|
||
)}
|
||
|
||
{linkedItems.length === 0 && !isLoading && (
|
||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||
Остановки не найдены
|
||
</Typography>
|
||
)}
|
||
|
||
{type === "edit" && !disableCreation && (
|
||
<Stack gap={2} mt={2}>
|
||
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||
|
||
<Tabs
|
||
value={activeTab}
|
||
onChange={(_, newValue) => setActiveTab(newValue)}
|
||
>
|
||
<Tab label="По одной" />
|
||
<Tab label="Массово" />
|
||
</Tabs>
|
||
|
||
<Box sx={{ mt: 2 }}>
|
||
{activeTab === 0 && (
|
||
<Stack gap={2}>
|
||
<Autocomplete
|
||
fullWidth
|
||
value={
|
||
availableItems?.find(
|
||
(item) => item.id === selectedItemId
|
||
) || null
|
||
}
|
||
onChange={(_, newValue) =>
|
||
setSelectedItemId(newValue?.id || null)
|
||
}
|
||
options={availableItems}
|
||
getOptionLabel={(item) => String(item.name)}
|
||
renderInput={(params) => (
|
||
<TextField
|
||
{...params}
|
||
label="Выберите остановку"
|
||
placeholder="Введите название или описание остановки..."
|
||
fullWidth
|
||
/>
|
||
)}
|
||
isOptionEqualToValue={(option, value) =>
|
||
option.id === value?.id
|
||
}
|
||
filterOptions={(options, { inputValue }) => {
|
||
if (!inputValue.trim()) return options;
|
||
const query = inputValue.toLowerCase();
|
||
return options.filter((option) => {
|
||
const name = String(option.name || "").toLowerCase();
|
||
const description = String(
|
||
option.description || ""
|
||
).toLowerCase();
|
||
return (
|
||
name.includes(query) || description.includes(query)
|
||
);
|
||
});
|
||
}}
|
||
renderOption={(props, option) => (
|
||
<li {...props} key={option.id}>
|
||
<div className="flex justify-between items-center w-full">
|
||
<p>{String(option.name)}</p>
|
||
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
|
||
{String(option.description)}
|
||
</p>
|
||
</div>
|
||
</li>
|
||
)}
|
||
/>
|
||
|
||
<FormControl fullWidth>
|
||
<TextField
|
||
type="number"
|
||
label="Позиция добавляемой остановки"
|
||
value={position}
|
||
onChange={(e) => {
|
||
const newValue = Math.max(1, Number(e.target.value));
|
||
setPosition(
|
||
newValue > linkedItems.length + 1
|
||
? linkedItems.length + 1
|
||
: newValue
|
||
);
|
||
}}
|
||
InputProps={{
|
||
inputProps: { min: 1, max: linkedItems.length + 1 },
|
||
}}
|
||
fullWidth
|
||
/>
|
||
</FormControl>
|
||
|
||
<AnimatedCircleButton
|
||
variant="contained"
|
||
onClick={linkItem}
|
||
disabled={!selectedItemId || isLinkingSingle}
|
||
loading={isLinkingSingle}
|
||
sx={{ alignSelf: "flex-start" }}
|
||
>
|
||
Добавить
|
||
</AnimatedCircleButton>
|
||
</Stack>
|
||
)}
|
||
|
||
{activeTab === 1 && (
|
||
<Stack gap={2}>
|
||
{/* Поле поиска */}
|
||
<TextField
|
||
fullWidth
|
||
label="Поиск остановок"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Введите название остановки..."
|
||
size="small"
|
||
sx={{ mb: 1 }}
|
||
/>
|
||
|
||
{/* Список доступных остановок с чекбоксами */}
|
||
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||
<Stack gap={1}>
|
||
{filteredAvailableItems.map((item) => (
|
||
<FormControlLabel
|
||
key={item.id}
|
||
control={
|
||
<Checkbox
|
||
checked={selectedItems.has(item.id)}
|
||
onChange={() => handleCheckboxChange(item.id)}
|
||
size="small"
|
||
/>
|
||
}
|
||
label={
|
||
<div className="flex justify-between items-center w-full gap-10">
|
||
<p>{String(item.name)}</p>
|
||
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
|
||
{String(item.description)}
|
||
</p>
|
||
</div>
|
||
}
|
||
sx={{
|
||
margin: 0,
|
||
"& .MuiFormControlLabel-label": {
|
||
fontSize: "0.875rem",
|
||
},
|
||
}}
|
||
/>
|
||
))}
|
||
{filteredAvailableItems.length === 0 && (
|
||
<Typography
|
||
color="textSecondary"
|
||
textAlign="center"
|
||
py={1}
|
||
>
|
||
{searchQuery.trim()
|
||
? "Остановки не найдены"
|
||
: "Нет доступных остановок"}
|
||
</Typography>
|
||
)}
|
||
</Stack>
|
||
</Paper>
|
||
|
||
<AnimatedCircleButton
|
||
variant="contained"
|
||
onClick={handleBulkLink}
|
||
disabled={selectedItems.size === 0 || isLinkingBulk}
|
||
loading={isLinkingBulk}
|
||
sx={{ alignSelf: "flex-start" }}
|
||
>
|
||
Добавить выбранные ({selectedItems.size})
|
||
</AnimatedCircleButton>
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
</Stack>
|
||
)}
|
||
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
|
||
</>
|
||
);
|
||
};
|
||
|
||
export const LinkedItemsContents = observer(
|
||
LinkedItemsContentsInner
|
||
) as typeof LinkedItemsContentsInner;
|