feat: add category management UI and relocate navbar controls to quick-actions modal
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
|
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
|
||||||
|
import type { SelectProps } from '@mui/material/Select';
|
||||||
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n';
|
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n';
|
||||||
|
|
||||||
const LABEL_LOOKUP: Record<SupportedLanguage, string> = {
|
const LABEL_LOOKUP: Record<SupportedLanguage, string> = {
|
||||||
@@ -7,14 +8,19 @@ const LABEL_LOOKUP: Record<SupportedLanguage, string> = {
|
|||||||
en: 'language.en'
|
en: 'language.en'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LanguageSwitcher() {
|
interface LanguageSwitcherProps {
|
||||||
|
selectProps?: Partial<SelectProps<SupportedLanguage>>;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSwitcher({ selectProps, fullWidth = false }: LanguageSwitcherProps = {}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const currentLanguage =
|
const currentLanguage =
|
||||||
SUPPORTED_LANGUAGES.find((language) => i18n.language.startsWith(language)) ?? 'zh-Hant';
|
SUPPORTED_LANGUAGES.find((language) => i18n.language.startsWith(language)) ?? 'zh-Hant';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl size="small">
|
<FormControl size="small" fullWidth={fullWidth} sx={{ minWidth: fullWidth ? undefined : 140 }}>
|
||||||
<InputLabel id="language-switcher-label">{t('language.label')}</InputLabel>
|
<InputLabel id="language-switcher-label">{t('language.label')}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
labelId="language-switcher-label"
|
labelId="language-switcher-label"
|
||||||
@@ -24,7 +30,7 @@ export function LanguageSwitcher() {
|
|||||||
const selected = event.target.value as SupportedLanguage;
|
const selected = event.target.value as SupportedLanguage;
|
||||||
void i18n.changeLanguage(selected);
|
void i18n.changeLanguage(selected);
|
||||||
}}
|
}}
|
||||||
sx={{ minWidth: 140 }}
|
{...selectProps}
|
||||||
>
|
>
|
||||||
{SUPPORTED_LANGUAGES.map((language) => (
|
{SUPPORTED_LANGUAGES.map((language) => (
|
||||||
<MenuItem key={language} value={language}>
|
<MenuItem key={language} value={language}>
|
||||||
|
@@ -6,16 +6,17 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
|
||||||
Stack,
|
Stack,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
useMediaQuery
|
useMediaQuery
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import { NavControlsDialog } from '../overlay/NavControlsDialog';
|
||||||
|
|
||||||
interface PrimaryLayoutProps {
|
interface PrimaryLayoutProps {
|
||||||
sidebar: ReactNode;
|
sidebar: ReactNode;
|
||||||
@@ -30,6 +31,7 @@ export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayo
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(isDesktop);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(isDesktop);
|
||||||
|
const [isNavControlsOpen, setIsNavControlsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSidebarOpen(isDesktop);
|
setIsSidebarOpen(isDesktop);
|
||||||
@@ -67,16 +69,12 @@ export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayo
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" spacing={2} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
<Tooltip title={t('nav.openControls')}>
|
||||||
<Link href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" underline="hover" color="text.secondary">
|
<IconButton color="primary" onClick={() => setIsNavControlsOpen(true)}>
|
||||||
{t('nav.openStreetMap')}
|
<MoreVertIcon />
|
||||||
</Link>
|
</IconButton>
|
||||||
<Link href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" underline="hover" color="text.secondary">
|
</Tooltip>
|
||||||
{t('nav.reactLeaflet')}
|
|
||||||
</Link>
|
|
||||||
</Stack>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
@@ -116,6 +114,7 @@ export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayo
|
|||||||
|
|
||||||
<Box component="main" sx={{ flexGrow: 1, minHeight: 0 }}>{content}</Box>
|
<Box component="main" sx={{ flexGrow: 1, minHeight: 0 }}>{content}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<NavControlsDialog open={isNavControlsOpen} onClose={() => setIsNavControlsOpen(false)} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ import { Button, Card, CardContent, Stack, Typography } from '@mui/material';
|
|||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { VisitList } from '../../features/visits/components/VisitList';
|
import { VisitList } from '../../features/visits/components/VisitList';
|
||||||
import { VisitSummary } from '../../features/visits/components/VisitSummary';
|
import { VisitSummary } from '../../features/visits/components/VisitSummary';
|
||||||
|
import { VisitCategorySelector } from '../../features/categories/components/VisitCategorySelector';
|
||||||
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||||
|
|
||||||
export function VisitSidebar() {
|
export function VisitSidebar() {
|
||||||
@@ -31,6 +32,8 @@ export function VisitSidebar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<VisitCategorySelector />
|
||||||
|
|
||||||
<VisitSummary />
|
<VisitSummary />
|
||||||
|
|
||||||
<Card sx={{ flex: 1, overflow: 'hidden', display: 'flex' }}>
|
<Card sx={{ flex: 1, overflow: 'hidden', display: 'flex' }}>
|
||||||
|
72
client/src/components/overlay/NavControlsDialog.tsx
Normal file
72
client/src/components/overlay/NavControlsDialog.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Dialog, DialogActions, DialogContent, DialogTitle, Stack, Button, Typography } from '@mui/material';
|
||||||
|
import LaunchIcon from '@mui/icons-material/Launch';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { LanguageSwitcher } from '../layout/LanguageSwitcher';
|
||||||
|
import { VisitCategoryNavSelect } from '../../features/categories/components/VisitCategoryNavSelect';
|
||||||
|
|
||||||
|
interface NavControlsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavControlsDialog({ open, onClose }: NavControlsDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>{t('nav.controls')}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{t('nav.controlsDescription')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
{t('categories.selectLabel')}
|
||||||
|
</Typography>
|
||||||
|
<VisitCategoryNavSelect fullWidth />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
{t('language.label')}
|
||||||
|
</Typography>
|
||||||
|
<LanguageSwitcher fullWidth />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
{t('nav.links')}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<LaunchIcon fontSize="small" />}
|
||||||
|
component="a"
|
||||||
|
href="https://www.openstreetmap.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('nav.openStreetMap')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<LaunchIcon fontSize="small" />}
|
||||||
|
component="a"
|
||||||
|
href="https://react-leaflet.js.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('nav.reactLeaflet')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>{t('nav.close')}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@@ -9,10 +9,12 @@ import {
|
|||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||||
import { VisitForm } from '../../features/visits/components/VisitForm';
|
import { VisitForm } from '../../features/visits/components/VisitForm';
|
||||||
|
import { useVisitCategorySelectionStore } from '../../state/useVisitCategorySelectionStore';
|
||||||
|
|
||||||
export function VisitModal() {
|
export function VisitModal() {
|
||||||
const { isOpen, close, editingVisit, initialLocation } = useVisitModalStore();
|
const { isOpen, close, editingVisit, initialLocation } = useVisitModalStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onClose={close} maxWidth="sm" fullWidth>
|
<Dialog open={isOpen} onClose={close} maxWidth="sm" fullWidth>
|
||||||
@@ -30,7 +32,13 @@ export function VisitModal() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<VisitForm editingVisit={editingVisit} initialLocation={initialLocation} onCompleted={close} onCancel={close} />
|
<VisitForm
|
||||||
|
editingVisit={editingVisit}
|
||||||
|
initialLocation={initialLocation}
|
||||||
|
selectedCategoryId={selectedCategoryId}
|
||||||
|
onCompleted={close}
|
||||||
|
onCancel={close}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
type SelectChangeEvent
|
||||||
|
} from '@mui/material';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import {
|
||||||
|
UNCATEGORIZED_CATEGORY_ID,
|
||||||
|
useVisitCategorySelectionStore
|
||||||
|
} from '../../../state/useVisitCategorySelectionStore';
|
||||||
|
import { useVisitCategoriesQuery } from '../hooks/useVisitCategoryQueries';
|
||||||
|
|
||||||
|
const ALL_OPTION_VALUE = '__all__';
|
||||||
|
|
||||||
|
interface VisitCategoryNavSelectProps {
|
||||||
|
fullWidth?: boolean;
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisitCategoryNavSelect({ fullWidth = false, sx }: VisitCategoryNavSelectProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: categories = [], isLoading } = useVisitCategoriesQuery();
|
||||||
|
const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId);
|
||||||
|
const setSelectedCategoryId = useVisitCategorySelectionStore((state) => state.setSelectedCategoryId);
|
||||||
|
|
||||||
|
const selectValue = selectedCategoryId ?? ALL_OPTION_VALUE;
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ value: ALL_OPTION_VALUE, label: t('categories.all') },
|
||||||
|
{ value: UNCATEGORIZED_CATEGORY_ID, label: t('categories.uncategorized') },
|
||||||
|
...categories.map((category) => ({ value: category.id, label: category.name }))
|
||||||
|
];
|
||||||
|
}, [categories, t]);
|
||||||
|
|
||||||
|
const handleChange = (event: SelectChangeEvent<string>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value === ALL_OPTION_VALUE) {
|
||||||
|
setSelectedCategoryId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCategoryId(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCategoryId || selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = categories.some((category) => category.id === selectedCategoryId);
|
||||||
|
if (!exists) {
|
||||||
|
setSelectedCategoryId(null);
|
||||||
|
}
|
||||||
|
}, [categories, selectedCategoryId, setSelectedCategoryId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
sx={{ minWidth: fullWidth ? undefined : 180, ...sx }}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<InputLabel id="visit-category-nav-select-label">{t('categories.selectLabel')}</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="visit-category-nav-select-label"
|
||||||
|
label={t('categories.selectLabel')}
|
||||||
|
value={selectValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,306 @@
|
|||||||
|
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import {
|
||||||
|
useCreateVisitCategoryMutation,
|
||||||
|
useDeleteVisitCategoryMutation,
|
||||||
|
useUpdateVisitCategoryMutation,
|
||||||
|
useVisitCategoriesQuery
|
||||||
|
} from '../hooks/useVisitCategoryQueries';
|
||||||
|
import { useVisitCategorySelectionStore, UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import type { VisitCategory } from '../types';
|
||||||
|
|
||||||
|
export function VisitCategorySelector() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: categories = [], isLoading } = useVisitCategoriesQuery();
|
||||||
|
const createCategoryMutation = useCreateVisitCategoryMutation();
|
||||||
|
const updateCategoryMutation = useUpdateVisitCategoryMutation();
|
||||||
|
const deleteCategoryMutation = useDeleteVisitCategoryMutation();
|
||||||
|
const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId);
|
||||||
|
const setSelectedCategoryId = useVisitCategorySelectionStore((state) => state.setSelectedCategoryId);
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
|
const [editingCategory, setEditingCategory] = useState<VisitCategory | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [categoryPendingDeletion, setCategoryPendingDeletion] = useState<VisitCategory | null>(null);
|
||||||
|
|
||||||
|
const chips = useMemo(() => {
|
||||||
|
const chipItems = [
|
||||||
|
{
|
||||||
|
id: null as string | null,
|
||||||
|
label: t('categories.all'),
|
||||||
|
isSelected: selectedCategoryId === null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNCATEGORIZED_CATEGORY_ID,
|
||||||
|
label: t('categories.uncategorized'),
|
||||||
|
isSelected: selectedCategoryId === UNCATEGORIZED_CATEGORY_ID
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...chipItems,
|
||||||
|
...categories.map((category) => ({
|
||||||
|
id: category.id,
|
||||||
|
label: category.name,
|
||||||
|
isSelected: selectedCategoryId === category.id
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}, [categories, selectedCategoryId, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCategoryId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = categories.some((category) => category.id === selectedCategoryId);
|
||||||
|
if (!exists) {
|
||||||
|
setSelectedCategoryId(null);
|
||||||
|
}
|
||||||
|
}, [categories, selectedCategoryId, setSelectedCategoryId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingCategory) {
|
||||||
|
setRenameValue(editingCategory.name);
|
||||||
|
} else {
|
||||||
|
setRenameValue('');
|
||||||
|
}
|
||||||
|
}, [editingCategory]);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = newCategoryName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const category = await createCategoryMutation.mutateAsync({ name: trimmed });
|
||||||
|
setNewCategoryName('');
|
||||||
|
setSelectedCategoryId(category.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(category: VisitCategory) {
|
||||||
|
setEditingCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestDelete(category: VisitCategory) {
|
||||||
|
setCategoryPendingDeletion(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRenameSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingCategory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === editingCategory.name) {
|
||||||
|
setEditingCategory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCategoryMutation.mutateAsync({ id: editingCategory.id, data: { name: trimmed } });
|
||||||
|
setEditingCategory(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfirm() {
|
||||||
|
if (!categoryPendingDeletion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryId = categoryPendingDeletion.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteCategoryMutation.mutateAsync(categoryId);
|
||||||
|
if (selectedCategoryId === categoryId) {
|
||||||
|
setSelectedCategoryId(null);
|
||||||
|
}
|
||||||
|
setCategoryPendingDeletion(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCreating = createCategoryMutation.isPending;
|
||||||
|
const isRenaming = updateCategoryMutation.isPending;
|
||||||
|
const isDeleting = deleteCategoryMutation.isPending;
|
||||||
|
|
||||||
|
const handleCloseRenameDialog = () => {
|
||||||
|
if (isRenaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditingCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteDialog = () => {
|
||||||
|
if (isDeleting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCategoryPendingDeletion(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent sx={{ py: 1.5, px: 2 }}>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
{t('categories.title')}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Chip
|
||||||
|
key={chip.id ?? 'all'}
|
||||||
|
label={chip.label}
|
||||||
|
color={chip.isSelected ? 'primary' : 'default'}
|
||||||
|
variant={chip.isSelected ? 'filled' : 'outlined'}
|
||||||
|
onClick={() => setSelectedCategoryId(chip.id)}
|
||||||
|
disabled={isLoading && chip.id !== null && chip.id !== UNCATEGORIZED_CATEGORY_ID}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{isLoading ? <CircularProgress size={18} /> : null}
|
||||||
|
</Stack>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||||
|
placeholder={t('categories.addPlaceholder')}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
disabled={!newCategoryName.trim() || isCreating}
|
||||||
|
>
|
||||||
|
{t('categories.addButton')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{categories.length ? (
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
{t('categories.manage')}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Stack
|
||||||
|
key={category.id}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{ border: 1, borderColor: 'divider', borderRadius: 1, px: 1, py: 0.75 }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.primary"
|
||||||
|
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', pr: 1 }}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEdit(category)}
|
||||||
|
aria-label={t('categories.edit')}
|
||||||
|
disabled={isRenaming || isDeleting}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleRequestDelete(category)}
|
||||||
|
aria-label={t('categories.delete')}
|
||||||
|
disabled={isRenaming || isDeleting}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(editingCategory)} onClose={handleCloseRenameDialog} fullWidth maxWidth="xs">
|
||||||
|
<Box component="form" onSubmit={handleRenameSubmit}>
|
||||||
|
<DialogTitle>{t('categories.renameTitle')}</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
fullWidth
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(event) => setRenameValue(event.target.value)}
|
||||||
|
placeholder={t('categories.renamePlaceholder')}
|
||||||
|
disabled={isRenaming}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseRenameDialog} disabled={isRenaming}>
|
||||||
|
{t('nav.close')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isRenaming || !renameValue.trim()}>
|
||||||
|
{t('categories.renameSubmit')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(categoryPendingDeletion)} onClose={handleCloseDeleteDialog} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>{t('categories.deleteTitle')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{t('categories.deleteMessage', { name: categoryPendingDeletion?.name ?? '' })}
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDeleteDialog} disabled={isDeleting}>
|
||||||
|
{t('nav.close')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleDeleteConfirm} color="error" variant="contained" disabled={isDeleting}>
|
||||||
|
{t('categories.deleteConfirm')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,54 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
VisitCategory,
|
||||||
|
VisitCategoryCreateInput,
|
||||||
|
VisitCategoryId,
|
||||||
|
VisitCategoryUpdateInput
|
||||||
|
} from '../types';
|
||||||
|
import { useVisitCategoryRepository } from '../../visits/context/VisitProvider';
|
||||||
|
|
||||||
|
const VISIT_CATEGORIES_QUERY_KEY = ['visitCategories'];
|
||||||
|
|
||||||
|
export function useVisitCategoriesQuery() {
|
||||||
|
const repository = useVisitCategoryRepository();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: VISIT_CATEGORIES_QUERY_KEY,
|
||||||
|
queryFn: () => repository.getAll()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateVisitCategoryMutation() {
|
||||||
|
const repository = useVisitCategoryRepository();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: VisitCategoryCreateInput) => repository.create(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: VISIT_CATEGORIES_QUERY_KEY });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateVisitCategoryMutation() {
|
||||||
|
const repository = useVisitCategoryRepository();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: VisitCategoryId; data: VisitCategoryUpdateInput }) =>
|
||||||
|
repository.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: VISIT_CATEGORIES_QUERY_KEY });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteVisitCategoryMutation() {
|
||||||
|
const repository = useVisitCategoryRepository();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: VisitCategoryId) => repository.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: VISIT_CATEGORIES_QUERY_KEY });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VisitCategoryListData = VisitCategory[];
|
@@ -0,0 +1,70 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { loadFromStorage, saveToStorage } from '../../../lib/browserStorage';
|
||||||
|
import type { VisitCategory, VisitCategoryRepository } from '../types';
|
||||||
|
import { localVisitRepository } from '../../visits/storage/localVisitRepository';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'traveling-around-the-world::visitCategories';
|
||||||
|
|
||||||
|
function stamp(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCategories(): VisitCategory[] {
|
||||||
|
return loadFromStorage<VisitCategory[]>(STORAGE_KEY, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCategories(categories: VisitCategory[]): void {
|
||||||
|
saveToStorage(STORAGE_KEY, categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localVisitCategoryRepository: VisitCategoryRepository = {
|
||||||
|
async getAll() {
|
||||||
|
return loadCategories();
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
return loadCategories().find((category) => category.id === id);
|
||||||
|
},
|
||||||
|
async create(input) {
|
||||||
|
const categories = loadCategories();
|
||||||
|
const now = stamp();
|
||||||
|
const category: VisitCategory = {
|
||||||
|
id: nanoid(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
name: input.name
|
||||||
|
};
|
||||||
|
persistCategories([category, ...categories]);
|
||||||
|
return category;
|
||||||
|
},
|
||||||
|
async update(id, input) {
|
||||||
|
const categories = loadCategories();
|
||||||
|
const idx = categories.findIndex((category) => category.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
throw new Error(`Category with id ${id} not found`);
|
||||||
|
}
|
||||||
|
const updated: VisitCategory = {
|
||||||
|
...categories[idx],
|
||||||
|
...input,
|
||||||
|
updatedAt: stamp()
|
||||||
|
};
|
||||||
|
categories[idx] = updated;
|
||||||
|
persistCategories(categories);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
async remove(id) {
|
||||||
|
const categories = loadCategories().filter((category) => category.id !== id);
|
||||||
|
persistCategories(categories);
|
||||||
|
if (this.detachVisitsFromCategory) {
|
||||||
|
await this.detachVisitsFromCategory(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async detachVisitsFromCategory(categoryId) {
|
||||||
|
const visits = await localVisitRepository.getAll();
|
||||||
|
const updates = visits
|
||||||
|
.filter((visit) => visit.categoryId === categoryId)
|
||||||
|
.map((visit) => localVisitRepository.update(visit.id, { categoryId: undefined }));
|
||||||
|
await Promise.all(updates);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalVisitCategoryRepository = typeof localVisitCategoryRepository;
|
25
client/src/features/categories/types.ts
Normal file
25
client/src/features/categories/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type VisitCategoryId = string;
|
||||||
|
|
||||||
|
export interface VisitCategory {
|
||||||
|
id: VisitCategoryId;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitCategoryCreateInput {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitCategoryUpdateInput {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitCategoryRepository {
|
||||||
|
getAll(): Promise<VisitCategory[]>;
|
||||||
|
getById(id: VisitCategoryId): Promise<VisitCategory | undefined>;
|
||||||
|
create(input: VisitCategoryCreateInput): Promise<VisitCategory>;
|
||||||
|
update(id: VisitCategoryId, input: VisitCategoryUpdateInput): Promise<VisitCategory>;
|
||||||
|
remove(id: VisitCategoryId): Promise<void>;
|
||||||
|
detachVisitsFromCategory?(categoryId: VisitCategoryId): Promise<void>;
|
||||||
|
}
|
@@ -7,6 +7,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
|
MenuItem,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
@@ -15,6 +16,8 @@ import type { Visit, VisitCreateInput } from '../types';
|
|||||||
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
|
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { reverseGeocode } from '../utils/geocoding';
|
import { reverseGeocode } from '../utils/geocoding';
|
||||||
|
import { useVisitCategoriesQuery } from '../../categories/hooks/useVisitCategoryQueries';
|
||||||
|
import { UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore';
|
||||||
|
|
||||||
function getTodayDate(): string {
|
function getTodayDate(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -30,6 +33,7 @@ const visitFormSchema = z
|
|||||||
start: z.string().min(1, 'validation.startDateRequired'),
|
start: z.string().min(1, 'validation.startDateRequired'),
|
||||||
end: z.string().optional(),
|
end: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
categoryId: z.string().optional(),
|
||||||
lat: z.number(),
|
lat: z.number(),
|
||||||
lng: z.number()
|
lng: z.number()
|
||||||
})
|
})
|
||||||
@@ -48,15 +52,29 @@ interface VisitFormProps {
|
|||||||
onCompleted?: () => void;
|
onCompleted?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
initialLocation?: { lat: number; lng: number };
|
initialLocation?: { lat: number; lng: number };
|
||||||
|
selectedCategoryId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation }: VisitFormProps) {
|
export function VisitForm({
|
||||||
|
editingVisit,
|
||||||
|
onCompleted,
|
||||||
|
onCancel,
|
||||||
|
initialLocation,
|
||||||
|
selectedCategoryId
|
||||||
|
}: VisitFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isGeocoding, setIsGeocoding] = useState(false);
|
const [isGeocoding, setIsGeocoding] = useState(false);
|
||||||
const [geocodeFailed, setGeocodeFailed] = useState(false);
|
const [geocodeFailed, setGeocodeFailed] = useState(false);
|
||||||
|
|
||||||
const createMutation = useCreateVisitMutation();
|
const createMutation = useCreateVisitMutation();
|
||||||
const updateMutation = useUpdateVisitMutation();
|
const updateMutation = useUpdateVisitMutation();
|
||||||
|
const { data: categories = [] } = useVisitCategoriesQuery();
|
||||||
|
|
||||||
|
const defaultCategoryId = editingVisit?.categoryId
|
||||||
|
? editingVisit.categoryId
|
||||||
|
: selectedCategoryId && selectedCategoryId !== UNCATEGORIZED_CATEGORY_ID
|
||||||
|
? selectedCategoryId
|
||||||
|
: '';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -72,6 +90,7 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation
|
|||||||
start: editingVisit?.date.start ?? getTodayDate(),
|
start: editingVisit?.date.start ?? getTodayDate(),
|
||||||
end: editingVisit?.date.end ?? '',
|
end: editingVisit?.date.end ?? '',
|
||||||
notes: editingVisit?.notes ?? '',
|
notes: editingVisit?.notes ?? '',
|
||||||
|
categoryId: defaultCategoryId,
|
||||||
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
|
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
|
||||||
lng: editingVisit?.location.lng ?? initialLocation?.lng ?? 0
|
lng: editingVisit?.location.lng ?? initialLocation?.lng ?? 0
|
||||||
}
|
}
|
||||||
@@ -84,6 +103,25 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation
|
|||||||
}
|
}
|
||||||
}, [initialLocation, editingVisit, setValue]);
|
}, [initialLocation, editingVisit, setValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingVisit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedCategoryId || selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) {
|
||||||
|
setValue('categoryId', '', { shouldDirty: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = categories.some((category) => category.id === selectedCategoryId);
|
||||||
|
if (!exists) {
|
||||||
|
setValue('categoryId', '', { shouldDirty: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue('categoryId', selectedCategoryId, { shouldDirty: false });
|
||||||
|
}, [categories, editingVisit, selectedCategoryId, setValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingVisit) {
|
if (editingVisit) {
|
||||||
setValue('start', editingVisit.date.start, { shouldDirty: false });
|
setValue('start', editingVisit.date.start, { shouldDirty: false });
|
||||||
@@ -151,6 +189,7 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation
|
|||||||
|
|
||||||
async function onSubmit(values: VisitFormSchema) {
|
async function onSubmit(values: VisitFormSchema) {
|
||||||
const payload: VisitCreateInput = {
|
const payload: VisitCreateInput = {
|
||||||
|
categoryId: values.categoryId ? values.categoryId : undefined,
|
||||||
location: {
|
location: {
|
||||||
country: values.country,
|
country: values.country,
|
||||||
city: values.city,
|
city: values.city,
|
||||||
@@ -200,6 +239,23 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t('form.category')}
|
||||||
|
{...register('categoryId')}
|
||||||
|
error={Boolean(errors.categoryId)}
|
||||||
|
helperText={errors.categoryId ? t(errors.categoryId.message ?? '') : ''}
|
||||||
|
fullWidth
|
||||||
|
SelectProps={{ displayEmpty: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">{t('categories.uncategorized')}</MenuItem>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<MenuItem key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<TextField
|
<TextField
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { Button, Card, CardActions, CardContent, CircularProgress, Stack, Typography } from '@mui/material';
|
import { Button, Card, CardActions, CardContent, CircularProgress, Stack, Typography } from '@mui/material';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { useDeleteVisitMutation, useVisitsQuery } from '../hooks/useVisitQueries';
|
import { useDeleteVisitMutation, useVisitsQuery } from '../hooks/useVisitQueries';
|
||||||
import type { Visit } from '../types';
|
import type { Visit } from '../types';
|
||||||
|
import { useVisitCategorySelectionStore, UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore';
|
||||||
|
|
||||||
interface VisitListProps {
|
interface VisitListProps {
|
||||||
onEdit: (visit: Visit) => void;
|
onEdit: (visit: Visit) => void;
|
||||||
@@ -13,6 +15,19 @@ export function VisitList({ onEdit }: VisitListProps) {
|
|||||||
const { data: visits = [], isLoading } = useVisitsQuery();
|
const { data: visits = [], isLoading } = useVisitsQuery();
|
||||||
const deleteMutation = useDeleteVisitMutation();
|
const deleteMutation = useDeleteVisitMutation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId);
|
||||||
|
|
||||||
|
const filteredVisits = useMemo(() => {
|
||||||
|
if (!selectedCategoryId) {
|
||||||
|
return visits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) {
|
||||||
|
return visits.filter((visit) => !visit.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return visits.filter((visit) => visit.categoryId === selectedCategoryId);
|
||||||
|
}, [visits, selectedCategoryId]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -25,17 +40,17 @@ export function VisitList({ onEdit }: VisitListProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visits.length) {
|
if (!filteredVisits.length) {
|
||||||
return (
|
return (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ p: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ p: 3 }}>
|
||||||
{t('list.empty')}
|
{selectedCategoryId ? t('list.emptyForCategory') : t('list.empty')}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2} sx={{ p: 2 }}>
|
<Stack spacing={2} sx={{ p: 2 }}>
|
||||||
{visits.map((visit) => (
|
{filteredVisits.map((visit) => (
|
||||||
<Card key={visit.id} variant="outlined">
|
<Card key={visit.id} variant="outlined">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" fontWeight={600} gutterBottom>
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
||||||
|
@@ -2,19 +2,33 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Box, Card, CardContent, Typography } from '@mui/material';
|
import { Box, Card, CardContent, Typography } from '@mui/material';
|
||||||
import { useVisitsQuery } from '../hooks/useVisitQueries';
|
import { useVisitsQuery } from '../hooks/useVisitQueries';
|
||||||
|
import { useVisitCategorySelectionStore, UNCATEGORIZED_CATEGORY_ID } from '../../../state/useVisitCategorySelectionStore';
|
||||||
|
|
||||||
export function VisitSummary() {
|
export function VisitSummary() {
|
||||||
const { data: visits = [] } = useVisitsQuery();
|
const { data: visits = [] } = useVisitsQuery();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const selectedCategoryId = useVisitCategorySelectionStore((state) => state.selectedCategoryId);
|
||||||
|
|
||||||
|
const filteredVisits = useMemo(() => {
|
||||||
|
if (!selectedCategoryId) {
|
||||||
|
return visits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCategoryId === UNCATEGORIZED_CATEGORY_ID) {
|
||||||
|
return visits.filter((visit) => !visit.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return visits.filter((visit) => visit.categoryId === selectedCategoryId);
|
||||||
|
}, [visits, selectedCategoryId]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const countries = new Set<string>();
|
const countries = new Set<string>();
|
||||||
visits.forEach((visit) => countries.add(visit.location.country));
|
filteredVisits.forEach((visit) => countries.add(visit.location.country));
|
||||||
return {
|
return {
|
||||||
totalVisits: visits.length,
|
totalVisits: filteredVisits.length,
|
||||||
uniqueCountries: countries.size
|
uniqueCountries: countries.size
|
||||||
};
|
};
|
||||||
}, [visits]);
|
}, [filteredVisits]);
|
||||||
|
|
||||||
const summaryItems = [
|
const summaryItems = [
|
||||||
{ label: t('summary.totalVisits'), value: stats.totalVisits },
|
{ label: t('summary.totalVisits'), value: stats.totalVisits },
|
||||||
|
@@ -3,6 +3,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { localVisitRepository } from '../storage/localVisitRepository';
|
import { localVisitRepository } from '../storage/localVisitRepository';
|
||||||
import type { VisitRepository } from '../types';
|
import type { VisitRepository } from '../types';
|
||||||
import { VisitApiClient } from '../api/visitApiClient';
|
import { VisitApiClient } from '../api/visitApiClient';
|
||||||
|
import type { VisitCategoryRepository } from '../../categories/types';
|
||||||
|
import { localVisitCategoryRepository } from '../../categories/storage/localVisitCategoryRepository';
|
||||||
|
|
||||||
export type VisitProviderMode = 'local' | 'api';
|
export type VisitProviderMode = 'local' | 'api';
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ interface VisitProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VisitRepositoryContext = createContext<VisitRepository>(localVisitRepository);
|
const VisitRepositoryContext = createContext<VisitRepository>(localVisitRepository);
|
||||||
|
const VisitCategoryRepositoryContext = createContext<VisitCategoryRepository>(localVisitCategoryRepository);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }: VisitProviderProps) {
|
export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }: VisitProviderProps) {
|
||||||
@@ -23,9 +26,15 @@ export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }:
|
|||||||
return localVisitRepository;
|
return localVisitRepository;
|
||||||
}, [mode, apiBaseUrl]);
|
}, [mode, apiBaseUrl]);
|
||||||
|
|
||||||
|
const categoryRepository = useMemo<VisitCategoryRepository>(() => {
|
||||||
|
return localVisitCategoryRepository;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisitRepositoryContext.Provider value={repository}>
|
<VisitRepositoryContext.Provider value={repository}>
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
<VisitCategoryRepositoryContext.Provider value={categoryRepository}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</VisitCategoryRepositoryContext.Provider>
|
||||||
</VisitRepositoryContext.Provider>
|
</VisitRepositoryContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,3 +42,7 @@ export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }:
|
|||||||
export function useVisitRepository(): VisitRepository {
|
export function useVisitRepository(): VisitRepository {
|
||||||
return useContext(VisitRepositoryContext);
|
return useContext(VisitRepositoryContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useVisitCategoryRepository(): VisitCategoryRepository {
|
||||||
|
return useContext(VisitCategoryRepositoryContext);
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { loadFromStorage, saveToStorage } from '../../../lib/browserStorage';
|
import { loadFromStorage, saveToStorage } from '../../../lib/browserStorage';
|
||||||
import type { Visit, VisitCreateInput, VisitId, VisitRepository, VisitUpdateInput } from '../types';
|
import type { Visit, VisitRepository } from '../types';
|
||||||
|
|
||||||
const STORAGE_KEY = 'traveling-around-the-world::visits';
|
const STORAGE_KEY = 'traveling-around-the-world::visits';
|
||||||
|
|
||||||
|
@@ -5,7 +5,12 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"openStreetMap": "OpenStreetMap",
|
"openStreetMap": "OpenStreetMap",
|
||||||
"reactLeaflet": "React Leaflet"
|
"reactLeaflet": "React Leaflet",
|
||||||
|
"controls": "Quick Actions",
|
||||||
|
"controlsDescription": "Manage common settings and resources",
|
||||||
|
"links": "Resources",
|
||||||
|
"openControls": "More",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
@@ -22,6 +27,23 @@
|
|||||||
"toggleOpen": "Show sidebar",
|
"toggleOpen": "Show sidebar",
|
||||||
"toggleClose": "Hide sidebar"
|
"toggleClose": "Hide sidebar"
|
||||||
},
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "Collections",
|
||||||
|
"addPlaceholder": "Name a new collection",
|
||||||
|
"addButton": "Add",
|
||||||
|
"all": "All",
|
||||||
|
"uncategorized": "Uncategorized",
|
||||||
|
"selectLabel": "Collection",
|
||||||
|
"manage": "Manage collections",
|
||||||
|
"edit": "Rename",
|
||||||
|
"delete": "Delete",
|
||||||
|
"renameTitle": "Rename collection",
|
||||||
|
"renamePlaceholder": "Enter a new name",
|
||||||
|
"renameSubmit": "Save",
|
||||||
|
"deleteTitle": "Delete collection",
|
||||||
|
"deleteMessage": "Delete \"{{name}}\"? Existing visits will move to Uncategorized.",
|
||||||
|
"deleteConfirm": "Delete"
|
||||||
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Stats",
|
"title": "Stats",
|
||||||
"totalVisits": "Trips",
|
"totalVisits": "Trips",
|
||||||
@@ -30,6 +52,7 @@
|
|||||||
"list": {
|
"list": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"empty": "No trips recorded yet.",
|
"empty": "No trips recorded yet.",
|
||||||
|
"emptyForCategory": "No trips recorded in this collection yet.",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
@@ -44,6 +67,7 @@
|
|||||||
"notesPlaceholder": "Capture thoughts or reminders about the trip",
|
"notesPlaceholder": "Capture thoughts or reminders about the trip",
|
||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
"longitude": "Longitude",
|
"longitude": "Longitude",
|
||||||
|
"category": "Collection",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"create": "Add Visit",
|
"create": "Add Visit",
|
||||||
"update": "Save Changes"
|
"update": "Save Changes"
|
||||||
|
@@ -5,7 +5,12 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"openStreetMap": "OpenStreetMap",
|
"openStreetMap": "OpenStreetMap",
|
||||||
"reactLeaflet": "React Leaflet"
|
"reactLeaflet": "React Leaflet",
|
||||||
|
"controls": "快速操作",
|
||||||
|
"controlsDescription": "管理常用設定與資源",
|
||||||
|
"links": "資源",
|
||||||
|
"openControls": "更多",
|
||||||
|
"close": "關閉"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "語言",
|
"label": "語言",
|
||||||
@@ -22,6 +27,23 @@
|
|||||||
"toggleOpen": "展開側邊欄",
|
"toggleOpen": "展開側邊欄",
|
||||||
"toggleClose": "收合側邊欄"
|
"toggleClose": "收合側邊欄"
|
||||||
},
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "旅程分類",
|
||||||
|
"addPlaceholder": "輸入新的分類名稱",
|
||||||
|
"addButton": "新增",
|
||||||
|
"all": "全部",
|
||||||
|
"uncategorized": "未分類",
|
||||||
|
"selectLabel": "分類",
|
||||||
|
"manage": "管理分類",
|
||||||
|
"edit": "重新命名",
|
||||||
|
"delete": "刪除",
|
||||||
|
"renameTitle": "重新命名分類",
|
||||||
|
"renamePlaceholder": "輸入新的名稱",
|
||||||
|
"renameSubmit": "儲存",
|
||||||
|
"deleteTitle": "刪除分類",
|
||||||
|
"deleteMessage": "確定要刪除「{{name}}」嗎?既有旅程會移至未分類。",
|
||||||
|
"deleteConfirm": "刪除"
|
||||||
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "統計",
|
"title": "統計",
|
||||||
"totalVisits": "旅程數",
|
"totalVisits": "旅程數",
|
||||||
@@ -30,6 +52,7 @@
|
|||||||
"list": {
|
"list": {
|
||||||
"loading": "讀取中...",
|
"loading": "讀取中...",
|
||||||
"empty": "目前尚未新增任何旅遊記錄。",
|
"empty": "目前尚未新增任何旅遊記錄。",
|
||||||
|
"emptyForCategory": "這個分類還沒有旅程。",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"delete": "刪除"
|
"delete": "刪除"
|
||||||
},
|
},
|
||||||
@@ -44,6 +67,7 @@
|
|||||||
"notesPlaceholder": "留下旅程的感想或提醒",
|
"notesPlaceholder": "留下旅程的感想或提醒",
|
||||||
"latitude": "緯度",
|
"latitude": "緯度",
|
||||||
"longitude": "經度",
|
"longitude": "經度",
|
||||||
|
"category": "分類",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"create": "新增足跡",
|
"create": "新增足跡",
|
||||||
"update": "儲存變更"
|
"update": "儲存變更"
|
||||||
|
21
client/src/state/useVisitCategorySelectionStore.ts
Normal file
21
client/src/state/useVisitCategorySelectionStore.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { loadFromStorage, saveToStorage } from '../lib/browserStorage';
|
||||||
|
|
||||||
|
export const UNCATEGORIZED_CATEGORY_ID = '__uncategorized__';
|
||||||
|
const CATEGORY_SELECTION_STORAGE_KEY = 'traveling-around-the-world::selectedCategoryId';
|
||||||
|
|
||||||
|
const initialSelectedCategoryId = loadFromStorage<string | null>(CATEGORY_SELECTION_STORAGE_KEY, null);
|
||||||
|
|
||||||
|
interface VisitCategorySelectionState {
|
||||||
|
selectedCategoryId: string | null;
|
||||||
|
setSelectedCategoryId: (categoryId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVisitCategorySelectionStore = create<VisitCategorySelectionState>((set) => ({
|
||||||
|
selectedCategoryId: initialSelectedCategoryId,
|
||||||
|
setSelectedCategoryId: (categoryId) =>
|
||||||
|
set(() => {
|
||||||
|
saveToStorage(CATEGORY_SELECTION_STORAGE_KEY, categoryId);
|
||||||
|
return { selectedCategoryId: categoryId };
|
||||||
|
})
|
||||||
|
}));
|
@@ -14,6 +14,7 @@ export interface VisitDateRange {
|
|||||||
|
|
||||||
export interface VisitDto {
|
export interface VisitDto {
|
||||||
id: VisitId;
|
id: VisitId;
|
||||||
|
categoryId?: string;
|
||||||
location: VisitLocation;
|
location: VisitLocation;
|
||||||
date: VisitDateRange;
|
date: VisitDateRange;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
Reference in New Issue
Block a user