hPiBot openclaw ve Opencode ilk versiyonu[B
This commit is contained in:
202
frontend/src/App.tsx
Normal file
202
frontend/src/App.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { CssBaseline, Box } from '@mui/material';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
// Context providers
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { SocketProvider } from './contexts/SocketContext';
|
||||
|
||||
// Components
|
||||
import Navbar from './components/Layout/Navbar';
|
||||
import ProtectedRoute from './components/Auth/ProtectedRoute';
|
||||
import GoogleCallback from './components/Auth/GoogleCallback';
|
||||
|
||||
// Pages
|
||||
import { LoginPage, RegisterPage } from './pages/Auth';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { ListsPage, ListDetailPage, ListEditPage } from './pages/Lists';
|
||||
import { ProductsPage } from './pages/Products';
|
||||
import { ProfilePage } from './pages/Profile';
|
||||
import { AdminPage } from './pages/Admin';
|
||||
|
||||
// Create theme
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#FF5722',
|
||||
light: '#FF8A65',
|
||||
dark: '#D84315',
|
||||
},
|
||||
secondary: {
|
||||
main: '#4CAF50',
|
||||
light: '#81C784',
|
||||
dark: '#388E3C',
|
||||
},
|
||||
background: {
|
||||
default: '#F5F5F5',
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Router>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Navbar />
|
||||
<Box component="main" sx={{ flexGrow: 1, pt: 2 }}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Navigate to="/dashboard" replace />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/lists" element={
|
||||
<ProtectedRoute>
|
||||
<ListsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/lists/:listId" element={
|
||||
<ProtectedRoute>
|
||||
<ListDetailPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/lists/:listId/edit" element={
|
||||
<ProtectedRoute>
|
||||
<ListEditPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/products" element={
|
||||
<ProtectedRoute>
|
||||
<ProductsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Catch all route */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#4CAF50',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 5000,
|
||||
iconTheme: {
|
||||
primary: '#f44336',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Router>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
61
frontend/src/components/Auth/GoogleCallback.tsx
Normal file
61
frontend/src/components/Auth/GoogleCallback.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const GoogleCallback: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { handleGoogleCallback } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const token = searchParams.get('token');
|
||||
const error = searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
toast.error('Google ile giriş yapılamadı: ' + error);
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await handleGoogleCallback(token);
|
||||
toast.success('Google ile başarıyla giriş yapıldı!');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Google callback error:', error);
|
||||
toast.error('Giriş işlemi sırasında bir hata oluştu');
|
||||
navigate('/login');
|
||||
}
|
||||
} else {
|
||||
toast.error('Geçersiz callback');
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, navigate, handleGoogleCallback]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Google ile giriş yapılıyor...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleCallback;
|
||||
39
frontend/src/components/Auth/ProtectedRoute.tsx
Normal file
39
frontend/src/components/Auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requireAdmin = false }) => {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && user?.role !== 'ADMIN') {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
282
frontend/src/components/Layout/Navbar.tsx
Normal file
282
frontend/src/components/Layout/Navbar.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Notifications as NotificationsIcon,
|
||||
AccountCircle,
|
||||
Dashboard,
|
||||
List as ListIcon,
|
||||
Inventory,
|
||||
Settings,
|
||||
AdminPanelSettings,
|
||||
Logout,
|
||||
ShoppingCart,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { notificationsAPI } from '../../services/api';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Get unread notifications count
|
||||
const { data: unreadCount } = useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: () => notificationsAPI.getUnreadCount(),
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
|
||||
const handleProfileMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
handleMenuClose();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Anasayfa', icon: <Dashboard />, path: '/dashboard' },
|
||||
{ text: 'Listeler', icon: <ListIcon />, path: '/lists' },
|
||||
{ text: 'Ürünler', icon: <Inventory />, path: '/products' },
|
||||
];
|
||||
|
||||
if (user?.role === 'ADMIN') {
|
||||
menuItems.push({ text: 'Yönetim', icon: <AdminPanelSettings />, path: '/admin' });
|
||||
}
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{ width: 250 }}>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<ShoppingCart sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="h6" color="primary.main" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
selected={location.pathname === item.path}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate('/profile');
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
selected={location.pathname === '/profile'}
|
||||
>
|
||||
<ListItemIcon><Settings /></ListItemIcon>
|
||||
<ListItemText primary="Profil" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={handleLogout}>
|
||||
<ListItemIcon><Logout /></ListItemIcon>
|
||||
<ListItemText primary="Çıkış" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<AppBar position="sticky" color="primary">
|
||||
<Toolbar>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
|
||||
<ShoppingCart sx={{ mr: 1 }} />
|
||||
<Typography variant="h6" component="div" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => navigate('/login')}
|
||||
variant={location.pathname === '/login' ? 'outlined' : 'text'}
|
||||
>
|
||||
Giriş
|
||||
</Button>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => navigate('/register')}
|
||||
variant={location.pathname === '/register' ? 'outlined' : 'text'}
|
||||
>
|
||||
Kayıt
|
||||
</Button>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="sticky" color="primary">
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
|
||||
<ShoppingCart sx={{ mr: 1 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
fontWeight="bold"
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!isMobile && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mr: 2 }}>
|
||||
{menuItems.map((item) => (
|
||||
<Button
|
||||
key={item.text}
|
||||
color="inherit"
|
||||
onClick={() => navigate(item.path)}
|
||||
variant={location.pathname === item.path ? 'outlined' : 'text'}
|
||||
startIcon={item.icon}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton color="inherit" onClick={() => navigate('/notifications')}>
|
||||
<Badge badgeContent={unreadCount?.data?.data?.count || 0} color="error">
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="end"
|
||||
aria-label="account of current user"
|
||||
aria-controls="primary-search-account-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={handleProfileMenuOpen}
|
||||
color="inherit"
|
||||
>
|
||||
{user?.avatar ? (
|
||||
<Avatar src={user.avatar} sx={{ width: 32, height: 32 }} />
|
||||
) : (
|
||||
<AccountCircle />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better open performance on mobile.
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: 250 },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
{/* Profile menu */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
id="primary-search-account-menu"
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={() => { navigate('/profile'); handleMenuClose(); }}>
|
||||
<Settings sx={{ mr: 1 }} />
|
||||
Profil
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout sx={{ mr: 1 }} />
|
||||
Çıkış
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
317
frontend/src/components/ShoppingList/ShoppingListFilters.tsx
Normal file
317
frontend/src/components/ShoppingList/ShoppingListFilters.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Chip,
|
||||
Collapse,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Paper,
|
||||
useTheme,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
FilterList,
|
||||
Clear,
|
||||
Sort,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ShoppingListFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
filterCompleted?: boolean;
|
||||
onFilterCompletedChange: (value: boolean | undefined) => void;
|
||||
filterCategory: string;
|
||||
onFilterCategoryChange: (value: string) => void;
|
||||
sortBy: string;
|
||||
onSortByChange: (value: string) => void;
|
||||
sortOrder: string;
|
||||
onSortOrderChange: (value: string) => void;
|
||||
categories: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const ShoppingListFilters: React.FC<ShoppingListFiltersProps> = ({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
filterCompleted,
|
||||
onFilterCompletedChange,
|
||||
filterCategory,
|
||||
onFilterCategoryChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
categories,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchTerm ||
|
||||
filterCompleted !== undefined ||
|
||||
filterCategory !== 'all' ||
|
||||
sortBy !== 'createdAt' ||
|
||||
sortOrder !== 'desc';
|
||||
|
||||
const clearAllFilters = () => {
|
||||
onSearchChange('');
|
||||
onFilterCompletedChange(undefined);
|
||||
onFilterCategoryChange('all');
|
||||
onSortByChange('createdAt');
|
||||
onSortOrderChange('desc');
|
||||
setSearchExpanded(false);
|
||||
setFiltersExpanded(false);
|
||||
};
|
||||
|
||||
const getFilterStatusLabel = (filterCompleted: boolean | undefined) => {
|
||||
if (filterCompleted === undefined) return 'Tümü';
|
||||
return filterCompleted ? 'Tamamlanan' : 'Bekleyen';
|
||||
};
|
||||
|
||||
const getCategoryLabel = (categoryId: string) => {
|
||||
if (categoryId === 'all') return 'Tüm Kategoriler';
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
return category?.name || 'Bilinmeyen';
|
||||
};
|
||||
|
||||
const getSortLabel = (sortBy: string) => {
|
||||
switch (sortBy) {
|
||||
case 'createdAt': return 'Eklenme Tarihi';
|
||||
case 'product.name': return 'Ürün Adı';
|
||||
case 'quantity': return 'Miktar';
|
||||
case 'priority': return 'Öncelik';
|
||||
default: return 'Eklenme Tarihi';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{/* Search and Filter Toggle */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
{/* Search Toggle */}
|
||||
<IconButton
|
||||
onClick={() => setSearchExpanded(!searchExpanded)}
|
||||
sx={{
|
||||
backgroundColor: searchTerm
|
||||
? alpha(theme.palette.primary.main, 0.1)
|
||||
: alpha(theme.palette.grey[500], 0.1),
|
||||
color: searchTerm ? 'primary.main' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: searchTerm
|
||||
? alpha(theme.palette.primary.main, 0.2)
|
||||
: alpha(theme.palette.grey[500], 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Search />
|
||||
</IconButton>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<IconButton
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
sx={{
|
||||
backgroundColor: hasActiveFilters
|
||||
? alpha(theme.palette.primary.main, 0.1)
|
||||
: alpha(theme.palette.grey[500], 0.1),
|
||||
color: hasActiveFilters ? 'primary.main' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: hasActiveFilters
|
||||
? alpha(theme.palette.primary.main, 0.2)
|
||||
: alpha(theme.palette.grey[500], 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
|
||||
{/* Active Filter Chips */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
{searchTerm && (
|
||||
<Chip
|
||||
label={`"${searchTerm}"`}
|
||||
size="small"
|
||||
onDelete={() => onSearchChange('')}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterCompleted !== undefined && (
|
||||
<Chip
|
||||
label={getFilterStatusLabel(filterCompleted)}
|
||||
size="small"
|
||||
onDelete={() => onFilterCompletedChange(undefined)}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterCategory !== 'all' && (
|
||||
<Chip
|
||||
label={getCategoryLabel(filterCategory)}
|
||||
size="small"
|
||||
onDelete={() => onFilterCategoryChange('all')}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(sortBy !== 'createdAt' || sortOrder !== 'desc') && (
|
||||
<Chip
|
||||
label={`${getSortLabel(sortBy)} (${sortOrder === 'asc' ? 'Artan' : 'Azalan'})`}
|
||||
size="small"
|
||||
onDelete={() => {
|
||||
onSortByChange('createdAt');
|
||||
onSortOrderChange('desc');
|
||||
}}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
label="Temizle"
|
||||
size="small"
|
||||
onClick={clearAllFilters}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
icon={<Clear />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Collapsible Search Bar */}
|
||||
<Collapse in={searchExpanded}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Ürünlerde ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Collapse>
|
||||
|
||||
|
||||
|
||||
{/* Expanded Filters */}
|
||||
<Collapse in={filtersExpanded}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: alpha(theme.palette.grey[50], 0.5),
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/* Status Filter */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Durum</InputLabel>
|
||||
<Select
|
||||
value={filterCompleted === undefined ? 'all' : filterCompleted ? 'completed' : 'pending'}
|
||||
label="Durum"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onFilterCompletedChange(
|
||||
value === 'all' ? undefined : value === 'completed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tüm Ürünler</MenuItem>
|
||||
<MenuItem value="pending">Bekleyen</MenuItem>
|
||||
<MenuItem value="completed">Tamamlanan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Category Filter */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Kategori</InputLabel>
|
||||
<Select
|
||||
value={filterCategory}
|
||||
label="Kategori"
|
||||
onChange={(e) => onFilterCategoryChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">Tüm Kategoriler</MenuItem>
|
||||
{categories.map((category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Sort By */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Sırala</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sırala"
|
||||
onChange={(e) => onSortByChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="createdAt">Eklenme Tarihi</MenuItem>
|
||||
<MenuItem value="product.name">Ürün Adı</MenuItem>
|
||||
<MenuItem value="quantity">Miktar</MenuItem>
|
||||
<MenuItem value="priority">Öncelik</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Sort Order */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Sıralama</InputLabel>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
label="Sıralama"
|
||||
onChange={(e) => onSortOrderChange(e.target.value)}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<Sort />
|
||||
</InputAdornment>
|
||||
}
|
||||
>
|
||||
<MenuItem value="asc">Artan</MenuItem>
|
||||
<MenuItem value="desc">Azalan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingListFilters;
|
||||
215
frontend/src/components/ShoppingList/ShoppingListHeader.tsx
Normal file
215
frontend/src/components/ShoppingList/ShoppingListHeader.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Avatar,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
useTheme,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Share,
|
||||
People,
|
||||
ShoppingCart,
|
||||
CheckCircle,
|
||||
Schedule,
|
||||
CalendarToday,
|
||||
} from '@mui/icons-material';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { tr } from 'date-fns/locale';
|
||||
|
||||
interface ShoppingListHeaderProps {
|
||||
list: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
stats: {
|
||||
totalItems: number;
|
||||
completedItems: number;
|
||||
remainingItems: number;
|
||||
completionRate: number;
|
||||
};
|
||||
onBack: () => void;
|
||||
onShare: () => void;
|
||||
}
|
||||
|
||||
const ShoppingListHeader: React.FC<ShoppingListHeaderProps> = ({
|
||||
list,
|
||||
stats,
|
||||
onBack,
|
||||
onShare,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{/* Navigation Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton
|
||||
onClick={onBack}
|
||||
sx={{
|
||||
mr: 1,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: list?.color || theme.palette.primary.main,
|
||||
width: 48,
|
||||
height: 48,
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
{list?.isShared ? <Share /> : <ShoppingCart />}
|
||||
</Avatar>
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: '1.25rem', sm: '1.5rem' },
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{list?.name}
|
||||
</Typography>
|
||||
|
||||
{/* Creation Date */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5, mb: list?.description ? 0.5 : 0 }}>
|
||||
<CalendarToday sx={{ fontSize: 14, color: 'text.secondary', mr: 0.5 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{formatDistanceToNow(new Date(list?.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: tr
|
||||
})} oluşturuldu
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{list?.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{list?.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
onClick={onShare}
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.secondary.main, 0.1),
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.secondary.main, 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<People />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Progress Section */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.05)} 0%, ${alpha(theme.palette.secondary.main, 0.05)} 100%)`,
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
}}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={500}>
|
||||
İlerleme
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary" fontWeight={600}>
|
||||
{Math.round(stats.completionRate)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={stats.completionRate}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: `linear-gradient(90deg, ${theme.palette.success.main} 0%, ${theme.palette.primary.main} 100%)`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||
<ShoppingCart sx={{ fontSize: 20, color: 'primary.main', mr: 0.5 }} />
|
||||
<Typography variant="h6" color="primary" fontWeight={600}>
|
||||
{stats.totalItems}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
Toplam
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||
<CheckCircle sx={{ fontSize: 20, color: 'success.main', mr: 0.5 }} />
|
||||
<Typography variant="h6" color="success.main" fontWeight={600}>
|
||||
{stats.completedItems}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
Tamamlanan
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||
<Schedule sx={{ fontSize: 20, color: 'warning.main', mr: 0.5 }} />
|
||||
<Typography variant="h6" color="warning.main" fontWeight={600}>
|
||||
{stats.remainingItems}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
Kalan
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingListHeader;
|
||||
480
frontend/src/components/ShoppingList/ShoppingListItem.tsx
Normal file
480
frontend/src/components/ShoppingList/ShoppingListItem.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Chip,
|
||||
Avatar,
|
||||
Collapse,
|
||||
useTheme,
|
||||
alpha,
|
||||
Slide,
|
||||
Fade,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
MoreVert,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
Category as CategoryIcon,
|
||||
AccessTime,
|
||||
StickyNote2,
|
||||
LocalOffer,
|
||||
Delete,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Fastfood,
|
||||
LocalDrink,
|
||||
CleaningServices,
|
||||
HealthAndSafety,
|
||||
Pets,
|
||||
Home,
|
||||
SportsEsports,
|
||||
MenuBook,
|
||||
LocalGroceryStore,
|
||||
} from '@mui/icons-material';
|
||||
import { tr } from 'date-fns/locale';
|
||||
|
||||
// Kategori simgelerini döndüren fonksiyon
|
||||
const getCategoryIcon = (category?: { name: string; icon?: string; color?: string }, categoryName?: string) => {
|
||||
// Eğer category objesi varsa ve icon varsa, emoji ikonunu kullan
|
||||
if (category?.icon && category.icon.length <= 4) {
|
||||
return <span style={{ fontSize: '20px' }}>{category.icon}</span>;
|
||||
}
|
||||
|
||||
// Fallback olarak kategori adına göre Material-UI ikonları
|
||||
const name = category?.name || categoryName;
|
||||
if (!name) return <CategoryIcon />;
|
||||
|
||||
const categoryLower = name.toLowerCase();
|
||||
|
||||
if (categoryLower.includes('gıda') || categoryLower.includes('yiyecek') || categoryLower.includes('food') || categoryLower.includes('meyve') || categoryLower.includes('sebze')) {
|
||||
return <Fastfood />;
|
||||
}
|
||||
if (categoryLower.includes('içecek') || categoryLower.includes('drink') || categoryLower.includes('beverage')) {
|
||||
return <LocalDrink />;
|
||||
}
|
||||
if (categoryLower.includes('temizlik') || categoryLower.includes('cleaning')) {
|
||||
return <CleaningServices />;
|
||||
}
|
||||
if (categoryLower.includes('sağlık') || categoryLower.includes('health') || categoryLower.includes('ilaç')) {
|
||||
return <HealthAndSafety />;
|
||||
}
|
||||
if (categoryLower.includes('pet') || categoryLower.includes('hayvan') || categoryLower.includes('evcil') || categoryLower.includes('bebek')) {
|
||||
return <Pets />;
|
||||
}
|
||||
if (categoryLower.includes('ev') || categoryLower.includes('home') || categoryLower.includes('house')) {
|
||||
return <Home />;
|
||||
}
|
||||
if (categoryLower.includes('oyun') || categoryLower.includes('game') || categoryLower.includes('sport')) {
|
||||
return <SportsEsports />;
|
||||
}
|
||||
if (categoryLower.includes('kitap') || categoryLower.includes('book') || categoryLower.includes('dergi')) {
|
||||
return <MenuBook />;
|
||||
}
|
||||
if (categoryLower.includes('market') || categoryLower.includes('grocery') || categoryLower.includes('alışveriş')) {
|
||||
return <LocalGroceryStore />;
|
||||
}
|
||||
|
||||
return <CategoryIcon />;
|
||||
};
|
||||
|
||||
interface ShoppingListItemProps {
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
isPurchased: boolean;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
|
||||
category?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
imageUrl?: string;
|
||||
product?: {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
onTogglePurchased: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMenuClick: (event: React.MouseEvent<HTMLElement>, itemId: string) => void;
|
||||
}
|
||||
|
||||
const ShoppingListItem: React.FC<ShoppingListItemProps> = ({
|
||||
item,
|
||||
onTogglePurchased,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMenuClick,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isSwipeActive, setIsSwipeActive] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [checkboxChecked, setCheckboxChecked] = useState(item.isPurchased);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const startX = useRef(0);
|
||||
const currentX = useRef(0);
|
||||
const theme = useTheme();
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority.toUpperCase()) {
|
||||
case 'HIGH':
|
||||
return theme.palette.error.main;
|
||||
case 'MEDIUM':
|
||||
return theme.palette.warning.main;
|
||||
case 'LOW':
|
||||
return theme.palette.success.main;
|
||||
default:
|
||||
return theme.palette.grey[500];
|
||||
}
|
||||
};
|
||||
|
||||
// Touch event handlers for swipe gesture
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
// Disable swipe for purchased items
|
||||
if (item.isPurchased) return;
|
||||
|
||||
startX.current = e.touches[0].clientX;
|
||||
setIsSwipeActive(true);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
// Disable swipe for purchased items
|
||||
if (!isSwipeActive || item.isPurchased) return;
|
||||
|
||||
currentX.current = e.touches[0].clientX;
|
||||
const diffX = startX.current - currentX.current;
|
||||
|
||||
// Only allow left swipe (positive diffX)
|
||||
if (diffX > 0 && diffX <= 120) {
|
||||
setSwipeOffset(diffX);
|
||||
if (diffX > 60) {
|
||||
setShowActions(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
// Disable swipe for purchased items
|
||||
if (item.isPurchased) {
|
||||
setIsSwipeActive(false);
|
||||
setSwipeOffset(0);
|
||||
setShowActions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwipeActive(false);
|
||||
|
||||
if (swipeOffset > 60) {
|
||||
setSwipeOffset(120); // Snap to show actions
|
||||
setShowActions(true);
|
||||
} else {
|
||||
setSwipeOffset(0); // Snap back
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset swipe when clicking outside
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (showActions) {
|
||||
setSwipeOffset(0);
|
||||
setShowActions(false);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Checkbox animation handler
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
setCheckboxChecked(e.target.checked);
|
||||
setTimeout(() => {
|
||||
onTogglePurchased(item.id);
|
||||
}, 150); // Small delay for animation
|
||||
};
|
||||
|
||||
// Action handlers
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onEdit(item.id);
|
||||
setSwipeOffset(0);
|
||||
setShowActions(false);
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
switch (priority.toUpperCase()) {
|
||||
case 'HIGH':
|
||||
return 'Yüksek';
|
||||
case 'MEDIUM':
|
||||
return 'Orta';
|
||||
case 'LOW':
|
||||
return 'Düşük';
|
||||
default:
|
||||
return 'Normal';
|
||||
}
|
||||
};
|
||||
|
||||
const hasDetails = item.notes || item.category;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mb: 1,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{/* Action Buttons Background - Only show for non-purchased items */}
|
||||
{!item.isPurchased && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 120,
|
||||
display: 'flex',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleEdit}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: '100%',
|
||||
borderRadius: 0,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleDelete}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: '100%',
|
||||
borderRadius: 0,
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main Card */}
|
||||
<Card
|
||||
ref={cardRef}
|
||||
onClick={handleCardClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
transform: `translateX(-${swipeOffset}px)`,
|
||||
transition: isSwipeActive ? 'none' : 'transform 0.3s ease-out',
|
||||
opacity: checkboxChecked ? 0.7 : 1,
|
||||
backgroundColor: checkboxChecked
|
||||
? alpha(theme.palette.success.main, 0.1)
|
||||
: theme.palette.background.paper,
|
||||
border: checkboxChecked
|
||||
? `1px solid ${alpha(theme.palette.success.main, 0.3)}`
|
||||
: `1px solid ${theme.palette.divider}`,
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[2],
|
||||
},
|
||||
cursor: showActions ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Animated Checkbox */}
|
||||
<Fade in={true} timeout={300}>
|
||||
<Checkbox
|
||||
checked={checkboxChecked}
|
||||
onChange={handleCheckboxChange}
|
||||
icon={<Circle />}
|
||||
checkedIcon={<CheckCircle />}
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
'&.Mui-checked': {
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 32,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
|
||||
},
|
||||
'&.Mui-checked .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.2)',
|
||||
filter: 'drop-shadow(0 3px 6px rgba(0,0,0,0.2))',
|
||||
},
|
||||
'&:hover .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Main Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
{/* Category Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: item.product?.category?.color || theme.palette.primary.main,
|
||||
flexShrink: 0,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 20,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{getCategoryIcon(item.product?.category, item.category)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 500,
|
||||
textDecoration: item.isPurchased ? 'line-through' : 'none',
|
||||
color: item.isPurchased
|
||||
? theme.palette.text.secondary
|
||||
: theme.palette.text.primary,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Quantity and Basic Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={`${item.quantity} ${item.unit || 'adet'}`}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
height: 28,
|
||||
fontWeight: 600,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Expand Button */}
|
||||
{hasDetails && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
{expanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Priority Indicator - moved to right */}
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{getPriorityLabel(item.priority)}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getPriorityColor(item.priority),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
{/* Expanded Details */}
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||
|
||||
|
||||
{item.category && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<LocalOffer sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Chip
|
||||
label={item.category}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.75rem', height: 24 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.notes && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<StickyNote2 sx={{ fontSize: 16, color: 'text.secondary', mt: 0.2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{item.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Menu Button */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => onMenuClick(e, item.id)}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingListItem;
|
||||
3
frontend/src/components/ShoppingList/index.ts
Normal file
3
frontend/src/components/ShoppingList/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ShoppingListItem } from './ShoppingListItem';
|
||||
export { default as ShoppingListHeader } from './ShoppingListHeader';
|
||||
export { default as ShoppingListFilters } from './ShoppingListFilters';
|
||||
205
frontend/src/contexts/AuthContext.tsx
Normal file
205
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
|
||||
import { authAPI } from '../services/api';
|
||||
import { User } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (login: string, password: string) => Promise<void>;
|
||||
register: (userData: RegisterData) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateProfile: (userData: Partial<User>) => Promise<void>;
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||
handleGoogleCallback: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
type AuthAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_USER'; payload: { user: User; token: string } }
|
||||
| { type: 'CLEAR_USER' }
|
||||
| { type: 'UPDATE_USER'; payload: Partial<User> };
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: localStorage.getItem('token'),
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload };
|
||||
case 'SET_USER':
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
};
|
||||
case 'CLEAR_USER':
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
};
|
||||
case 'UPDATE_USER':
|
||||
return {
|
||||
...state,
|
||||
user: state.user ? { ...state.user, ...action.payload } : null,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// Backend'ten gelen kullanıcı nesnesini frontend User tipine dönüştür
|
||||
const normalizeUser = (apiUser: any): User => {
|
||||
return {
|
||||
id: apiUser.id,
|
||||
firstName: apiUser.firstName,
|
||||
lastName: apiUser.lastName,
|
||||
email: apiUser.email,
|
||||
role: apiUser.isAdmin ? 'ADMIN' : 'USER',
|
||||
isActive: apiUser.isActive ?? true,
|
||||
avatar: apiUser.avatar ?? undefined,
|
||||
createdAt: apiUser.createdAt,
|
||||
updatedAt: apiUser.updatedAt ?? apiUser.createdAt,
|
||||
settings: apiUser.settings ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Check if user is authenticated on app load
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
const response = await authAPI.getProfile();
|
||||
dispatch({
|
||||
type: 'SET_USER',
|
||||
payload: { user: normalizeUser(response.data.data.user), token },
|
||||
});
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'CLEAR_USER' });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (login: string, password: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
try {
|
||||
const response = await authAPI.login({ login, password });
|
||||
const { user, token } = response.data.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
dispatch({ type: 'SET_USER', payload: { user: normalizeUser(user), token } });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (userData: RegisterData) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
try {
|
||||
const response = await authAPI.register(userData);
|
||||
const { user, token } = response.data.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
dispatch({ type: 'SET_USER', payload: { user: normalizeUser(user), token } });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'CLEAR_USER' });
|
||||
};
|
||||
|
||||
const updateProfile = async (userData: Partial<User>) => {
|
||||
try {
|
||||
const response = await authAPI.updateProfile(userData);
|
||||
dispatch({ type: 'UPDATE_USER', payload: normalizeUser(response.data.data.user) });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (currentPassword: string, newPassword: string) => {
|
||||
try {
|
||||
await authAPI.changePassword({ currentPassword, newPassword });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleCallback = async (token: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
try {
|
||||
localStorage.setItem('token', token);
|
||||
const response = await authAPI.getProfile();
|
||||
dispatch({
|
||||
type: 'SET_USER',
|
||||
payload: { user: normalizeUser(response.data.data.user), token },
|
||||
});
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
handleGoogleCallback,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
149
frontend/src/contexts/SocketContext.tsx
Normal file
149
frontend/src/contexts/SocketContext.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { createContext, useContext, useEffect, useRef, ReactNode } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useAuth } from './AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SocketContextType {
|
||||
socket: Socket | null;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
||||
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSocket must be used within a SocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface SocketProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SocketProvider: React.FC<SocketProviderProps> = ({ children }) => {
|
||||
const { user, token, isAuthenticated } = useAuth();
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && token && user) {
|
||||
// Initialize socket connection
|
||||
const socketUrl = process.env.NODE_ENV === 'production'
|
||||
? `http://${window.location.hostname}:7001`
|
||||
: 'http://localhost:7001';
|
||||
|
||||
socketRef.current = io(socketUrl, {
|
||||
auth: {
|
||||
token,
|
||||
},
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
const socket = socketRef.current;
|
||||
|
||||
// Connection event handlers
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected:', socket.id);
|
||||
setIsConnected(true);
|
||||
|
||||
// Join user's personal room
|
||||
socket.emit('join-user-room', user.id);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Socket disconnected');
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error);
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
// Notification handlers
|
||||
socket.on('notification', (notification) => {
|
||||
toast.success(notification.message, {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
// List update handlers
|
||||
socket.on('list-updated', (data) => {
|
||||
// This will be handled by specific components using the socket
|
||||
console.log('List updated:', data);
|
||||
});
|
||||
|
||||
socket.on('list-item-added', (data) => {
|
||||
toast.success(`${data.item.name} added to ${data.list.name}`, {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('list-item-updated', (data) => {
|
||||
if (data.item.isPurchased) {
|
||||
toast.success(`${data.item.name} marked as purchased`, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('list-item-removed', (data) => {
|
||||
toast(`${data.item.name} removed from ${data.list.name}`, {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
// List member handlers
|
||||
socket.on('list-member-added', (data) => {
|
||||
toast.success(`You've been added to ${data.list.name}`, {
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('list-member-removed', (data) => {
|
||||
toast(`You've been removed from ${data.list.name}`, {
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
// List invitation handlers
|
||||
socket.on('list-invitation', (data) => {
|
||||
toast.success(`You've been invited to join ${data.list.name}`, {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
// Error handlers
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error);
|
||||
toast.error(error.message || 'An error occurred', {
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Disconnect socket if user is not authenticated
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, token, user]);
|
||||
|
||||
const value: SocketContextType = {
|
||||
socket: socketRef.current,
|
||||
isConnected,
|
||||
};
|
||||
|
||||
return <SocketContext.Provider value={value}>{children}</SocketContext.Provider>;
|
||||
};
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
13
frontend/src/index.tsx
Normal file
13
frontend/src/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
1260
frontend/src/pages/Admin/AdminPage.tsx
Normal file
1260
frontend/src/pages/Admin/AdminPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/src/pages/Admin/index.ts
Normal file
1
frontend/src/pages/Admin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AdminPage } from './AdminPage';
|
||||
308
frontend/src/pages/Auth/LoginPage.tsx
Normal file
308
frontend/src/pages/Auth/LoginPage.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Link,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Email,
|
||||
Lock,
|
||||
ShoppingCart,
|
||||
Google,
|
||||
} from '@mui/icons-material';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { LoginForm } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const schema = yup.object({
|
||||
login: yup
|
||||
.string()
|
||||
.required('E-posta veya kullanıcı adı gerekli'),
|
||||
password: yup
|
||||
.string()
|
||||
.min(6, 'Şifre en az 6 karakter olmalı')
|
||||
.required('Şifre gerekli'),
|
||||
});
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isLoading } = useAuth();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const from = (location.state as any)?.from?.pathname || '/dashboard';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
login: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
try {
|
||||
setError('');
|
||||
await login(data.login, data.password);
|
||||
toast.success('Başarıyla giriş yapıldı!');
|
||||
navigate(from, { replace: true });
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || 'Giriş başarısız. Lütfen tekrar deneyin.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Backend'deki Google OAuth endpoint'ine yönlendir
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
|
||||
window.location.href = `${apiUrl}/auth/google`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Logo and Title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
|
||||
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
|
||||
Giriş Yap
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
|
||||
<Controller
|
||||
name="login"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="login"
|
||||
label="E-posta veya Kullanıcı Adı"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
error={!!errors.login}
|
||||
helperText={errors.login?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Email />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Şifre"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Giriş Yapılıyor...' : 'Giriş Yap'}
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
VEYA
|
||||
</Typography>
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
startIcon={<Google />}
|
||||
sx={{
|
||||
mb: 2,
|
||||
py: 1.5,
|
||||
borderColor: '#db4437',
|
||||
color: '#db4437',
|
||||
'&:hover': {
|
||||
borderColor: '#c23321',
|
||||
backgroundColor: 'rgba(219, 68, 55, 0.04)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Google ile Giriş Yap
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
Şifremi unuttum?
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Hesabınız yok mu?{' '}
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/register')}
|
||||
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 3, pt: 2, borderTop: '1px solid rgba(0,0,0,0.05)', display: 'flex', justifyContent: 'center', gap: 2 }}>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="caption"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Kullanım Şartları
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="caption"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Features */}
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Neden hMarket?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 4, mt: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
📝 Akıllı Listeler
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Alışveriş listeleri oluştur ve paylaş
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
🔄 Gerçek Zamanlı Senkronizasyon
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tüm cihazlarda güncellemeler
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
💰 Fiyat Takibi
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fiyatları takip et ve karşılaştır
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
381
frontend/src/pages/Auth/RegisterPage.tsx
Normal file
381
frontend/src/pages/Auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Link,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Email,
|
||||
Lock,
|
||||
Person,
|
||||
ShoppingCart,
|
||||
Google,
|
||||
} from '@mui/icons-material';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { RegisterForm } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const schema = yup.object({
|
||||
firstName: yup
|
||||
.string()
|
||||
.required('Ad gerekli')
|
||||
.min(2, 'Ad en az 2 karakter olmalı'),
|
||||
lastName: yup
|
||||
.string()
|
||||
.required('Soyad gerekli')
|
||||
.min(2, 'Soyad en az 2 karakter olmalı'),
|
||||
email: yup
|
||||
.string()
|
||||
.email('Lütfen geçerli bir e-posta adresi girin')
|
||||
.required('E-posta gerekli'),
|
||||
password: yup
|
||||
.string()
|
||||
.min(6, 'Şifre en az 6 karakter olmalı')
|
||||
.matches(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
'Şifre en az bir büyük harf, bir küçük harf ve bir rakam içermeli'
|
||||
)
|
||||
.required('Şifre gerekli'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.oneOf([yup.ref('password')], 'Şifreler eşleşmeli')
|
||||
.required('Lütfen şifrenizi onaylayın'),
|
||||
});
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading } = useAuth();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterForm) => {
|
||||
try {
|
||||
setError('');
|
||||
await register({
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
});
|
||||
toast.success('Hesap başarıyla oluşturuldu!');
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || 'Kayıt başarısız. Lütfen tekrar deneyin.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleClickShowConfirmPassword = () => {
|
||||
setShowConfirmPassword(!showConfirmPassword);
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Backend'deki Google OAuth endpoint'ine yönlendir
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
|
||||
window.location.href = `${apiUrl}/auth/google`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Logo and Title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
|
||||
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
|
||||
hMarket
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
|
||||
Hesap Oluştur
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
|
||||
<Box>
|
||||
<Controller
|
||||
name="firstName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
required
|
||||
fullWidth
|
||||
id="firstName"
|
||||
label="Ad"
|
||||
autoComplete="given-name"
|
||||
autoFocus
|
||||
error={!!errors.firstName}
|
||||
helperText={errors.firstName?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Person />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Controller
|
||||
name="lastName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
required
|
||||
fullWidth
|
||||
id="lastName"
|
||||
label="Soyad"
|
||||
autoComplete="family-name"
|
||||
error={!!errors.lastName}
|
||||
helperText={errors.lastName?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Person />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="E-posta Adresi"
|
||||
autoComplete="email"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Email />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Şifre"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Şifre Onayı"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
error={!!errors.confirmPassword}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle confirm password visibility"
|
||||
onClick={handleClickShowConfirmPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Hesap Oluşturuluyor...' : 'Hesap Oluştur'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
startIcon={<Google />}
|
||||
sx={{
|
||||
mb: 2,
|
||||
py: 1.5,
|
||||
borderColor: '#db4437',
|
||||
color: '#db4437',
|
||||
'&:hover': {
|
||||
borderColor: '#c23321',
|
||||
backgroundColor: 'rgba(219, 68, 55, 0.04)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Google ile Kayıt Ol
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Zaten hesabınız var mı?{' '}
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/login')}
|
||||
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Giriş Yap
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Terms */}
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Hesap oluşturarak,{' '}
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
Kullanım Şartları
|
||||
</Link>
|
||||
{' '}ve{' '}
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
'nı kabul etmiş olursunuz.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
2
frontend/src/pages/Auth/index.ts
Normal file
2
frontend/src/pages/Auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
359
frontend/src/pages/Dashboard/DashboardPage.tsx
Normal file
359
frontend/src/pages/Dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Avatar,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Link,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
List as ListIcon,
|
||||
ShoppingCart,
|
||||
CheckCircle,
|
||||
PendingActions,
|
||||
Share,
|
||||
TrendingUp,
|
||||
Refresh,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { dashboardAPI, listsAPI } from '../../services/api';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ListsResponse } from '../../types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Fetch dashboard stats
|
||||
const { data: statsData, isLoading: statsLoading, refetch: refetchStats } = useQuery({
|
||||
queryKey: ['dashboard', 'stats'],
|
||||
queryFn: () => dashboardAPI.getStats(),
|
||||
});
|
||||
|
||||
// Fetch recent lists
|
||||
const { data: listsData, isLoading: listsLoading } = useQuery<ListsResponse>({
|
||||
queryKey: ['lists', 'recent'],
|
||||
queryFn: () => listsAPI.getLists({ limit: 5, sortBy: 'updatedAt', sortOrder: 'desc' }).then(response => response.data),
|
||||
});
|
||||
|
||||
// Fetch recent activity
|
||||
const { data: activityData, isLoading: activityLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'activity'],
|
||||
queryFn: () => dashboardAPI.getRecentActivity({ limit: 10 }),
|
||||
});
|
||||
|
||||
const stats = statsData?.data?.data;
|
||||
const recentLists = listsData?.data?.lists || [];
|
||||
const recentActivity = activityData?.data?.data?.activities || [];
|
||||
|
||||
const completionRate = stats ? Math.round((stats.completedItems / stats.totalItems) * 100) || 0 : 0;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Welcome Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Tekrar hoş geldin, {user?.firstName}! 👋
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Bugün alışveriş listelerinizde neler oluyor, işte özet.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' }, gap: 3 }}>
|
||||
{/* Stats Cards */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ListIcon sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Toplam Liste
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.totalLists || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ShoppingCart sx={{ fontSize: 40, color: 'info.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Toplam Ürün
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.totalItems || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'success.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Tamamlanan
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.completedItems || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PendingActions sx={{ fontSize: 40, color: 'warning.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Bekleyen
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{statsLoading ? '-' : stats?.pendingItems || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
|
||||
{/* Completion Rate */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6">Alışveriş İlerlemesi</Typography>
|
||||
<IconButton onClick={() => refetchStats()}>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<TrendingUp sx={{ mr: 1, color: 'success.main' }} />
|
||||
<Typography variant="h4" color="success.main">
|
||||
{completionRate}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||
tamamlanma oranı
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={completionRate}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{stats?.totalItems || 0} üründen {stats?.completedItems || 0} tanesi tamamlandı
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Hızlı İşlemler
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/lists?action=create')}
|
||||
>
|
||||
Yeni Liste Oluştur
|
||||
</Button>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<ListIcon />}
|
||||
onClick={() => navigate('/lists')}
|
||||
>
|
||||
Tüm Listeler
|
||||
</Button>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<ShoppingCart />}
|
||||
onClick={() => navigate('/products')}
|
||||
>
|
||||
Ürünlere Gözat
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
|
||||
{/* Recent Lists */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Son Listeler
|
||||
</Typography>
|
||||
{listsLoading ? (
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
) : recentLists.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
Henüz liste yok. İlk alışveriş listenizi oluşturun!
|
||||
</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{recentLists.map((list: any) => (
|
||||
<ListItem
|
||||
key={list.id}
|
||||
onClick={() => navigate(`/lists/${list.id}`)}
|
||||
sx={{ px: 0, cursor: 'pointer' }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Avatar sx={{ bgcolor: list.color || 'primary.main', width: 32, height: 32 }}>
|
||||
{list.isShared ? <Share /> : <ListIcon />}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={list.name}
|
||||
secondary={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)' }}>
|
||||
{list._count?.items || 0} ürün
|
||||
</span>
|
||||
{list.isShared && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
Paylaşılan
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" onClick={() => navigate('/lists')}>
|
||||
Tüm Listeleri Görüntüle
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Son Aktiviteler
|
||||
</Typography>
|
||||
{activityLoading ? (
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
Son aktivite yok.
|
||||
</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{recentActivity.slice(0, 5).map((activity: any) => (
|
||||
<ListItem key={activity.id} sx={{ px: 0 }}>
|
||||
<ListItemIcon>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
{activity.user?.firstName?.[0] || '?'}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={activity.description}
|
||||
secondary={formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" onClick={() => navigate('/activity')}>
|
||||
Tüm Aktiviteleri Görüntüle
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box sx={{ mt: 8, pt: 4, borderTop: '1px solid rgba(0,0,0,0.05)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-kullanim-sartlari/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="body2"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Kullanım Şartları
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.mustafaozkaya.tr/hmarket-gizlilik-politikasi/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
variant="body2"
|
||||
sx={{ textDecoration: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
</Box>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1, color: 'text.disabled' }}>
|
||||
© 2026 hMarket - Mustafa ÖZKAYA
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
1
frontend/src/pages/Dashboard/index.ts
Normal file
1
frontend/src/pages/Dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
1570
frontend/src/pages/Lists/ListDetailPage.tsx
Normal file
1570
frontend/src/pages/Lists/ListDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
249
frontend/src/pages/Lists/ListEditPage.tsx
Normal file
249
frontend/src/pages/Lists/ListEditPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Save } from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { listsAPI } from '../../services/api';
|
||||
import { UpdateListForm } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Validation schema
|
||||
const schema: yup.ObjectSchema<UpdateListForm> = yup.object({
|
||||
name: yup.string().optional().test('min-length', 'Liste adı en az 1 karakter olmalıdır', function(value) {
|
||||
if (value === undefined || value === null) return true; // optional field
|
||||
return value.length >= 1 && value.length <= 100;
|
||||
}),
|
||||
description: yup.string().optional().max(500, 'Açıklama en fazla 500 karakter olabilir'),
|
||||
color: yup.string().optional().matches(/^#[0-9A-F]{6}$/i, 'Geçerli bir hex renk kodu girin'),
|
||||
isShared: yup.boolean().optional(),
|
||||
});
|
||||
|
||||
// Color options
|
||||
const colors = [
|
||||
'#FF5722', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
||||
];
|
||||
|
||||
const ListEditPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { listId } = useParams<{ listId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch list data
|
||||
const { data: listData, isLoading, error } = useQuery({
|
||||
queryKey: ['list', listId],
|
||||
queryFn: () => listsAPI.getList(listId!),
|
||||
enabled: !!listId,
|
||||
});
|
||||
|
||||
// Update list mutation
|
||||
const updateListMutation = useMutation({
|
||||
mutationFn: (data: UpdateListForm) => listsAPI.updateList(listId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['list', listId] });
|
||||
toast.success('Liste başarıyla güncellendi!');
|
||||
navigate(`/lists/${listId}`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Liste güncellenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<UpdateListForm>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#FF5722',
|
||||
isShared: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when data is loaded
|
||||
useEffect(() => {
|
||||
if (listData?.data?.data?.list) {
|
||||
const list = listData.data.data.list;
|
||||
reset({
|
||||
name: list.name,
|
||||
description: list.description || '',
|
||||
color: list.color,
|
||||
isShared: list.isShared,
|
||||
});
|
||||
}
|
||||
}, [listData, reset]);
|
||||
|
||||
const handleUpdateList = (data: UpdateListForm) => {
|
||||
updateListMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Liste yüklenirken bir hata oluştu
|
||||
</Alert>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/lists')}
|
||||
>
|
||||
Listelere Dön
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const list = listData?.data?.data?.list;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate(`/lists/${listId}`)}
|
||||
variant="outlined"
|
||||
>
|
||||
Geri
|
||||
</Button>
|
||||
<Typography variant="h4" component="h1">
|
||||
Liste Düzenle
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<form onSubmit={handleSubmit(handleUpdateList)}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Liste Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Açıklama (isteğe bağlı)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
error={!!errors.description}
|
||||
helperText={errors.description?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Renk
|
||||
</Typography>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{colors.map((color) => (
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
bgcolor: color,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
border: field.value === color ? '3px solid #000' : '2px solid transparent',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onClick={() => field.onChange(color)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Controller
|
||||
name="isShared"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label="Paylaşımlı Liste"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate(`/lists/${listId}`)}
|
||||
>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
disabled={!isDirty || updateListMutation.isPending}
|
||||
>
|
||||
{updateListMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListEditPage;
|
||||
682
frontend/src/pages/Lists/ListsPage.tsx
Normal file
682
frontend/src/pages/Lists/ListsPage.tsx
Normal file
@@ -0,0 +1,682 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
Fab,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Search,
|
||||
MoreVert,
|
||||
Share,
|
||||
Edit,
|
||||
Delete,
|
||||
List as ListIcon,
|
||||
People,
|
||||
ShoppingCart,
|
||||
FilterList,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { listsAPI, itemsAPI } from '../../services/api';
|
||||
import { CreateListForm, ShoppingList, ListsResponse } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const createListSchema = yup.object().shape({
|
||||
name: yup.string().required('Liste adı gerekli').min(2, 'Ad en az 2 karakter olmalı'),
|
||||
description: yup.string().optional(),
|
||||
color: yup.string().optional(),
|
||||
isShared: yup.boolean().required(),
|
||||
});
|
||||
|
||||
const ListsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
||||
const [filterShared, setFilterShared] = useState<boolean | undefined>(
|
||||
searchParams.get('shared') ? searchParams.get('shared') === 'true' : undefined
|
||||
);
|
||||
const [filterCompleted, setFilterCompleted] = useState<boolean | undefined>(
|
||||
searchParams.get('completed') ? searchParams.get('completed') === 'true' : false
|
||||
);
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || 'updatedAt');
|
||||
const [sortOrder, setSortOrder] = useState(searchParams.get('sortOrder') || 'desc');
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(searchParams.get('action') === 'create');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [selectedList, setSelectedList] = useState<ShoppingList | null>(null);
|
||||
|
||||
// Fetch lists
|
||||
const { data: listsData, isLoading, error } = useQuery<ListsResponse>({
|
||||
queryKey: ['lists', { search: searchTerm, isShared: filterShared, sortBy, sortOrder }],
|
||||
queryFn: () => {
|
||||
console.log('🔍 Fetching lists with params:', { search: searchTerm, isShared: filterShared, sortBy, sortOrder });
|
||||
return listsAPI.getLists({
|
||||
search: searchTerm || undefined,
|
||||
isShared: filterShared,
|
||||
sortBy: sortBy as any,
|
||||
sortOrder: sortOrder as any,
|
||||
limit: 50,
|
||||
}).then(response => {
|
||||
console.log('📋 Lists API response:', response.data);
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Create list mutation
|
||||
const createListMutation = useMutation({
|
||||
mutationFn: (data: CreateListForm) => listsAPI.createList(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
toast.success('Liste başarıyla oluşturuldu!');
|
||||
setCreateDialogOpen(false);
|
||||
reset();
|
||||
// Yeni oluşturulan listeye otomatik yönlendir
|
||||
const newListId = response.data.data.list.id;
|
||||
navigate(`/lists/${newListId}`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Liste oluşturulamadı');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete list mutation
|
||||
const deleteListMutation = useMutation({
|
||||
mutationFn: (id: string) => listsAPI.deleteList(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
toast.success('Liste başarıyla silindi!');
|
||||
handleMenuClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Liste silinemedi');
|
||||
},
|
||||
});
|
||||
|
||||
// Add sample data mutation
|
||||
const addSampleDataMutation = useMutation({
|
||||
mutationFn: async (listId: string) => {
|
||||
const sampleItems = [
|
||||
{ name: 'Ekmek', quantity: 2, unit: 'adet', priority: 'MEDIUM' as const, notes: 'Tam buğday ekmeği' },
|
||||
{ name: 'Süt', quantity: 1, unit: 'litre', priority: 'HIGH' as const, notes: '3.5% yağlı' },
|
||||
{ name: 'Yumurta', quantity: 12, unit: 'adet', priority: 'MEDIUM' as const, notes: 'Organik' },
|
||||
{ name: 'Domates', quantity: 1, unit: 'kg', priority: 'LOW' as const, notes: 'Taze' },
|
||||
{ name: 'Soğan', quantity: 500, unit: 'gram', priority: 'LOW' as const, notes: 'Kuru soğan' },
|
||||
{ name: 'Peynir', quantity: 250, unit: 'gram', priority: 'MEDIUM' as const, notes: 'Beyaz peynir' },
|
||||
{ name: 'Zeytin', quantity: 200, unit: 'gram', priority: 'LOW' as const, notes: 'Siyah zeytin' },
|
||||
{ name: 'Çay', quantity: 1, unit: 'paket', priority: 'MEDIUM' as const, notes: 'Bergamot aromalı' },
|
||||
];
|
||||
|
||||
// Add each sample item to the list
|
||||
for (const item of sampleItems) {
|
||||
await itemsAPI.createItem(listId, item);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||
toast.success('Örnek ürünler başarıyla eklendi!');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Örnek ürünler eklenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CreateListForm>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#FF5722',
|
||||
isShared: false,
|
||||
},
|
||||
});
|
||||
|
||||
const lists = listsData?.data?.lists || [];
|
||||
|
||||
// Filter lists based on completion status
|
||||
const filteredLists = lists.filter((list: ShoppingList) => {
|
||||
if (filterCompleted === undefined) return true;
|
||||
|
||||
const totalItems = list._count?.items || 0;
|
||||
const completedItems = list._count?.completedItems || 0;
|
||||
const isCompleted = totalItems > 0 && completedItems === totalItems;
|
||||
|
||||
return filterCompleted ? isCompleted : !isCompleted;
|
||||
});
|
||||
|
||||
// Debug logs
|
||||
console.log('🔍 listsData:', listsData);
|
||||
console.log('📋 lists array:', lists);
|
||||
console.log('🎯 filteredLists:', filteredLists);
|
||||
console.log('📊 lists length:', lists.length);
|
||||
console.log('📊 filteredLists length:', filteredLists.length);
|
||||
console.log('🎯 isLoading:', isLoading);
|
||||
console.log('❌ error:', error);
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, list: ShoppingList) => {
|
||||
setMenuAnchor(event.currentTarget);
|
||||
setSelectedList(list);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchor(null);
|
||||
setSelectedList(null);
|
||||
};
|
||||
|
||||
const handleCreateList = (data: CreateListForm) => {
|
||||
createListMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleDeleteList = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
setMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (selectedList) {
|
||||
deleteListMutation.mutate(selectedList.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedList(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedList(null);
|
||||
};
|
||||
|
||||
const handleAddSampleData = (listId: string) => {
|
||||
addSampleDataMutation.mutate(listId);
|
||||
};
|
||||
|
||||
const updateSearchParams = (key: string, value: string | undefined) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const colors = [
|
||||
'#FF5722', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
||||
];
|
||||
|
||||
// Render function for lists grid
|
||||
const renderListsGrid = () => {
|
||||
console.log('🎨 Render condition check:', { isLoading, listsLength: filteredLists.length, filteredLists });
|
||||
|
||||
if (isLoading) {
|
||||
console.log('🔄 Rendering loading state');
|
||||
return <Typography>Yükleniyor...</Typography>;
|
||||
}
|
||||
|
||||
if (filteredLists.length === 0) {
|
||||
console.log('📭 Rendering empty state');
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<ListIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Liste bulunamadı
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{searchTerm || filterCompleted !== undefined ? 'Arama kriterlerinizi ayarlamayı deneyin' : 'Başlamak için ilk alışveriş listenizi oluşturun'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
İlk Listenizi Oluşturun
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('📋 Rendering lists grid with', filteredLists.length, 'lists');
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 3 }}>
|
||||
{filteredLists.map((list: ShoppingList) => (
|
||||
<Box key={list.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { boxShadow: 4 },
|
||||
}}
|
||||
onClick={() => navigate(`/lists/${list.id}`)}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: list.color || '#FF5722',
|
||||
width: 40,
|
||||
height: 40,
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
{list.isShared ? <Share /> : <ListIcon />}
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" noWrap>
|
||||
{list.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDistanceToNow(new Date(list.updatedAt), { addSuffix: true })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuOpen(e, list);
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{list.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{list.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
{list.isShared && (
|
||||
<Chip
|
||||
icon={<People />}
|
||||
label={`${list._count?.members || 0} üye`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
icon={<ShoppingCart />}
|
||||
label={`${list._count?.completedItems || 0}/${list._count?.items || 0} ürün`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={(list._count?.completedItems || 0) === (list._count?.items || 0) && (list._count?.items || 0) > 0 ? "success" : "default"}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardActions>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/lists/${list.id}`);
|
||||
}}
|
||||
>
|
||||
Listeyi Görüntüle
|
||||
</Button>
|
||||
{(list._count?.items || 0) === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddSampleData(list.id);
|
||||
}}
|
||||
disabled={addSampleDataMutation.isPending}
|
||||
>
|
||||
{addSampleDataMutation.isPending ? 'Ekleniyor...' : 'Örnek Ürünler Ekle'}
|
||||
</Button>
|
||||
)}
|
||||
{list.isShared && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Share />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: Implement share functionality
|
||||
}}
|
||||
>
|
||||
Paylaş
|
||||
</Button>
|
||||
)}
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Alışveriş Listeleri
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
sx={{ display: { xs: 'none', sm: 'flex' } }}
|
||||
>
|
||||
Liste Oluştur
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flex: '1 1 300px', minWidth: '200px' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Listelerde ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
updateSearchParams('search', e.target.value || undefined);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Paylaşım</InputLabel>
|
||||
<Select
|
||||
value={filterShared === undefined ? 'all' : filterShared ? 'shared' : 'private'}
|
||||
label="Paylaşım"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const shared = value === 'all' ? undefined : value === 'shared';
|
||||
setFilterShared(shared);
|
||||
updateSearchParams('shared', shared === undefined ? undefined : shared.toString());
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tüm Listeler</MenuItem>
|
||||
<MenuItem value="shared">Paylaşılan Listeler</MenuItem>
|
||||
<MenuItem value="private">Özel Listeler</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Durum</InputLabel>
|
||||
<Select
|
||||
value={filterCompleted === undefined ? 'all' : filterCompleted ? 'completed' : 'ongoing'}
|
||||
label="Durum"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const completed = value === 'all' ? undefined : value === 'completed';
|
||||
setFilterCompleted(completed);
|
||||
updateSearchParams('completed', completed === undefined ? undefined : completed.toString());
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">Tüm Listeler</MenuItem>
|
||||
<MenuItem value="completed">Tamamlananlar</MenuItem>
|
||||
<MenuItem value="ongoing">Devam Edenler</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sırala</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sırala"
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value);
|
||||
updateSearchParams('sortBy', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="name">İsim</MenuItem>
|
||||
<MenuItem value="createdAt">Oluşturulma Tarihi</MenuItem>
|
||||
<MenuItem value="updatedAt">Güncellenme Tarihi</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sıralama</InputLabel>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
label="Sıralama"
|
||||
onChange={(e) => {
|
||||
setSortOrder(e.target.value);
|
||||
updateSearchParams('sortOrder', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="asc">Artan</MenuItem>
|
||||
<MenuItem value="desc">Azalan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Lists Grid */}
|
||||
{renderListsGrid()}
|
||||
|
||||
{/* Floating Action Button for Mobile */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="add"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
display: { xs: 'flex', sm: 'none' },
|
||||
}}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
|
||||
{/* Create List Dialog */}
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Yeni Liste Oluştur</DialogTitle>
|
||||
<form onSubmit={handleSubmit(handleCreateList)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Liste Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Açıklama (isteğe bağlı)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Renk
|
||||
</Typography>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{colors.map((color) => (
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: color,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
border: field.value === color ? '3px solid #000' : '2px solid transparent',
|
||||
}}
|
||||
onClick={() => field.onChange(color)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Controller
|
||||
name="isShared"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label="Bu listeyi paylaşımlı yap"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateDialogOpen(false)}>İptal</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={createListMutation.isPending}
|
||||
>
|
||||
{createListMutation.isPending ? 'Oluşturuluyor...' : 'Liste Oluştur'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCancelDelete}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Liste Silme Onayı</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
"{selectedList?.name}" listesini silmek istediğinizden emin misiniz?
|
||||
Bu işlem geri alınamaz ve listedeki tüm ürünler de silinecektir.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete}>İptal</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={deleteListMutation.isPending}
|
||||
>
|
||||
{deleteListMutation.isPending ? 'Siliniyor...' : 'Sil'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* List Menu */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (selectedList) {
|
||||
navigate(`/lists/${selectedList.id}/edit`);
|
||||
}
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
Düzenle
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// TODO: Implement share functionality
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<Share sx={{ mr: 1 }} />
|
||||
Paylaş
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleDeleteList}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
Sil
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListsPage;
|
||||
3
frontend/src/pages/Lists/index.ts
Normal file
3
frontend/src/pages/Lists/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ListsPage } from './ListsPage';
|
||||
export { default as ListDetailPage } from './ListDetailPage';
|
||||
export { default as ListEditPage } from './ListEditPage';
|
||||
809
frontend/src/pages/Products/ProductsPage.tsx
Normal file
809
frontend/src/pages/Products/ProductsPage.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
Fab,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Search,
|
||||
MoreVert,
|
||||
Edit,
|
||||
Delete,
|
||||
Category as CategoryIcon,
|
||||
ShoppingCart,
|
||||
Fastfood,
|
||||
LocalDrink,
|
||||
Cake,
|
||||
LocalGroceryStore,
|
||||
ChildCare,
|
||||
CleaningServices,
|
||||
Face,
|
||||
AcUnit,
|
||||
LocalFlorist,
|
||||
LocalDining,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { productsAPI, categoriesAPI } from '../../services/api';
|
||||
import { CreateProductForm, Product, Category } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
// Icon mapping for categories - now supports emoji icons from database
|
||||
const getCategoryIcon = (iconName?: string) => {
|
||||
// If iconName exists and looks like an emoji (short string), return it directly
|
||||
if (iconName && iconName.length <= 4) {
|
||||
return <span style={{ fontSize: '16px' }}>{iconName}</span>;
|
||||
}
|
||||
|
||||
// Fallback to Material-UI icons for backward compatibility
|
||||
const iconMap: { [key: string]: React.ReactElement } = {
|
||||
'Fastfood': <Fastfood />,
|
||||
'LocalDrink': <LocalDrink />,
|
||||
'Cake': <Cake />,
|
||||
'LocalGroceryStore': <LocalGroceryStore />,
|
||||
'ChildCare': <ChildCare />,
|
||||
'CleaningServices': <CleaningServices />,
|
||||
'Face': <Face />,
|
||||
'AcUnit': <AcUnit />,
|
||||
'LocalFlorist': <LocalFlorist />,
|
||||
'LocalDining': <LocalDining />,
|
||||
};
|
||||
|
||||
return iconMap[iconName || ''] || <CategoryIcon />;
|
||||
};
|
||||
|
||||
const ProductsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
||||
const [selectedCategory, setSelectedCategory] = useState(searchParams.get('categoryId') || '');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || 'name');
|
||||
const [sortOrder, setSortOrder] = useState(searchParams.get('sortOrder') || 'asc');
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(searchParams.get('action') === 'create');
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
|
||||
// Fetch products
|
||||
const { data: productsData, isLoading } = useQuery({
|
||||
queryKey: ['products', { search: searchTerm, categoryId: selectedCategory, sortBy, sortOrder }],
|
||||
queryFn: () => productsAPI.getProducts({
|
||||
search: searchTerm || undefined,
|
||||
categoryId: selectedCategory || undefined,
|
||||
sortBy: sortBy as any,
|
||||
sortOrder: sortOrder as any,
|
||||
limit: 50,
|
||||
}),
|
||||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categoriesData } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => categoriesAPI.getCategories({ limit: 100 }),
|
||||
});
|
||||
|
||||
// Create product mutation
|
||||
const createProductMutation = useMutation({
|
||||
mutationFn: (data: CreateProductForm) => productsAPI.createProduct(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast.success('Ürün başarıyla oluşturuldu!');
|
||||
setCreateDialogOpen(false);
|
||||
reset();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ürün oluşturulamadı');
|
||||
},
|
||||
});
|
||||
|
||||
// Update product mutation
|
||||
const updateProductMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: CreateProductForm }) =>
|
||||
productsAPI.updateProduct(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast.success('Ürün başarıyla güncellendi!');
|
||||
resetEdit();
|
||||
setEditDialogOpen(false);
|
||||
setSelectedProduct(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ürün güncellenirken hata oluştu');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete product mutation
|
||||
const deleteProductMutation = useMutation({
|
||||
mutationFn: (id: string) => productsAPI.deleteProduct(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast.success('Ürün başarıyla silindi!');
|
||||
handleMenuClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ürün silinemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CreateProductForm>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
barcode: '',
|
||||
brand: '',
|
||||
averagePrice: 0,
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { control: editControl, handleSubmit: handleEditSubmit, reset: resetEdit, formState: { errors: editErrors } } = useForm<CreateProductForm>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
averagePrice: 0,
|
||||
brand: '',
|
||||
barcode: '',
|
||||
},
|
||||
});
|
||||
|
||||
const products = productsData?.data?.data?.products || [];
|
||||
const categories = categoriesData?.data?.data?.categories || [];
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, product: Product) => {
|
||||
setMenuAnchor(event.currentTarget);
|
||||
setSelectedProduct(product);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchor(null);
|
||||
setSelectedProduct(null);
|
||||
};
|
||||
|
||||
const handleCreateProduct = (data: CreateProductForm) => {
|
||||
// averagePrice'ı price olarak gönder
|
||||
const cleanData = {
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
categoryId: data.categoryId,
|
||||
brand: data.brand || undefined,
|
||||
barcode: data.barcode || undefined,
|
||||
price: data.averagePrice || undefined,
|
||||
};
|
||||
createProductMutation.mutate(cleanData);
|
||||
};
|
||||
|
||||
const handleEditProduct = async (data: CreateProductForm) => {
|
||||
if (!selectedProduct) return;
|
||||
|
||||
const productData: any = {};
|
||||
|
||||
if (data.name) productData.name = data.name;
|
||||
if (data.barcode) productData.barcode = data.barcode;
|
||||
if (data.categoryId) productData.categoryId = data.categoryId;
|
||||
if (data.description !== undefined) productData.description = data.description;
|
||||
if (data.averagePrice !== undefined) productData.averagePrice = data.averagePrice;
|
||||
if (data.brand !== undefined) productData.brand = data.brand;
|
||||
|
||||
updateProductMutation.mutate({
|
||||
id: selectedProduct.id,
|
||||
data: productData,
|
||||
});
|
||||
};
|
||||
|
||||
const openEditDialog = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
resetEdit({
|
||||
name: product.name,
|
||||
description: product.description || '',
|
||||
categoryId: product.categoryId || '',
|
||||
averagePrice: product.averagePrice || 0,
|
||||
brand: product.brand || '',
|
||||
barcode: product.barcode || '',
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProduct = () => {
|
||||
if (selectedProduct) {
|
||||
deleteProductMutation.mutate(selectedProduct.id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSearchParams = (key: string, value: string | undefined) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Ürünler
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
sx={{ display: { xs: 'none', sm: 'flex' } }}
|
||||
>
|
||||
Ürün Ekle
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2, alignItems: 'center' }}>
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Ürün ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
updateSearchParams('search', e.target.value || undefined);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Kategori</InputLabel>
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
label="Kategori"
|
||||
onChange={(e) => {
|
||||
setSelectedCategory(e.target.value);
|
||||
updateSearchParams('categoryId', e.target.value || undefined);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">Tüm Kategoriler</MenuItem>
|
||||
{categories && categories.map((category: Category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sırala</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sırala"
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value);
|
||||
updateSearchParams('sortBy', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="name">İsim</MenuItem>
|
||||
<MenuItem value="createdAt">Oluşturma Tarihi</MenuItem>
|
||||
<MenuItem value="category.name">Kategori</MenuItem>
|
||||
<MenuItem value="brand">Marka</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Sıralama</InputLabel>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
label="Sıralama"
|
||||
onChange={(e) => {
|
||||
setSortOrder(e.target.value);
|
||||
updateSearchParams('sortOrder', e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="asc">Artan</MenuItem>
|
||||
<MenuItem value="desc">Azalan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Products Grid */}
|
||||
{isLoading ? (
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
) : products.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<ShoppingCart sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Ürün bulunamadı
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{searchTerm ? 'Arama kriterlerinizi ayarlamayı deneyin' : 'Başlamak için ilk ürününüzü ekleyin'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
İlk Ürününüzü Ekleyin
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 3 }}>
|
||||
{products.map((product: Product) => (
|
||||
<Box key={product.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { boxShadow: 4 },
|
||||
}}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
>
|
||||
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuOpen(e, product);
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{product.brand && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{product.brand}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{product.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
{product.category && (
|
||||
<Chip
|
||||
icon={getCategoryIcon(product.category.icon)}
|
||||
label={product.category.name}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: product.category.color ? `${product.category.color}20` : undefined,
|
||||
borderColor: product.category.color || undefined,
|
||||
color: product.category.color || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDistanceToNow(new Date(product.createdAt), { addSuffix: true })} eklendi
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Floating Action Button for Mobile */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="add"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
display: { xs: 'flex', sm: 'none' },
|
||||
}}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
|
||||
{/* Create Product Dialog */}
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Yeni Ürün Ekle</DialogTitle>
|
||||
<form onSubmit={handleSubmit(handleCreateProduct)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Ürün adı zorunludur',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Ürün adı en az 2 karakter olmalıdır'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Ürün Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Açıklama (isteğe bağlı)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="categoryId"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Kategori seçimi zorunludur'
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={categories || []}
|
||||
getOptionLabel={(option: Category) => option.name}
|
||||
value={categories?.find((c: Category) => c.id === field.value) || null}
|
||||
onChange={(_, value) => field.onChange(value?.id || '')}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Kategori"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
error={!!errors.categoryId}
|
||||
helperText={errors.categoryId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="averagePrice"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Fiyat zorunludur',
|
||||
min: {
|
||||
value: 0.01,
|
||||
message: 'Fiyat 0\'dan büyük olmalıdır'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Fiyat (₺)"
|
||||
fullWidth
|
||||
type="number"
|
||||
inputProps={{ step: "0.01", min: "0" }}
|
||||
variant="outlined"
|
||||
error={!!errors.averagePrice}
|
||||
helperText={errors.averagePrice?.message}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="brand"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Marka (isteğe bağlı)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="barcode"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Barkod (isteğe bağlı)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateDialogOpen(false)}>İptal</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={createProductMutation.isPending}
|
||||
>
|
||||
{createProductMutation.isPending ? 'Oluşturuluyor...' : 'Ürün Oluştur'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Product Dialog */}
|
||||
<Dialog
|
||||
open={editDialogOpen}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Ürün Düzenle</DialogTitle>
|
||||
<form onSubmit={handleEditSubmit(handleEditProduct)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={editControl}
|
||||
rules={{
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Ürün adı en az 2 karakter olmalıdır'
|
||||
},
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: 'Ürün adı en fazla 100 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Ürün Adı"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.name}
|
||||
helperText={editErrors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={editControl}
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: 500,
|
||||
message: 'Açıklama en fazla 500 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Açıklama"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows={3}
|
||||
error={!!editErrors.description}
|
||||
helperText={editErrors.description?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="categoryId"
|
||||
control={editControl}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
{...field}
|
||||
options={categories}
|
||||
getOptionLabel={(option: Category) => option.name}
|
||||
value={categories.find(cat => cat.id === field.value) || null}
|
||||
onChange={(_, value: Category | null) => field.onChange(value?.id || '')}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
margin="dense"
|
||||
label="Kategori"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.categoryId}
|
||||
helperText={editErrors.categoryId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="averagePrice"
|
||||
control={editControl}
|
||||
rules={{
|
||||
min: {
|
||||
value: 0,
|
||||
message: 'Fiyat 0 veya daha büyük olmalıdır'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Fiyat (₺)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type="number"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
error={!!editErrors.averagePrice}
|
||||
helperText={editErrors.averagePrice?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="brand"
|
||||
control={editControl}
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: 'Marka en fazla 100 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Marka"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.brand}
|
||||
helperText={editErrors.brand?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="barcode"
|
||||
control={editControl}
|
||||
rules={{
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Barkod en az 8 karakter olmalıdır'
|
||||
},
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message: 'Barkod en fazla 20 karakter olabilir'
|
||||
}
|
||||
}}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Barkod"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!editErrors.barcode}
|
||||
helperText={editErrors.barcode?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditDialogOpen(false)}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={updateProductMutation.isPending}
|
||||
>
|
||||
{updateProductMutation.isPending ? 'Güncelleniyor...' : 'Ürün Güncelle'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{/* Product Menu */}
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (selectedProduct) {
|
||||
openEditDialog(selectedProduct);
|
||||
}
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
Düzenle
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleDeleteProduct}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
Sil
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductsPage;
|
||||
1
frontend/src/pages/Products/index.ts
Normal file
1
frontend/src/pages/Products/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ProductsPage } from './ProductsPage';
|
||||
632
frontend/src/pages/Profile/ProfilePage.tsx
Normal file
632
frontend/src/pages/Profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
TextField,
|
||||
Avatar,
|
||||
Divider,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Tab,
|
||||
Tabs,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit,
|
||||
Save,
|
||||
Cancel,
|
||||
Person,
|
||||
Security,
|
||||
Notifications,
|
||||
Delete,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { usersAPI, notificationsAPI } from '../../services/api';
|
||||
import { User, UserSettings } from '../../types';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const profileSchema = yup.object({
|
||||
firstName: yup.string().required('Ad gereklidir').min(2, 'Ad en az 2 karakter olmalıdır'),
|
||||
lastName: yup.string().required('Soyad gereklidir').min(2, 'Soyad en az 2 karakter olmalıdır'),
|
||||
email: yup.string().required('E-posta gereklidir').email('Geçersiz e-posta formatı'),
|
||||
});
|
||||
|
||||
const passwordSchema = yup.object({
|
||||
currentPassword: yup.string().required('Mevcut şifre gereklidir'),
|
||||
newPassword: yup.string().required('Yeni şifre gereklidir').min(6, 'Şifre en az 6 karakter olmalıdır'),
|
||||
confirmPassword: yup.string()
|
||||
.required('Şifre onayı gereklidir')
|
||||
.oneOf([yup.ref('newPassword')], 'Şifreler eşleşmelidir'),
|
||||
});
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`profile-tabpanel-${index}`}
|
||||
aria-labelledby={`profile-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { user, updateProfile, changePassword } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
const [changePasswordDialogOpen, setChangePasswordDialogOpen] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// Fetch user settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['userSettings'],
|
||||
queryFn: () => usersAPI.getUserSettings(),
|
||||
});
|
||||
|
||||
// Fetch user activities - temporarily disabled
|
||||
// const { data: activitiesData } = useQuery({
|
||||
// queryKey: ['userActivities'],
|
||||
// queryFn: () => usersAPI.getUserActivities({ limit: 10 }),
|
||||
// });
|
||||
const activitiesData = { data: [] };
|
||||
|
||||
// Update profile mutation
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: (data: Partial<User>) => updateProfile(data),
|
||||
onSuccess: () => {
|
||||
toast.success('Profil başarıyla güncellendi!');
|
||||
setEditingProfile(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Profil güncellenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
// Change password mutation
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||
changePassword(data.currentPassword, data.newPassword),
|
||||
onSuccess: () => {
|
||||
toast.success('Şifre başarıyla değiştirildi!');
|
||||
setChangePasswordDialogOpen(false);
|
||||
passwordReset();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Şifre değiştirilemedi');
|
||||
},
|
||||
});
|
||||
|
||||
// Update settings mutation with optimistic cache sync to avoid flicker
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: (data: Partial<UserSettings>) => usersAPI.updateUserSettings(data),
|
||||
onSuccess: (res) => {
|
||||
const serverSettings = (res?.data?.data?.settings ?? {}) as Partial<UserSettings>;
|
||||
// Update local state to the authoritative server value
|
||||
setLocalSettings(serverSettings);
|
||||
// Update the react-query cache so refetch won't revert UI
|
||||
queryClient.setQueryData(['userSettings'], (prev: any) => {
|
||||
if (!prev) return res;
|
||||
try {
|
||||
const next = {
|
||||
...prev,
|
||||
data: {
|
||||
...prev.data,
|
||||
data: {
|
||||
...(prev?.data?.data || {}),
|
||||
settings: serverSettings,
|
||||
},
|
||||
},
|
||||
};
|
||||
return next;
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
});
|
||||
toast.success('Ayarlar başarıyla güncellendi!');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Ayarlar güncellenemedi');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control: profileControl,
|
||||
handleSubmit: handleProfileSubmit,
|
||||
reset: profileReset,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm({
|
||||
resolver: yupResolver(profileSchema),
|
||||
defaultValues: {
|
||||
firstName: user?.firstName || '',
|
||||
lastName: user?.lastName || '',
|
||||
email: user?.email || '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control: passwordControl,
|
||||
handleSubmit: handlePasswordSubmit,
|
||||
reset: passwordReset,
|
||||
formState: { errors: passwordErrors },
|
||||
} = useForm({
|
||||
resolver: yupResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Backend returns { success, data: { settings } }
|
||||
const settings = (settingsData?.data?.data?.settings ?? {}) as Partial<UserSettings>;
|
||||
const [localSettings, setLocalSettings] = useState<Partial<UserSettings>>({});
|
||||
const activities = activitiesData?.data || [];
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleUpdateProfile = (data: any) => {
|
||||
updateProfileMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleChangePassword = (data: any) => {
|
||||
changePasswordMutation.mutate({
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSettingChange = async (key: keyof UserSettings, value: boolean) => {
|
||||
const previous = localSettings[key];
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
try {
|
||||
await updateSettingsMutation.mutateAsync({ [key]: value } as Partial<UserSettings>);
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: previous as boolean }));
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// Avoid overriding optimistic state while a mutation is pending
|
||||
if (!updateSettingsMutation.isPending) {
|
||||
setLocalSettings(settings);
|
||||
}
|
||||
}, [settings, updateSettingsMutation.isPending]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
profileReset({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
});
|
||||
}
|
||||
}, [user, profileReset]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Typography>Yükleniyor...</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
mr: 3,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '2rem',
|
||||
}}
|
||||
>
|
||||
{user.firstName?.[0] || ''}{user.lastName?.[0] || ''}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{user.firstName || ''} {user.lastName || ''}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{user.email}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Üye olma tarihi: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
aria-label="profile tabs"
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab icon={<Person />} label="Profil" />
|
||||
<Tab icon={<Security />} label="Güvenlik" />
|
||||
<Tab icon={<Notifications />} label="Bildirimler" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Profile Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '2fr 1fr' }, gap: 3 }}>
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6">Kişisel Bilgiler</Typography>
|
||||
{!editingProfile ? (
|
||||
<Button
|
||||
startIcon={<Edit />}
|
||||
onClick={() => setEditingProfile(true)}
|
||||
>
|
||||
Düzenle
|
||||
</Button>
|
||||
) : (
|
||||
<Box>
|
||||
<Button
|
||||
startIcon={<Cancel />}
|
||||
onClick={() => {
|
||||
setEditingProfile(false);
|
||||
profileReset();
|
||||
}}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
onClick={handleProfileSubmit(handleUpdateProfile)}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
Kaydet
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleProfileSubmit(handleUpdateProfile)}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
|
||||
<Box>
|
||||
<Controller
|
||||
name="firstName"
|
||||
control={profileControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Ad"
|
||||
fullWidth
|
||||
disabled={!editingProfile}
|
||||
error={!!profileErrors.firstName}
|
||||
helperText={profileErrors.firstName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Controller
|
||||
name="lastName"
|
||||
control={profileControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Soyad"
|
||||
fullWidth
|
||||
disabled={!editingProfile}
|
||||
error={!!profileErrors.lastName}
|
||||
helperText={profileErrors.lastName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: '1 / -1' }}>
|
||||
<Controller
|
||||
name="email"
|
||||
control={profileControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="E-posta"
|
||||
fullWidth
|
||||
disabled={!editingProfile}
|
||||
error={!!profileErrors.email}
|
||||
helperText={profileErrors.email?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Son Aktiviteler
|
||||
</Typography>
|
||||
<List>
|
||||
{activities.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Son aktivite bulunmuyor
|
||||
</Typography>
|
||||
) : (
|
||||
activities.map((activity: any, index: number) => (
|
||||
<ListItem key={index} divider={index < activities.length - 1}>
|
||||
<ListItemText
|
||||
primary={activity.description}
|
||||
secondary={formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Security Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3 }}>
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Şifre
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Güçlü bir şifre kullanarak hesabınızı güvende tutun
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setChangePasswordDialogOpen(true)}
|
||||
>
|
||||
Şifre Değiştir
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Hesap Durumu
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ mr: 2 }}>
|
||||
Hesap Durumu:
|
||||
</Typography>
|
||||
<Chip
|
||||
label={user.isActive ? 'Aktif' : 'Pasif'}
|
||||
color={user.isActive ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Notifications Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Bildirim Tercihleri
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Hangi bildirimleri almak istediğinizi seçin
|
||||
</Typography>
|
||||
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="E-posta Bildirimleri"
|
||||
secondary="E-posta yoluyla bildirim alın"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={Boolean(localSettings.emailNotifications)}
|
||||
onChange={(e) => handleSettingChange('emailNotifications', e.target.checked)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Anlık Bildirimler"
|
||||
secondary="Cihazınızda anlık bildirimler alın"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={Boolean(localSettings.pushNotifications)}
|
||||
onChange={(e) => handleSettingChange('pushNotifications', e.target.checked)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Liste Güncellemeleri"
|
||||
secondary="Paylaşılan listeler güncellendiğinde bildirim alın"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={Boolean(localSettings.itemUpdateNotifications)}
|
||||
onChange={(e) => handleSettingChange('itemUpdateNotifications', e.target.checked)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
|
||||
{/* Pazarlama E-postaları kaldırıldı */}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* Change Password Dialog */}
|
||||
<Dialog
|
||||
open={changePasswordDialogOpen}
|
||||
onClose={() => setChangePasswordDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Şifre Değiştir</DialogTitle>
|
||||
<form onSubmit={handlePasswordSubmit(handleChangePassword)}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="currentPassword"
|
||||
control={passwordControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Mevcut Şifre"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!passwordErrors.currentPassword}
|
||||
helperText={passwordErrors.currentPassword?.message}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showCurrentPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="newPassword"
|
||||
control={passwordControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Yeni Şifre"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!passwordErrors.newPassword}
|
||||
helperText={passwordErrors.newPassword?.message}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showNewPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
control={passwordControl}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
margin="dense"
|
||||
label="Yeni Şifre Tekrar"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!passwordErrors.confirmPassword}
|
||||
helperText={passwordErrors.confirmPassword?.message}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setChangePasswordDialogOpen(false)}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? 'Değiştiriliyor...' : 'Şifre Değiştir'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
1
frontend/src/pages/Profile/index.ts
Normal file
1
frontend/src/pages/Profile/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
312
frontend/src/services/api.ts
Normal file
312
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import {
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ListsResponse,
|
||||
UsersResponse,
|
||||
User,
|
||||
ShoppingList,
|
||||
ListItem,
|
||||
Product,
|
||||
Category,
|
||||
Notification,
|
||||
Activity,
|
||||
DashboardStats,
|
||||
AdminStats,
|
||||
LoginForm,
|
||||
RegisterForm,
|
||||
CreateListForm,
|
||||
UpdateListForm,
|
||||
CreateItemForm,
|
||||
UpdateItemForm,
|
||||
CreateProductForm,
|
||||
CreateCategoryForm,
|
||||
ListFilters,
|
||||
ItemFilters,
|
||||
ProductFilters,
|
||||
UserSettings,
|
||||
} from '../types';
|
||||
|
||||
// Dinamik API URL belirleme
|
||||
const getApiBaseUrl = (): string => {
|
||||
// Önce environment variable'ı kontrol et
|
||||
if (process.env.REACT_APP_API_URL) {
|
||||
return process.env.REACT_APP_API_URL;
|
||||
}
|
||||
|
||||
// Eğer localhost'ta çalışıyorsa localhost:7001 kullan
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return 'http://localhost:7001/api';
|
||||
}
|
||||
|
||||
// Uzak erişimde aynı host'u kullan ama port 7001
|
||||
return `http://${window.location.hostname}:7001/api`;
|
||||
};
|
||||
|
||||
// Create axios instance
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
login: (data: LoginForm): Promise<AxiosResponse<ApiResponse<{ user: User; token: string }>>> =>
|
||||
api.post('/auth/login', data),
|
||||
|
||||
register: (data: RegisterForm): Promise<AxiosResponse<ApiResponse<{ user: User; token: string }>>> =>
|
||||
api.post('/auth/register', data),
|
||||
|
||||
getProfile: (): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.get('/auth/profile'),
|
||||
|
||||
updateProfile: (data: Partial<User>): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put('/auth/profile', data),
|
||||
|
||||
changePassword: (data: { currentPassword: string; newPassword: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/auth/change-password', data),
|
||||
|
||||
forgotPassword: (email: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/auth/forgot-password', { email }),
|
||||
|
||||
resetPassword: (data: { token: string; password: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/auth/reset-password', data),
|
||||
};
|
||||
|
||||
// Users API
|
||||
export const usersAPI = {
|
||||
getUsers: (params?: { page?: number; limit?: number; search?: string; status?: 'active' | 'inactive' }): Promise<AxiosResponse<UsersResponse>> =>
|
||||
api.get('/users', { params }),
|
||||
|
||||
getUser: (id: string): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.get(`/users/${id}`),
|
||||
|
||||
updateUser: (id: string, data: Partial<User>): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put(`/users/${id}`, data),
|
||||
|
||||
deleteUser: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/users/${id}`),
|
||||
|
||||
updateUserStatus: (id: string, isActive: boolean): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put(`/users/${id}/status`, { isActive }),
|
||||
|
||||
setUserAdmin: (id: string, isAdmin: boolean): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||
api.put(`/users/${id}/admin`, { isAdmin }),
|
||||
|
||||
resetUserPassword: (id: string, newPassword: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put(`/users/${id}/password`, { newPassword }),
|
||||
|
||||
getUserSettings: (): Promise<AxiosResponse<ApiResponse<{ settings: UserSettings }>>> =>
|
||||
api.get('/users/settings'),
|
||||
|
||||
updateUserSettings: (data: Partial<UserSettings>): Promise<AxiosResponse<ApiResponse<{ settings: UserSettings }>>> =>
|
||||
api.put('/users/settings', data),
|
||||
};
|
||||
|
||||
// Lists API
|
||||
export const listsAPI = {
|
||||
getLists: (params?: ListFilters & { page?: number; limit?: number }): Promise<AxiosResponse<ListsResponse>> =>
|
||||
api.get('/lists', { params }),
|
||||
|
||||
getList: (id: string): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.get(`/lists/${id}`),
|
||||
|
||||
createList: (data: CreateListForm): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.post('/lists', data),
|
||||
|
||||
updateList: (id: string, data: UpdateListForm): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.put(`/lists/${id}`, data),
|
||||
|
||||
deleteList: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/lists/${id}`),
|
||||
|
||||
getListMembers: (id: string): Promise<AxiosResponse<ApiResponse<{ members: any[] }>>> =>
|
||||
api.get(`/lists/${id}/members`),
|
||||
|
||||
addListMember: (id: string, data: { email: string; role: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post(`/lists/${id}/members`, data),
|
||||
|
||||
updateListMember: (id: string, memberId: string, data: { role: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put(`/lists/${id}/members/${memberId}`, data),
|
||||
|
||||
removeListMember: (id: string, memberId: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/lists/${id}/members/${memberId}`),
|
||||
|
||||
shareList: (id: string, data: { emails: string[]; message?: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post(`/lists/${id}/share`, data),
|
||||
|
||||
duplicateList: (id: string, data: { name: string; includeItems: boolean }): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||
api.post(`/lists/${id}/duplicate`, data),
|
||||
};
|
||||
|
||||
// Items API
|
||||
export const itemsAPI = {
|
||||
getListItems: (listId: string, params?: ItemFilters & { page?: number; limit?: number }): Promise<AxiosResponse<PaginatedResponse<ListItem>>> =>
|
||||
api.get(`/items/${listId}`, { params }),
|
||||
|
||||
getItem: (id: string): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.get(`/items/${id}`),
|
||||
|
||||
createItem: (listId: string, data: CreateItemForm): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.post(`/items/${listId}`, data),
|
||||
|
||||
updateItem: (listId: string, itemId: string, data: UpdateItemForm): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.put(`/items/${listId}/${itemId}`, data),
|
||||
|
||||
deleteItem: (listId: string, itemId: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/items/${listId}/${itemId}`),
|
||||
|
||||
bulkUpdateItems: (data: { itemIds: string[]; updates: Partial<ListItem> }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/items/bulk', data),
|
||||
|
||||
bulkDeleteItems: (itemIds: string[]): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete('/items/bulk', { data: { itemIds } }),
|
||||
|
||||
purchaseItem: (id: string, data?: { actualPrice?: number }): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.put(`/items/${id}/purchase`, data),
|
||||
|
||||
unpurchaseItem: (id: string): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||
api.put(`/items/${id}/unpurchase`),
|
||||
};
|
||||
|
||||
// Products API
|
||||
export const productsAPI = {
|
||||
getProducts: (params?: ProductFilters & { page?: number; limit?: number }): Promise<AxiosResponse<ApiResponse<{ products: Product[] }>>> =>
|
||||
api.get('/products', { params }),
|
||||
|
||||
getProduct: (id: string): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.get(`/products/${id}`),
|
||||
|
||||
createProduct: (data: CreateProductForm): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.post('/products', data),
|
||||
|
||||
updateProduct: (productId: string, data: Partial<CreateProductForm>): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.put(`/products/${productId}`, data),
|
||||
|
||||
deleteProduct: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/products/${id}`),
|
||||
|
||||
searchProducts: (query: string): Promise<AxiosResponse<ApiResponse<{ products: Product[] }>>> =>
|
||||
api.get(`/products/search?q=${encodeURIComponent(query)}`),
|
||||
|
||||
getProductByBarcode: (barcode: string): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||
api.get(`/products/barcode/${barcode}`),
|
||||
|
||||
addPriceHistory: (id: string, data: { price: number; store?: string; location?: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post(`/products/${id}/price-history`, data),
|
||||
};
|
||||
|
||||
// Categories API
|
||||
export const categoriesAPI = {
|
||||
getCategories: (params?: { page?: number; limit?: number; search?: string; parentId?: string; includeInactive?: boolean }): Promise<AxiosResponse<ApiResponse<{ categories: Category[] }>>> =>
|
||||
api.get('/categories', { params }),
|
||||
|
||||
getCategory: (id: string): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||
api.get(`/categories/${id}`),
|
||||
|
||||
createCategory: (data: CreateCategoryForm): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||
api.post('/categories', data),
|
||||
|
||||
updateCategory: (id: string, data: Partial<CreateCategoryForm>): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||
api.put(`/categories/${id}`, data),
|
||||
|
||||
deleteCategory: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/categories/${id}`),
|
||||
|
||||
getCategoryTree: (): Promise<AxiosResponse<ApiResponse<{ categories: Category[] }>>> =>
|
||||
api.get('/categories/tree'),
|
||||
};
|
||||
|
||||
// Notifications API
|
||||
export const notificationsAPI = {
|
||||
getNotifications: (params?: { page?: number; limit?: number; isRead?: boolean; type?: string }): Promise<AxiosResponse<PaginatedResponse<Notification>>> =>
|
||||
api.get('/notifications', { params }),
|
||||
|
||||
markAsRead: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put(`/notifications/${id}/read`),
|
||||
|
||||
markAllAsRead: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/notifications/read-all'),
|
||||
|
||||
deleteNotification: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/notifications/${id}`),
|
||||
|
||||
clearAllRead: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete('/notifications/clear-read'),
|
||||
|
||||
getUnreadCount: (): Promise<AxiosResponse<ApiResponse<{ count: number }>>> =>
|
||||
api.get('/notifications/unread-count'),
|
||||
|
||||
updateSettings: (data: any): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/notifications/settings', data),
|
||||
|
||||
registerDevice: (data: { token: string; platform: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/notifications/device', data),
|
||||
|
||||
unregisterDevice: (token: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.delete(`/notifications/device/${token}`),
|
||||
};
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardAPI = {
|
||||
getStats: (): Promise<AxiosResponse<ApiResponse<DashboardStats>>> =>
|
||||
api.get('/dashboard/stats'),
|
||||
|
||||
getRecentActivity: (params?: { limit?: number }): Promise<AxiosResponse<ApiResponse<{ activities: Activity[] }>>> =>
|
||||
api.get('/dashboard/activity', { params }),
|
||||
};
|
||||
|
||||
// Admin API
|
||||
export const adminAPI = {
|
||||
getStats: (): Promise<AxiosResponse<ApiResponse<AdminStats>>> =>
|
||||
api.get('/admin/dashboard'),
|
||||
|
||||
getRecentActivity: (params?: { page?: number; limit?: number; type?: string; userId?: string }): Promise<AxiosResponse<PaginatedResponse<Activity>>> =>
|
||||
api.get('/admin/activities', { params }),
|
||||
|
||||
getSystemSettings: (): Promise<AxiosResponse<ApiResponse<{ settings: any }>>> =>
|
||||
api.get('/admin/settings'),
|
||||
|
||||
updateSystemSettings: (data: any): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.put('/admin/settings', data),
|
||||
|
||||
getSystemStatus: (): Promise<AxiosResponse<ApiResponse<{ status: any }>>> =>
|
||||
api.get('/admin/status'),
|
||||
|
||||
backupDatabase: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||
api.post('/admin/backup'),
|
||||
|
||||
getSystemLogs: (params?: { level?: string; limit?: number }): Promise<AxiosResponse<ApiResponse<{ logs: any[] }>>> =>
|
||||
api.get('/admin/logs', { params }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
372
frontend/src/types/index.ts
Normal file
372
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
// User types
|
||||
export interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: 'USER' | 'ADMIN';
|
||||
isActive: boolean;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
settings?: UserSettings;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
id: string;
|
||||
userId: string;
|
||||
emailNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
listInviteNotifications: boolean;
|
||||
itemUpdateNotifications: boolean;
|
||||
priceAlertNotifications: boolean;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: string;
|
||||
currency: string;
|
||||
timezone: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// List types
|
||||
export interface ShoppingList {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared: boolean;
|
||||
ownerId: string;
|
||||
owner: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
members: ListMember[];
|
||||
items: ListItem[];
|
||||
_count?: {
|
||||
items: number;
|
||||
members: number;
|
||||
completedItems: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListMember {
|
||||
id: string;
|
||||
listId: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
role: 'OWNER' | 'EDITOR' | 'VIEWER';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
listId: string;
|
||||
list?: ShoppingList;
|
||||
name: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
|
||||
actualPrice?: number;
|
||||
isPurchased: boolean;
|
||||
purchasedAt?: string;
|
||||
purchasedBy?: string;
|
||||
purchaser?: User;
|
||||
productId?: string;
|
||||
product?: Product;
|
||||
categoryId?: string;
|
||||
category?: Category;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
notes?: string;
|
||||
imageUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
creator: User;
|
||||
}
|
||||
|
||||
// Product types
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
barcode?: string;
|
||||
brand?: string;
|
||||
unit?: string;
|
||||
categoryId?: string;
|
||||
category?: Category;
|
||||
averagePrice?: number;
|
||||
imageUrl?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
priceHistory: PriceHistory[];
|
||||
}
|
||||
|
||||
export interface PriceHistory {
|
||||
id: string;
|
||||
productId: string;
|
||||
product?: Product;
|
||||
price: number;
|
||||
store?: string;
|
||||
location?: string;
|
||||
userId?: string;
|
||||
user?: User;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
parentId?: string;
|
||||
parent?: Category;
|
||||
children?: Category[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Notification types
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
user?: User;
|
||||
type: 'LIST_INVITE' | 'LIST_UPDATE' | 'ITEM_UPDATE' | 'PRICE_ALERT' | 'SYSTEM';
|
||||
title: string;
|
||||
message: string;
|
||||
data?: any;
|
||||
isRead: boolean;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Activity types
|
||||
export interface Activity {
|
||||
id: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
type: 'LIST_CREATED' | 'LIST_UPDATED' | 'LIST_DELETED' | 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_DELETED' | 'MEMBER_ADDED' | 'MEMBER_REMOVED';
|
||||
description: string;
|
||||
metadata?: any;
|
||||
listId?: string;
|
||||
list?: ShoppingList;
|
||||
itemId?: string;
|
||||
item?: ListItem;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListsResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: {
|
||||
lists: ShoppingList[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: {
|
||||
users: User[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Form types
|
||||
export interface LoginForm {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterForm {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface CreateListForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateListForm {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateItemForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
|
||||
productId?: string;
|
||||
categoryId?: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateItemForm {
|
||||
name?: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
|
||||
actualPrice?: number;
|
||||
isPurchased?: boolean;
|
||||
categoryId?: string;
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateProductForm {
|
||||
name: string;
|
||||
categoryId: string;
|
||||
barcode?: string;
|
||||
description?: string;
|
||||
brand?: string;
|
||||
unit?: string;
|
||||
averagePrice?: number;
|
||||
price?: number;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface CreateCategoryForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
// Filter and search types
|
||||
export interface ListFilters {
|
||||
search?: string;
|
||||
isShared?: boolean;
|
||||
ownerId?: string;
|
||||
sortBy?: 'name' | 'createdAt' | 'updatedAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ItemFilters {
|
||||
search?: string;
|
||||
purchased?: string;
|
||||
categoryId?: string;
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
sortBy?: 'name' | 'createdAt' | 'priority';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ProductFilters {
|
||||
search?: string;
|
||||
categoryId?: string;
|
||||
brand?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
sortBy?: 'name' | 'averagePrice' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Dashboard types
|
||||
export interface DashboardStats {
|
||||
totalLists: number;
|
||||
totalItems: number;
|
||||
completedItems: number;
|
||||
pendingItems: number;
|
||||
sharedLists: number;
|
||||
recentActivity: Activity[];
|
||||
}
|
||||
|
||||
// Admin types
|
||||
export interface AdminStats {
|
||||
users: {
|
||||
total: number;
|
||||
active: number;
|
||||
new: number;
|
||||
};
|
||||
lists: {
|
||||
total: number;
|
||||
shared: number;
|
||||
private: number;
|
||||
};
|
||||
products: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
categories: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
notifications: {
|
||||
total: number;
|
||||
unread: number;
|
||||
};
|
||||
activities: {
|
||||
total: number;
|
||||
today: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Socket event types
|
||||
export interface SocketEvents {
|
||||
// List events
|
||||
'list-updated': { list: ShoppingList };
|
||||
'list-deleted': { listId: string };
|
||||
'list-member-added': { list: ShoppingList; member: ListMember };
|
||||
'list-member-removed': { list: ShoppingList; memberId: string };
|
||||
'list-invitation': { list: ShoppingList; inviter: User };
|
||||
|
||||
// Item events
|
||||
'list-item-added': { list: ShoppingList; item: ListItem };
|
||||
'list-item-updated': { list: ShoppingList; item: ListItem };
|
||||
'list-item-removed': { list: ShoppingList; item: ListItem };
|
||||
|
||||
// Notification events
|
||||
'notification': Notification;
|
||||
|
||||
// Error events
|
||||
'error': { message: string; code?: string };
|
||||
}
|
||||
Reference in New Issue
Block a user