init LinkedItems
component for show, edit
pages
This commit is contained in:
parent
0a1eeccf08
commit
9cb939deac
163
src/components/LinkedItems.tsx
Normal file
163
src/components/LinkedItems.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import {useState, useEffect} from 'react'
|
||||||
|
import {Stack, Typography, Button, MenuItem, Select, FormControl, InputLabel, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme} from '@mui/material'
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
|
import axios from 'axios'
|
||||||
|
import {BACKEND_URL} from '../lib/constants'
|
||||||
|
|
||||||
|
type Field<T> = {
|
||||||
|
label: string
|
||||||
|
data: keyof T
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedItemsProps<T> = {
|
||||||
|
parentId: string | number
|
||||||
|
parentResource: string
|
||||||
|
childResource: string
|
||||||
|
fields: Field<T>[]
|
||||||
|
title: string
|
||||||
|
type: 'show' | 'edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => {
|
||||||
|
const [items, setItems] = useState<T[]>([])
|
||||||
|
const [linkedItems, setLinkedItems] = useState<T[]>([])
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | ''>('')
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentId) {
|
||||||
|
axios
|
||||||
|
.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
|
||||||
|
.then((response) => {
|
||||||
|
setLinkedItems(response?.data || [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLinkedItems([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [parentId, parentResource, childResource])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === 'edit') {
|
||||||
|
axios
|
||||||
|
.get(`${BACKEND_URL}/${childResource}/`)
|
||||||
|
.then((response) => {
|
||||||
|
setItems(response?.data || [])
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setItems([])
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [childResource, type])
|
||||||
|
|
||||||
|
const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||||
|
|
||||||
|
const linkItem = () => {
|
||||||
|
if (selectedItemId) {
|
||||||
|
axios
|
||||||
|
.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, {
|
||||||
|
[`${childResource}_id`]: selectedItemId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
axios.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => {
|
||||||
|
setLinkedItems(response?.data || [])
|
||||||
|
setSelectedItemId('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error linking item:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteItem = (itemId: number) => {
|
||||||
|
axios
|
||||||
|
.delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, {
|
||||||
|
data: {[`${childResource}_id`]: itemId},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error unlinking item:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion defaultExpanded>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
background: theme.palette.background.paper,
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
{type === 'show' ? `Привязанные ${title}` : title}
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
|
||||||
|
<AccordionDetails sx={{background: theme.palette.background.paper}}>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Grid container gap={2}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography>Загрузка...</Typography>
|
||||||
|
) : linkedItems.length > 0 ? (
|
||||||
|
linkedItems.map((item, index) => (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
sx={{
|
||||||
|
marginBottom: '8px',
|
||||||
|
padding: '14px',
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `2px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={0.5}>
|
||||||
|
{fields.map(({label, data}) => (
|
||||||
|
<Typography key={String(data)}>
|
||||||
|
<strong>{label}:</strong> {item[data]}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
{type === 'edit' && (
|
||||||
|
<Button variant="outlined" color="error" onClick={() => deleteItem(item.id)} sx={{mt: 1.5}}>
|
||||||
|
Отвязать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography>{title} не найдены</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{type === 'edit' && (
|
||||||
|
<>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>{title}</InputLabel>
|
||||||
|
|
||||||
|
<Select value={selectedItemId} onChange={(e) => setSelectedItemId(Number(e.target.value))} label={title} fullWidth>
|
||||||
|
{availableItems.map((item) => (
|
||||||
|
<MenuItem key={item.id} value={item.id}>
|
||||||
|
{item[fields[0].data]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button variant="contained" onClick={linkItem} disabled={!selectedItemId}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
)
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
import {Box, TextField} from '@mui/material'
|
import {Box, TextField} from '@mui/material'
|
||||||
import {Edit} from '@refinedev/mui'
|
import {Edit} from '@refinedev/mui'
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import {useForm} from '@refinedev/react-hook-form'
|
||||||
|
import {useParams} from 'react-router'
|
||||||
|
import {LinkedItems} from '../../components/LinkedItems'
|
||||||
|
import {type SightItem, sightFields} from './types'
|
||||||
|
|
||||||
export const StationEdit = () => {
|
export const StationEdit = () => {
|
||||||
const {
|
const {
|
||||||
@ -9,6 +12,8 @@ export const StationEdit = () => {
|
|||||||
formState: {errors},
|
formState: {errors},
|
||||||
} = useForm({})
|
} = useForm({})
|
||||||
|
|
||||||
|
const {id: stationId} = useParams<{id: string}>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||||
@ -67,6 +72,17 @@ export const StationEdit = () => {
|
|||||||
name="longitude"
|
name="longitude"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{stationId && (
|
||||||
|
<LinkedItems<SightItem>
|
||||||
|
type="edit" // display and manage
|
||||||
|
parentId={stationId}
|
||||||
|
parentResource="station"
|
||||||
|
childResource="sight"
|
||||||
|
fields={sightFields}
|
||||||
|
title="достопримечательности"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,123 +1,18 @@
|
|||||||
import {Stack, Typography, Box, Grid2 as Grid, Button, MenuItem, Select, FormControl, InputLabel} from '@mui/material'
|
|
||||||
import {useShow} from '@refinedev/core'
|
import {useShow} from '@refinedev/core'
|
||||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
||||||
|
import {Stack, Typography} from '@mui/material'
|
||||||
import {useEffect, useState} from 'react'
|
import {LinkedItems} from '../../components/LinkedItems'
|
||||||
import axios from 'axios'
|
import {type SightItem, sightFields, stationFields} from './types'
|
||||||
|
|
||||||
import {BACKEND_URL} from '../../lib/constants'
|
|
||||||
|
|
||||||
type SightItem = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
latitude: number
|
|
||||||
longitude: number
|
|
||||||
city_id: number
|
|
||||||
city: string
|
|
||||||
[key: string]: string | number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StationShow = () => {
|
export const StationShow = () => {
|
||||||
const {query} = useShow({})
|
const {query} = useShow({})
|
||||||
const {data, isLoading} = query
|
const {data, isLoading} = query
|
||||||
const record = data?.data
|
const record = data?.data
|
||||||
|
|
||||||
const [sights, setSights] = useState<SightItem[]>([])
|
|
||||||
const [linkedSights, setLinkedSights] = useState<SightItem[]>([])
|
|
||||||
const [selectedSightId, setSelectedSightId] = useState<number | ''>('')
|
|
||||||
const [sightsLoading, setSightsLoading] = useState<boolean>(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (record?.id) {
|
|
||||||
axios
|
|
||||||
.get(`${BACKEND_URL}/station/${record.id}/sight`)
|
|
||||||
.then((response) => {
|
|
||||||
setLinkedSights(response?.data || [])
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLinkedSights([])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [record?.id])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
axios
|
|
||||||
.get(`${BACKEND_URL}/sight/`) // without "/" throws CORS error
|
|
||||||
.then((response) => {
|
|
||||||
setSights(response?.data || [])
|
|
||||||
setSightsLoading(false)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setSights([])
|
|
||||||
setSightsLoading(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const availableSights = sights.filter((sight) => !linkedSights.some((linked) => linked.id === sight.id))
|
|
||||||
|
|
||||||
const linkSight = () => {
|
|
||||||
if (selectedSightId) {
|
|
||||||
axios
|
|
||||||
.post(
|
|
||||||
`${BACKEND_URL}/station/${record?.id}/sight`,
|
|
||||||
{sight_id: selectedSightId},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
axios
|
|
||||||
.get(`${BACKEND_URL}/station/${record?.id}/sight`)
|
|
||||||
.then((response) => {
|
|
||||||
setLinkedSights(response?.data || [])
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLinkedSights([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Error linking sight:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteSight = (sightId: number) => {
|
|
||||||
axios
|
|
||||||
.delete(`${BACKEND_URL}/station/${record?.id}/sight`, {
|
|
||||||
data: {sight_id: sightId},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
setLinkedSights((prevSights) => prevSights.filter((item) => item.id !== sightId))
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Error deleting sight:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = [
|
|
||||||
// {label: 'ID', data: 'id'},
|
|
||||||
{label: 'Название', data: 'name'},
|
|
||||||
// {label: 'Широта', data: 'latitude'}, #*
|
|
||||||
// {label: 'Долгота', data: 'longitude'}, #*
|
|
||||||
{label: 'Описание', data: 'description'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const sightFields: Array<{label: string; data: keyof SightItem}> = [
|
|
||||||
// {label: 'ID', data: 'id'},
|
|
||||||
{label: 'Название', data: 'name'},
|
|
||||||
// {label: 'Широта', data: 'latitude'}, #*
|
|
||||||
// {label: 'Долгота', data: 'longitude'}, #*
|
|
||||||
// {label: 'ID города', data: 'city_id'},
|
|
||||||
{label: 'Город', data: 'city'},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show isLoading={isLoading}>
|
<Show isLoading={isLoading}>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{fields.map(({label, data}) => (
|
{stationFields.map(({label, data}) => (
|
||||||
<Stack key={data} gap={1}>
|
<Stack key={data} gap={1}>
|
||||||
<Typography variant="body1" fontWeight="bold">
|
<Typography variant="body1" fontWeight="bold">
|
||||||
{label}
|
{label}
|
||||||
@ -126,64 +21,16 @@ export const StationShow = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Stack gap={2}>
|
{record?.id && (
|
||||||
<Typography variant="body1" fontWeight="bold">
|
<LinkedItems<SightItem>
|
||||||
Привязанные достопримечательности
|
type="show" // only display
|
||||||
</Typography>
|
parentId={record.id}
|
||||||
|
parentResource="station"
|
||||||
<Grid container gap={2}>
|
childResource="sight"
|
||||||
{sightsLoading ? (
|
fields={sightFields}
|
||||||
<Typography>Загрузка достопримечательностей...</Typography>
|
title="достопримечательности"
|
||||||
) : linkedSights.length > 0 ? (
|
/>
|
||||||
linkedSights.map((sight, index) => (
|
)}
|
||||||
<Box
|
|
||||||
key={index}
|
|
||||||
sx={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
padding: '14px',
|
|
||||||
borderRadius: 2,
|
|
||||||
border: (theme) => `2px solid ${theme.palette.divider}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap={0.5}>
|
|
||||||
{sightFields.map(({label, data}) => (
|
|
||||||
<Typography key={data}>
|
|
||||||
<strong>{label}:</strong> {sight[data]}
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button variant="outlined" color="error" onClick={() => deleteSight(sight?.id)} sx={{mt: 1.5}}>
|
|
||||||
Отвязать
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Typography>Достопримечательности не найдены</Typography>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Typography variant="body1" fontWeight="bold">
|
|
||||||
Привязать достопримечательность
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Достопримечательность</InputLabel>
|
|
||||||
<Select value={selectedSightId} onChange={(e) => setSelectedSightId(Number(e.target.value))} label="Достопримечательность" fullWidth>
|
|
||||||
{availableSights.map((sight) => (
|
|
||||||
<MenuItem key={sight.id} value={sight.id}>
|
|
||||||
{sight.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Button variant="contained" onClick={linkSight} disabled={!selectedSightId}>
|
|
||||||
Привязать
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
43
src/pages/station/types.ts
Normal file
43
src/pages/station/types.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type StationItem = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
[key: string]: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SightItem = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
city_id: number
|
||||||
|
city: string
|
||||||
|
[key: string]: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldType<T> = {
|
||||||
|
label: string
|
||||||
|
data: keyof T
|
||||||
|
render?: (value: any) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stationFields: Array<FieldType<StationItem>> = [
|
||||||
|
// {label: 'ID', data: 'id'},
|
||||||
|
{label: 'Название', data: 'name'},
|
||||||
|
// {label: 'Широта', data: 'latitude'},
|
||||||
|
// {label: 'Долгота', data: 'longitude'},
|
||||||
|
{label: 'Описание', data: 'description'},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const sightFields: Array<FieldType<SightItem>> = [
|
||||||
|
// {label: 'ID', data: 'id'},
|
||||||
|
{label: 'Название', data: 'name'},
|
||||||
|
// {label: 'Широта', data: 'latitude'},
|
||||||
|
// {label: 'Долгота', data: 'longitude'},
|
||||||
|
// {label: 'ID города', data: 'city_id'},
|
||||||
|
{label: 'Город', data: 'city'},
|
||||||
|
]
|
Loading…
Reference in New Issue
Block a user