feat: Update
This commit is contained in:
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
@@ -16,11 +15,21 @@ import {
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
import {
|
||||
AnimatedCircleButton,
|
||||
authInstance,
|
||||
languageStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
@@ -93,6 +102,16 @@ const LinkedStationsContentsInner = <
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
|
||||
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
|
||||
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
|
||||
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
@@ -110,6 +129,11 @@ const LinkedStationsContentsInner = <
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
@@ -120,6 +144,18 @@ const LinkedStationsContentsInner = <
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedToDetach((prev) => {
|
||||
const updated = new Set<number>();
|
||||
linkedItems.forEach((item) => {
|
||||
if (prev.has(item.id)) {
|
||||
updated.add(item.id);
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, [linkedItems]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
@@ -127,6 +163,7 @@ const LinkedStationsContentsInner = <
|
||||
station_id: selectedItemId,
|
||||
};
|
||||
|
||||
setIsLinkingSingle(true);
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
@@ -140,12 +177,20 @@ const LinkedStationsContentsInner = <
|
||||
.catch((error) => {
|
||||
console.error("Error linking station:", error);
|
||||
setError("Failed to link station");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLinkingSingle(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
setDetachingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(itemId);
|
||||
return next;
|
||||
});
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
@@ -157,9 +202,119 @@ const LinkedStationsContentsInner = <
|
||||
.catch((error) => {
|
||||
console.error("Error deleting station:", error);
|
||||
setError("Failed to delete station");
|
||||
})
|
||||
.finally(() => {
|
||||
setDetachingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (itemId: number) => {
|
||||
const updated = new Set(selectedItems);
|
||||
if (updated.has(itemId)) {
|
||||
updated.delete(itemId);
|
||||
} else {
|
||||
updated.add(itemId);
|
||||
}
|
||||
setSelectedItems(updated);
|
||||
};
|
||||
|
||||
const handleBulkLink = () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setError(null);
|
||||
|
||||
setIsLinkingBulk(true);
|
||||
Promise.all(
|
||||
Array.from(selectedItems).map((id) =>
|
||||
authInstance.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
station_id: id,
|
||||
})
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
const newItems = allItems.filter((item) =>
|
||||
selectedItems.has(item.id)
|
||||
);
|
||||
setLinkedItems([...linkedItems, ...newItems]);
|
||||
setSelectedItems(new Set());
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error bulk linking stations:", error);
|
||||
setError("Failed to link stations");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLinkingBulk(false);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDetachSelection = (itemId: number) => {
|
||||
const updated = new Set(selectedToDetach);
|
||||
if (updated.has(itemId)) {
|
||||
updated.delete(itemId);
|
||||
} else {
|
||||
updated.add(itemId);
|
||||
}
|
||||
setSelectedToDetach(updated);
|
||||
};
|
||||
|
||||
const handleToggleAllDetach = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedToDetach(new Set());
|
||||
return;
|
||||
}
|
||||
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
|
||||
};
|
||||
|
||||
const handleBulkDetach = () => {
|
||||
const idsToDetach = Array.from(selectedToDetach);
|
||||
if (idsToDetach.length === 0) return;
|
||||
setError(null);
|
||||
|
||||
setIsBulkDetaching(true);
|
||||
setDetachingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
idsToDetach.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
|
||||
Promise.all(
|
||||
idsToDetach.map((itemId) =>
|
||||
authInstance.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
setLinkedItems(
|
||||
linkedItems.filter((item) => !idsToDetach.includes(item.id))
|
||||
);
|
||||
setSelectedToDetach(new Set());
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error bulk deleting stations:", error);
|
||||
setError("Failed to delete stations");
|
||||
})
|
||||
.finally(() => {
|
||||
setDetachingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
idsToDetach.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
setIsBulkDetaching(false);
|
||||
});
|
||||
};
|
||||
|
||||
const allSelectedForDetach =
|
||||
linkedItems.length > 0 &&
|
||||
linkedItems.every((item) => selectedToDetach.has(item.id));
|
||||
const isIndeterminateDetach =
|
||||
selectedToDetach.size > 0 && !allSelectedForDetach;
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
@@ -203,6 +358,16 @@ const LinkedStationsContentsInner = <
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{type === "edit" && (
|
||||
<TableCell width="50px">
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={allSelectedForDetach}
|
||||
indeterminate={isIndeterminateDetach}
|
||||
onChange={(e) => handleToggleAllDetach(e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
@@ -218,6 +383,15 @@ const LinkedStationsContentsInner = <
|
||||
<TableBody>
|
||||
{linkedItems.map((item, index) => (
|
||||
<TableRow key={item.id} hover>
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={selectedToDetach.has(item.id)}
|
||||
onChange={() => toggleDetachSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
@@ -228,7 +402,7 @@ const LinkedStationsContentsInner = <
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
@@ -236,9 +410,11 @@ const LinkedStationsContentsInner = <
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
disabled={detachingIds.has(item.id)}
|
||||
loading={detachingIds.has(item.id)}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</AnimatedCircleButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
@@ -248,6 +424,20 @@ const LinkedStationsContentsInner = <
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{type === "edit" && linkedItems.length > 0 && (
|
||||
<Stack direction="row" gap={2} mt={2}>
|
||||
<AnimatedCircleButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleBulkDetach}
|
||||
disabled={selectedToDetach.size === 0 || isBulkDetaching}
|
||||
loading={isBulkDetaching}
|
||||
>
|
||||
Отвязать выбранные ({selectedToDetach.size})
|
||||
</AnimatedCircleButton>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Остановки не найдены
|
||||
@@ -256,48 +446,127 @@ const LinkedStationsContentsInner = <
|
||||
|
||||
{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" }}
|
||||
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, value) => setActiveTab(value)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
<Tab label="По одной" />
|
||||
<Tab label="Массово" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{activeTab === 0 && (
|
||||
<Stack gap={2}>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatedCircleButton
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId || isLinkingSingle}
|
||||
loading={isLinkingSingle}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</AnimatedCircleButton>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<Stack gap={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Поиск остановок"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Введите название остановки..."
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||||
<Stack gap={1}>
|
||||
{filteredAvailableItems.map((item) => (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => handleCheckboxChange(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={String(item.name)}
|
||||
sx={{
|
||||
margin: 0,
|
||||
"& .MuiFormControlLabel-label": {
|
||||
fontSize: "0.9rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{filteredAvailableItems.length === 0 && (
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
textAlign="center"
|
||||
py={1}
|
||||
>
|
||||
{searchQuery.trim()
|
||||
? "Остановки не найдены"
|
||||
: "Нет доступных остановок"}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<AnimatedCircleButton
|
||||
variant="contained"
|
||||
onClick={handleBulkLink}
|
||||
disabled={selectedItems.size === 0 || isLinkingBulk}
|
||||
loading={isLinkingBulk}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить выбранные ({selectedItems.size})
|
||||
</AnimatedCircleButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user