fix: Add route-station link area

This commit is contained in:
2025-06-16 12:26:19 +03:00
parent 32a7cb44d1
commit d415441af8
8 changed files with 570 additions and 119 deletions

View File

@@ -0,0 +1,425 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
FormControl,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
IconButton,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
import { authInstance, languageStore } from "@shared";
// Helper function to insert an item at a specific position (1-based index)
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;
}
// Helper function to reorder items after drag and drop
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;
cityId?: 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>
</>
);
};
export const LinkedItemsContents = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
dragAllowed = false,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
cityId,
}: 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 parentResource = "route";
const childResource = "station";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.sort((a, b) => a.name.localeCompare(b.name));
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setPosition(linkedItems.length + 1);
}, [linkedItems.length]);
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) => ({ id: item.id })),
})
.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("Failed to load available stations");
setAllItems([]);
});
}
}, [type]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = {
stations: insertAtPosition(
linkedItems.map((item) => ({ id: item.id })),
position,
{ id: selectedItemId }
),
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then((response) => {
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");
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error unlinking item:", error);
setError("Failed to unlink station");
});
};
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
>
{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>
<Button
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
>
Отвязать
</Button>
</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>
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) || null
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems.filter(
(item) => !cityId || item.city_id == cityId
)}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField {...params} label="Выберите станцию" fullWidth />
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</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>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
</Stack>
)}
</>
);
};