329 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			329 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { useState, useEffect } from "react";
 | ||
| import {
 | ||
|   Stack,
 | ||
|   Typography,
 | ||
|   Button,
 | ||
|   Accordion,
 | ||
|   AccordionSummary,
 | ||
|   AccordionDetails,
 | ||
|   useTheme,
 | ||
|   TextField,
 | ||
|   Autocomplete,
 | ||
|   TableCell,
 | ||
|   TableContainer,
 | ||
|   Table,
 | ||
|   TableHead,
 | ||
|   TableRow,
 | ||
|   Paper,
 | ||
|   TableBody,
 | ||
| } from "@mui/material";
 | ||
| import { observer } from "mobx-react-lite";
 | ||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
 | ||
| 
 | ||
| import { authInstance, languageStore, selectedCityStore } from "@shared";
 | ||
| 
 | ||
| type Field<T> = {
 | ||
|   label: string;
 | ||
|   data: keyof T;
 | ||
|   render?: (value: any) => React.ReactNode;
 | ||
| };
 | ||
| 
 | ||
| type LinkedSightsProps<T> = {
 | ||
|   parentId: string | number;
 | ||
|   fields: Field<T>[];
 | ||
|   setItemsParent?: (items: T[]) => void;
 | ||
|   type: "show" | "edit";
 | ||
|   onUpdate?: () => void;
 | ||
|   disableCreation?: boolean;
 | ||
|   updatedLinkedItems?: T[];
 | ||
|   refresh?: number;
 | ||
| };
 | ||
| 
 | ||
| export const LinkedSights = <
 | ||
|   T extends { id: number; name: string; [key: string]: any }
 | ||
| >(
 | ||
|   props: LinkedSightsProps<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%">
 | ||
|             <LinkedSightsContents {...props} />
 | ||
|           </Stack>
 | ||
|         </AccordionDetails>
 | ||
|       </Accordion>
 | ||
|     </>
 | ||
|   );
 | ||
| };
 | ||
| 
 | ||
| const LinkedSightsContentsInner = <
 | ||
|   T extends { id: number; name: string; [key: string]: any }
 | ||
| >({
 | ||
|   parentId,
 | ||
|   setItemsParent,
 | ||
|   fields,
 | ||
|   type,
 | ||
|   onUpdate,
 | ||
|   disableCreation = false,
 | ||
|   updatedLinkedItems,
 | ||
|   refresh,
 | ||
| }: LinkedSightsProps<T>) => {
 | ||
|   const { language } = languageStore;
 | ||
| 
 | ||
|   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);
 | ||
| 
 | ||
|   useEffect(() => {}, [error]);
 | ||
| 
 | ||
|   const parentResource = "station";
 | ||
|   const childResource = "sight";
 | ||
| 
 | ||
|   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));
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     if (updatedLinkedItems) {
 | ||
|       setLinkedItems(updatedLinkedItems);
 | ||
|     }
 | ||
|   }, [updatedLinkedItems]);
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     setItemsParent?.(linkedItems);
 | ||
|   }, [linkedItems, setItemsParent]);
 | ||
| 
 | ||
|   const linkItem = () => {
 | ||
|     if (selectedItemId !== null) {
 | ||
|       setError(null);
 | ||
|       const requestData = {
 | ||
|         sight_id: selectedItemId,
 | ||
|       };
 | ||
| 
 | ||
|       authInstance
 | ||
|         .post(`/${parentResource}/${parentId}/${childResource}`, requestData)
 | ||
|         .then(() => {
 | ||
|           const newItem = allItems.find((item) => item.id === selectedItemId);
 | ||
|           if (newItem) {
 | ||
|             setLinkedItems([...linkedItems, newItem]);
 | ||
|           }
 | ||
|           setSelectedItemId(null);
 | ||
|           onUpdate?.();
 | ||
|         })
 | ||
|         .catch((error) => {
 | ||
|           console.error("Error linking sight:", error);
 | ||
|           setError("Failed to link sight");
 | ||
|         });
 | ||
|     }
 | ||
|   };
 | ||
| 
 | ||
|   const deleteItem = (itemId: number) => {
 | ||
|     setError(null);
 | ||
|     authInstance
 | ||
|       .delete(`/${parentResource}/${parentId}/${childResource}`, {
 | ||
|         data: { [`${childResource}_id`]: itemId },
 | ||
|       })
 | ||
|       .then(() => {
 | ||
|         setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
 | ||
|         onUpdate?.();
 | ||
|       })
 | ||
|       .catch((error) => {
 | ||
|         console.error("Error deleting sight:", error);
 | ||
|         setError("Failed to delete sight");
 | ||
|       });
 | ||
|   };
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     if (parentId) {
 | ||
|       setIsLoading(true);
 | ||
|       setError(null);
 | ||
|       authInstance
 | ||
|         .get(`/${parentResource}/${parentId}/${childResource}`)
 | ||
|         .then((response) => {
 | ||
|           setLinkedItems(response?.data || []);
 | ||
|         })
 | ||
|         .catch((error) => {
 | ||
|           console.error("Error fetching linked sights:", error);
 | ||
|           setError("Failed to load linked sights");
 | ||
|           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 sights:", error);
 | ||
|           setError("Failed to load available sights");
 | ||
|           setAllItems([]);
 | ||
|         });
 | ||
|     }
 | ||
|   }, [type]);
 | ||
| 
 | ||
|   return (
 | ||
|     <>
 | ||
|       {linkedItems?.length > 0 && (
 | ||
|         <TableContainer component={Paper} sx={{ width: "100%" }}>
 | ||
|           <Table sx={{ width: "100%" }}>
 | ||
|             <TableHead>
 | ||
|               <TableRow>
 | ||
|                 <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>
 | ||
| 
 | ||
|             <TableBody>
 | ||
|               {linkedItems.map((item, index) => (
 | ||
|                 <TableRow key={item.id} hover>
 | ||
|                   <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>
 | ||
|               ))}
 | ||
|             </TableBody>
 | ||
|           </Table>
 | ||
|         </TableContainer>
 | ||
|       )}
 | ||
| 
 | ||
|       {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}
 | ||
|             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>
 | ||
|             )}
 | ||
|           />
 | ||
| 
 | ||
|           <Button
 | ||
|             variant="contained"
 | ||
|             onClick={linkItem}
 | ||
|             disabled={!selectedItemId}
 | ||
|             sx={{ alignSelf: "flex-start" }}
 | ||
|           >
 | ||
|             Добавить
 | ||
|           </Button>
 | ||
|         </Stack>
 | ||
|       )}
 | ||
| 
 | ||
|       {isLoading && (
 | ||
|         <Typography color="textSecondary" textAlign="center" py={2}>
 | ||
|           Загрузка...
 | ||
|         </Typography>
 | ||
|       )}
 | ||
| 
 | ||
|       {error && (
 | ||
|         <Typography color="error" textAlign="center" py={2}>
 | ||
|           {error}
 | ||
|         </Typography>
 | ||
|       )}
 | ||
|     </>
 | ||
|   );
 | ||
| };
 | ||
| 
 | ||
| export const LinkedSightsContents = observer(
 | ||
|   LinkedSightsContentsInner
 | ||
| ) as typeof LinkedSightsContentsInner;
 |