556 lines
17 KiB
TypeScript
556 lines
17 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { languageStore } from "../store/LanguageStore";
|
||
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 { axiosInstance } from "../providers/data";
|
||
|
||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||
|
||
import { articleStore } from "../store/ArticleStore";
|
||
import { ArticleEditModal } from "./modals/ArticleEditModal";
|
||
import { StationEditModal } from "./modals/StationEditModal";
|
||
import { stationStore } from "../store/StationStore";
|
||
|
||
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||
const index = pos - 1;
|
||
if (index >= arr.length) {
|
||
arr.push(value);
|
||
} else {
|
||
arr.splice(index, 0, value);
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
type Field<T> = {
|
||
label: string;
|
||
data: keyof T;
|
||
render?: (value: any) => React.ReactNode;
|
||
};
|
||
|
||
type ExtraFieldConfig = {
|
||
type: "number";
|
||
label: string;
|
||
minValue: number;
|
||
maxValue: (linkedItems: any[]) => number;
|
||
};
|
||
|
||
type LinkedItemsProps<T> = {
|
||
parentId: string | number;
|
||
parentResource: string;
|
||
childResource: string;
|
||
fields: Field<T>[];
|
||
setItemsParent?: (items: T[]) => void;
|
||
title: string;
|
||
type: "show" | "edit";
|
||
extraField?: ExtraFieldConfig;
|
||
dragAllowed?: boolean;
|
||
onSave?: (items: T[]) => void;
|
||
onUpdate?: () => void;
|
||
dontRecurse?: boolean;
|
||
disableCreation?: boolean;
|
||
updatedLinkedItems?: T[];
|
||
refresh?: number;
|
||
};
|
||
|
||
const reorder = (list: any[], startIndex: number, endIndex: number) => {
|
||
const result = Array.from(list);
|
||
const [removed] = result.splice(startIndex, 1);
|
||
result.splice(endIndex, 0, removed);
|
||
return result;
|
||
};
|
||
|
||
export const LinkedItems = <T extends { id: number; [key: string]: any }>(
|
||
props: LinkedItemsProps<T>
|
||
) => {
|
||
const theme = useTheme();
|
||
|
||
return (
|
||
<>
|
||
<Accordion>
|
||
<AccordionSummary
|
||
expandIcon={<ExpandMoreIcon />}
|
||
sx={{
|
||
background: theme.palette.background.paper,
|
||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||
}}
|
||
>
|
||
<Typography variant="subtitle1" fontWeight="bold">
|
||
Привязанные {props.title}
|
||
</Typography>
|
||
</AccordionSummary>
|
||
|
||
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
||
<Stack gap={2}>
|
||
<LinkedItemsContents {...props} />
|
||
</Stack>
|
||
</AccordionDetails>
|
||
</Accordion>
|
||
|
||
{!props.dontRecurse && (
|
||
<>
|
||
<ArticleEditModal />
|
||
<StationEditModal />
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
export const LinkedItemsContents = <
|
||
T extends { id: number; [key: string]: any }
|
||
>({
|
||
parentId,
|
||
parentResource,
|
||
childResource,
|
||
setItemsParent,
|
||
fields,
|
||
title,
|
||
dragAllowed = false,
|
||
type,
|
||
onUpdate,
|
||
disableCreation = false,
|
||
updatedLinkedItems,
|
||
refresh,
|
||
}: LinkedItemsProps<T>) => {
|
||
const { language } = languageStore;
|
||
const { setArticleModalOpenAction, setArticleIdAction } = articleStore;
|
||
const { setStationModalOpenAction, setStationIdAction, setRouteIdAction } =
|
||
stationStore;
|
||
const [position, setPosition] = useState<number>(1);
|
||
const [items, setItems] = useState<T[]>([]);
|
||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||
const [pageNum, setPageNum] = useState<number>(1);
|
||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||
const [mediaOrder, setMediaOrder] = useState<number>(1);
|
||
|
||
let availableItems = items.filter(
|
||
(item) => !linkedItems.some((linked) => linked.id === item.id)
|
||
);
|
||
useEffect(() => {
|
||
if (childResource == "station") {
|
||
availableItems = availableItems.sort((a, b) =>
|
||
a.name.localeCompare(b.name)
|
||
);
|
||
}
|
||
}, [childResource, availableItems]);
|
||
|
||
useEffect(() => {
|
||
if (!updatedLinkedItems?.length) return;
|
||
setLinkedItems(updatedLinkedItems);
|
||
}, [updatedLinkedItems]);
|
||
|
||
useEffect(() => {
|
||
setItemsParent?.(linkedItems);
|
||
}, [linkedItems, setItemsParent]);
|
||
|
||
const onDragEnd = (result: any) => {
|
||
if (!result.destination) return;
|
||
|
||
const reorderedItems = reorder(
|
||
linkedItems,
|
||
result.source.index,
|
||
result.destination.index
|
||
);
|
||
|
||
setLinkedItems(reorderedItems);
|
||
|
||
if (parentResource === "sight" && childResource === "article") {
|
||
axiosInstance.post(
|
||
`${import.meta.env.VITE_KRBL_API}/sight/${parentId}/article/order`,
|
||
{
|
||
articles: reorderedItems.map((item) => ({
|
||
id: item.id,
|
||
})),
|
||
}
|
||
);
|
||
} else {
|
||
axiosInstance.post(
|
||
`${import.meta.env.VITE_KRBL_API}/route/${parentId}/station`,
|
||
{
|
||
stations: reorderedItems.map((item) => ({
|
||
id: item.id,
|
||
})),
|
||
}
|
||
);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (parentId) {
|
||
axiosInstance
|
||
.get(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/${parentResource}/${parentId}/${childResource}`
|
||
)
|
||
.then((response) => {
|
||
setLinkedItems(response?.data || []);
|
||
})
|
||
.catch(() => {
|
||
setLinkedItems([]);
|
||
});
|
||
}
|
||
}, [parentId, parentResource, childResource, language, refresh]);
|
||
|
||
useEffect(() => {
|
||
if (type === "edit") {
|
||
axiosInstance
|
||
.get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`)
|
||
.then((response) => {
|
||
setItems(response?.data || []);
|
||
setIsLoading(false);
|
||
})
|
||
.catch(() => {
|
||
setItems([]);
|
||
setIsLoading(false);
|
||
});
|
||
} else {
|
||
setIsLoading(false);
|
||
}
|
||
}, [childResource, type]);
|
||
|
||
useEffect(() => {
|
||
if (childResource === "article" && parentResource === "sight") {
|
||
setPageNum(linkedItems.length + 1);
|
||
}
|
||
}, [linkedItems, childResource, parentResource]);
|
||
|
||
const linkItem = () => {
|
||
if (selectedItemId !== null) {
|
||
const requestData =
|
||
childResource === "article"
|
||
? {
|
||
[`${childResource}_id`]: selectedItemId,
|
||
page_num: pageNum,
|
||
}
|
||
: childResource === "media"
|
||
? {
|
||
[`${childResource}_id`]: selectedItemId,
|
||
media_order: mediaOrder,
|
||
}
|
||
: childResource === "station"
|
||
? {
|
||
stations: insertAtPosition(
|
||
linkedItems.map((item) => ({
|
||
id: item.id,
|
||
})),
|
||
position,
|
||
{
|
||
id: selectedItemId,
|
||
}
|
||
),
|
||
}
|
||
: { [`${childResource}_id`]: selectedItemId };
|
||
|
||
axiosInstance
|
||
.post(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/${parentResource}/${parentId}/${childResource}`,
|
||
requestData
|
||
)
|
||
.then(() => {
|
||
axiosInstance
|
||
.get(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/${parentResource}/${parentId}/${childResource}`
|
||
)
|
||
.then((response) => {
|
||
setLinkedItems(response?.data || []);
|
||
setSelectedItemId(null);
|
||
if (childResource === "article") {
|
||
setPageNum(pageNum + 1);
|
||
}
|
||
onUpdate?.();
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error linking item:", error);
|
||
});
|
||
}
|
||
};
|
||
|
||
const deleteItem = (itemId: number) => {
|
||
axiosInstance
|
||
.delete(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/${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);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{linkedItems?.length > 0 && (
|
||
<DragDropContext onDragEnd={onDragEnd}>
|
||
<TableContainer component={Paper}>
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
{type === "edit" && dragAllowed && (
|
||
<TableCell width="40px"></TableCell>
|
||
)}
|
||
<TableCell key="id">№</TableCell>
|
||
{fields.map((field) => (
|
||
<TableCell key={String(field.data)}>
|
||
{field.label}
|
||
</TableCell>
|
||
))}
|
||
|
||
{type === "edit" && (
|
||
<TableCell width="120px">Действие</TableCell>
|
||
)}
|
||
</TableRow>
|
||
</TableHead>
|
||
|
||
<Droppable
|
||
droppableId="droppable"
|
||
isDropDisabled={type !== "edit" || !dragAllowed}
|
||
>
|
||
{(provided) => (
|
||
<TableBody
|
||
ref={provided.innerRef}
|
||
{...provided.droppableProps}
|
||
>
|
||
{linkedItems.map((item, index) => (
|
||
<Draggable
|
||
key={item.id}
|
||
draggableId={"q" + String(item.id)}
|
||
index={index}
|
||
isDragDisabled={type !== "edit" || !dragAllowed}
|
||
>
|
||
{(provided) => (
|
||
<TableRow
|
||
sx={{
|
||
cursor:
|
||
childResource === "article"
|
||
? "pointer"
|
||
: "default",
|
||
}}
|
||
onClick={() => {
|
||
if (
|
||
childResource === "article" &&
|
||
type === "edit"
|
||
) {
|
||
setArticleModalOpenAction(true);
|
||
setArticleIdAction(item.id);
|
||
}
|
||
if (
|
||
childResource === "station" &&
|
||
type === "edit"
|
||
) {
|
||
setStationModalOpenAction(true);
|
||
setStationIdAction(item.id);
|
||
setRouteIdAction(Number(parentId));
|
||
}
|
||
}}
|
||
ref={provided.innerRef}
|
||
{...provided.draggableProps}
|
||
{...provided.dragHandleProps}
|
||
hover
|
||
>
|
||
{type === "edit" && dragAllowed && (
|
||
<TableCell {...provided.dragHandleProps}>
|
||
<IconButton size="small">
|
||
<DragIndicatorIcon />
|
||
</IconButton>
|
||
</TableCell>
|
||
)}
|
||
<TableCell key={String(item.id)}>
|
||
{index + 1}
|
||
</TableCell>
|
||
{fields.map((field, index) => (
|
||
<TableCell
|
||
key={String(field.data) + String(index)}
|
||
>
|
||
{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}>
|
||
{title} не найдены
|
||
</Typography>
|
||
)}
|
||
|
||
{type === "edit" && !disableCreation && (
|
||
<Stack gap={2} mt={2}>
|
||
<Typography variant="subtitle1">Добавить {title}</Typography>
|
||
<Autocomplete
|
||
fullWidth
|
||
value={
|
||
availableItems?.find((item) => item.id === selectedItemId) || null
|
||
}
|
||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||
options={availableItems}
|
||
getOptionLabel={(item) => String(item[fields[0].data])}
|
||
renderInput={(params) => (
|
||
<TextField {...params} label={`Выберите ${title}`} fullWidth />
|
||
)}
|
||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||
filterOptions={(options, { inputValue }) => {
|
||
const searchWords = inputValue
|
||
.toLowerCase()
|
||
.split(" ")
|
||
.filter((word) => word.length > 0);
|
||
return options.filter((option) => {
|
||
const optionWords = String(option[fields[0].data])
|
||
.toLowerCase()
|
||
.split(" ");
|
||
return searchWords.every((searchWord) =>
|
||
optionWords.some((word) => word.startsWith(searchWord))
|
||
);
|
||
});
|
||
}}
|
||
renderOption={(props, option) => (
|
||
<li {...props} key={option.id}>
|
||
{String(option[fields[0].data])}
|
||
</li>
|
||
)}
|
||
/>
|
||
|
||
{/* {childResource === "article" && (
|
||
<FormControl fullWidth>
|
||
<TextField
|
||
type="number"
|
||
label="Позиция добавляемой статьи"
|
||
name="page_num"
|
||
value={pageNum}
|
||
onChange={(e) => {
|
||
const newValue = Number(e.target.value);
|
||
const minValue = linkedItems.length + 1;
|
||
setPageNum(newValue < minValue ? minValue : newValue);
|
||
}}
|
||
fullWidth
|
||
InputLabelProps={{ shrink: true }}
|
||
/>
|
||
</FormControl>
|
||
)} */}
|
||
|
||
{childResource === "media" && (
|
||
<FormControl fullWidth>
|
||
<TextField
|
||
type="text"
|
||
label="Порядок отображения медиа"
|
||
value={mediaOrder}
|
||
onChange={(e) => {
|
||
const rawValue = e.target.value;
|
||
const numericValue = Number(rawValue);
|
||
const maxValue = linkedItems.length + 1;
|
||
|
||
if (isNaN(numericValue)) {
|
||
return;
|
||
} else {
|
||
let newValue = numericValue;
|
||
|
||
if (newValue < 10 && newValue > 0) {
|
||
setMediaOrder(numericValue);
|
||
}
|
||
|
||
if (newValue > maxValue) {
|
||
newValue = maxValue;
|
||
}
|
||
|
||
setMediaOrder(newValue);
|
||
}
|
||
}}
|
||
fullWidth
|
||
InputLabelProps={{ shrink: true }}
|
||
/>
|
||
</FormControl>
|
||
)}
|
||
|
||
<Button
|
||
variant="contained"
|
||
onClick={linkItem}
|
||
disabled={
|
||
!selectedItemId || (childResource == "media" && mediaOrder == 0)
|
||
}
|
||
sx={{ alignSelf: "flex-start" }}
|
||
>
|
||
Добавить
|
||
</Button>
|
||
{childResource == "station" && (
|
||
<TextField
|
||
type="text"
|
||
label="Позиция добавляемой остановки к маршруту"
|
||
value={position}
|
||
onChange={(e) => {
|
||
const newValue = Number(e.target.value);
|
||
setPosition(
|
||
newValue > linkedItems.length + 1
|
||
? linkedItems.length + 1
|
||
: newValue
|
||
);
|
||
}}
|
||
></TextField>
|
||
)}
|
||
</Stack>
|
||
)}
|
||
</>
|
||
);
|
||
};
|