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>
)}
</>
);
};

View File

@@ -184,9 +184,8 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => setRouteNumber(e.target.value)}
/>
<TextField
className="w-full"
label="Координаты маршрута"
multiline
className="w-full max-h-[300px] overflow-y-scroll"
minRows={4}
value={routeCoords}
onChange={(e) => {

View File

@@ -19,7 +19,8 @@ import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { routeStore } from "../../../shared/store/RouteStore";
import { toast } from "react-toastify";
import { languageStore } from "@shared";
import { languageStore, stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => {
const navigate = useNavigate();
@@ -34,6 +35,7 @@ export const RouteEditPage = observer(() => {
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
carrierStore.getCarriers(language);
stationsStore.getStations();
articlesStore.getArticleList();
};
fetchData();
@@ -150,8 +152,7 @@ export const RouteEditPage = observer(() => {
}
/>
<TextField
className="w-full"
label="Координаты маршрута"
className="w-full max-h-[300px] overflow-y-scroll -mt-5 h-full"
multiline
minRows={4}
value={coordinates}
@@ -245,54 +246,73 @@ export const RouteEditPage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
value={editRouteData.scale_min || ""}
value={editRouteData.scale_min ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_min: Number(e.target.value),
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={editRouteData.scale_max || ""}
value={editRouteData.scale_max ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max: Number(e.target.value),
scale_max:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Поворот"
value={editRouteData.rotate || ""}
value={editRouteData.rotate ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
rotate: Number(e.target.value),
rotate:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. широта"
value={editRouteData.center_latitude || ""}
value={editRouteData.center_latitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_latitude: Number(e.target.value),
center_latitude: e.target.value,
})
}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={editRouteData.center_longitude || ""}
value={editRouteData.center_longitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_longitude: Number(e.target.value),
center_longitude: e.target.value,
})
}
/>
</Box>
<LinkedItems
parentId={id || ""}
type="edit"
dragAllowed={true}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
onUpdate={() => {
routeStore.getRoute(Number(id));
}}
/>
<div className="flex w-full justify-end">
<Button
variant="contained"

View File

@@ -56,12 +56,12 @@ export function RightSidebar() {
setMapRotation(rotationDegrees);
}, [rotationDegrees]);
useEffect(() => {
const center = screenCenter ?? { x: 0, y: 0 };
const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}, [position]);
// useEffect(() => {
// const center = screenCenter ?? { x: 0, y: 0 };
// const localCenter = screenToLocal(center.x, center.y);
// const coordinates = localToCoordinates(localCenter.x, localCenter.y);
// setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
// }, [position]);
useEffect(() => {
setMapCenter(localCenter.x, localCenter.y);