WhiteNightsAdminPanel/src/components/LinkedItems.tsx

556 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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