RestroAi/src/components/admin/InventoryView.tsx

449 lines
17 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
CircularProgress,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Chip,
Select,
MenuItem,
InputLabel,
FormControl,
List,
ListItem,
ListItemText,
Divider,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import SaveIcon from '@mui/icons-material/Save';
import { api } from '../../services/api';
import type { StaffMenuItem } from '../../services/api';
export const InventoryView: React.FC = () => {
const [ingredients, setIngredients] = useState<any[]>([]);
const [menuDishes, setMenuDishes] = useState<StaffMenuItem[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Ingredient Form Dialog States
const [openIngForm, setOpenIngForm] = useState<boolean>(false);
const [editingIng, setEditingIng] = useState<any | null>(null);
const [ingName, setIngName] = useState<string>('');
const [ingStock, setIngStock] = useState<number>(0);
const [ingUnit, setIngUnit] = useState<string>('kg');
const [ingMinStock, setIngMinStock] = useState<number>(2);
// Recipe Builder States
const [selectedMenuId, setSelectedMenuId] = useState<number>(5); // Default to item 5
// Backend schema: { ingredient_id, quantity_required }
const [recipeIngredients, setRecipeIngredients] = useState<{ ingredient_id: number; quantity_required: number }[]>([]);
const [recipeLoading, setRecipeLoading] = useState<boolean>(false);
// Adding single ingredient to recipe
const [addIngId, setAddIngId] = useState<string>('');
const [addIngQty, setAddIngQty] = useState<number>(0.1);
const fetchIngredients = async () => {
setLoading(true);
setError(null);
try {
const [data, dishes] = await Promise.all([
api.getIngredients(),
api.listMenuItems().catch(() => []),
]);
setIngredients(Array.isArray(data) ? data : []);
const dishList = Array.isArray(dishes) ? dishes : [];
setMenuDishes(dishList);
if (dishList.length && !dishList.some((d) => d.id === selectedMenuId)) {
setSelectedMenuId(dishList[0].id);
}
} catch (err: any) {
setError(err.message || 'Failed to fetch ingredients');
} finally {
setLoading(false);
}
};
const fetchRecipe = async (menuId: number) => {
setRecipeLoading(true);
try {
const data = await api.getRecipe(menuId);
// Backend returns list[RecipeIngredientRead] - an array directly
setRecipeIngredients(Array.isArray(data) ? data : (data?.ingredients || []));
} catch (err) {
console.error('Failed to load recipe', err);
} finally {
setRecipeLoading(false);
}
};
useEffect(() => {
fetchIngredients();
}, []);
useEffect(() => {
fetchRecipe(selectedMenuId);
}, [selectedMenuId]);
const handleSaveIngredient = async () => {
setError(null);
try {
if (editingIng) {
// Edit existing — backend only allows: name, unit, reorder_threshold, unit_cost
const updated = await api.updateIngredient(editingIng.id, {
name: ingName,
reorder_threshold: ingMinStock,
});
setIngredients((prev) =>
prev.map((i) => (i.id === editingIng.id ? updated : i))
);
} else {
// Create new — backend schema: name, unit, current_stock, reorder_threshold, unit_cost
const created = await api.createIngredient(ingName, ingStock, ingUnit, ingMinStock);
setIngredients((prev) => [...prev, created]);
}
setOpenIngForm(false);
setEditingIng(null);
setIngName('');
setIngStock(0);
setIngMinStock(2);
} catch (err: any) {
setError(err.message || 'Failed to save ingredient');
}
};
const handleDeleteIngredient = async (id: string) => {
if (!window.confirm('Delete this ingredient?')) return;
try {
await api.deleteIngredient(id);
setIngredients((prev) => prev.filter((i) => i.id !== id));
} catch (err: any) {
alert(err.message || 'Failed to delete ingredient');
}
};
const handleSaveRecipe = async () => {
try {
await api.saveRecipe(selectedMenuId, recipeIngredients);
alert('Recipe updated successfully!');
} catch (err: any) {
alert(err.message || 'Failed to save recipe');
}
};
const handleAddRecipeIngredient = () => {
if (!addIngId || addIngQty <= 0) return;
if (recipeIngredients.some(r => r.ingredient_id === Number(addIngId))) {
alert('Ingredient already added to recipe!');
return;
}
setRecipeIngredients(prev => [...prev, { ingredient_id: Number(addIngId), quantity_required: addIngQty }]);
setAddIngId('');
};
const handleRemoveRecipeIngredient = (ingId: number) => {
setRecipeIngredients(prev => prev.filter(r => r.ingredient_id !== ingId));
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Kitchen Stock & Recipes
</Typography>
<Typography variant="body2" color="text.secondary">
Keep track of ingredient raw stocks, identify low stock warnings, and link menu items to recipes.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
onClick={() => {
setEditingIng(null);
setIngName('');
setIngStock(0);
setIngUnit('kg');
setIngMinStock(2);
setOpenIngForm(true);
}}
startIcon={<AddIcon />}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Add Raw Stock
</Button>
<Button
variant="outlined"
onClick={fetchIngredients}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh Stock
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
<Grid container spacing={3}>
{/* Ingredients Grid */}
<Grid size={{ xs: 12, md: 7 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Ingredient Stocks List
</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (
<TableContainer component={Paper} elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<Table size="small">
<TableHead sx={{ bgcolor: '#fbfbfb' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Ingredient</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Stock Qty</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Alert Level</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ingredients.map((ing) => {
// Backend fields: current_stock, reorder_threshold
const currentStock = Number(ing.current_stock ?? ing.stock ?? 0);
const threshold = Number(ing.reorder_threshold ?? ing.minStock ?? 0);
const isLow = currentStock <= threshold;
return (
<TableRow key={ing.id} sx={{ bgcolor: isLow ? 'rgba(237, 108, 2, 0.05)' : 'inherit' }}>
<TableCell sx={{ fontWeight: 700 }}>{ing.name}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{currentStock} {ing.unit}</TableCell>
<TableCell>
{isLow ? (
<Chip label="LOW STOCK" color="warning" size="small" sx={{ fontWeight: 850, fontSize: '0.6rem' }} />
) : (
<Chip label="GOOD" color="success" size="small" sx={{ fontWeight: 850, fontSize: '0.6rem' }} />
)}
</TableCell>
<TableCell align="right" sx={{ display: 'flex', gap: 0.5, justifyContent: 'flex-end' }}>
<IconButton
size="small"
onClick={() => {
setEditingIng(ing);
setIngName(ing.name);
setIngStock(currentStock);
setIngUnit(ing.unit);
setIngMinStock(threshold);
setOpenIngForm(true);
}}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDeleteIngredient(ing.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Grid>
{/* Recipes Linker Panel */}
<Grid size={{ xs: 12, md: 5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Dish Recipes Linking
</Typography>
<Card elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px', p: 2 }}>
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
<InputLabel>Select Menu Dish</InputLabel>
<Select
value={selectedMenuId}
label="Select Menu Dish"
onChange={(e) => setSelectedMenuId(Number(e.target.value))}
>
{menuDishes.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.name} ({Number(item.price)})
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>
Recipe Ingredients needed:
</Typography>
{recipeLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress size={30} /></Box>
) : (
<Box sx={{ mb: 3 }}>
{recipeIngredients.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, fontStyle: 'italic' }}>
No ingredients linked to this dish recipe yet. Link below.
</Typography>
) : (
<List disablePadding>
{recipeIngredients.map((item) => {
// Backend fields: ingredient_id, quantity_required
const detail = ingredients.find((i: any) => i.id === item.ingredient_id);
return (
<ListItem
key={item.ingredient_id}
secondaryAction={
<IconButton size="small" color="error" onClick={() => handleRemoveRecipeIngredient(item.ingredient_id)}>
<DeleteIcon fontSize="small" />
</IconButton>
}
sx={{ px: 0, py: 0.5 }}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{detail ? detail.name : `Ingredient #${item.ingredient_id}`}
</Typography>
}
secondary={`Required: ${item.quantity_required} ${detail ? detail.unit : 'kg'}`}
/>
</ListItem>
);
})}
</List>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="caption" sx={{ fontWeight: 800, color: 'text.secondary', display: 'block', mb: 1 }}>
LINK ANOTHER STOCK COMPONENT:
</Typography>
<Grid container spacing={1} sx={{ alignItems: 'center' }}>
<Grid size={{ xs: 6 }}>
<FormControl fullWidth size="small">
<Select
value={addIngId}
displayEmpty
onChange={(e) => setAddIngId(e.target.value)}
>
<MenuItem value="">Choose stock...</MenuItem>
{ingredients.map((ing) => (
<MenuItem key={ing.id} value={ing.id}>{ing.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 3 }}>
<TextField
type="number"
size="small"
placeholder="Qty"
value={addIngQty}
onChange={(e) => setAddIngQty(parseFloat(e.target.value) || 0)}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
size="small"
onClick={handleAddRecipeIngredient}
sx={{ height: 38, borderColor: '#ac2d00', color: '#ac2d00', fontWeight: 700 }}
>
Add
</Button>
</Grid>
</Grid>
</Box>
)}
<Button
fullWidth
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSaveRecipe}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Save Recipe Link
</Button>
</Card>
</Grid>
</Grid>
{/* Ingredient Form Dialog */}
<Dialog open={openIngForm} onClose={() => setOpenIngForm(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>
{editingIng ? 'Modify Raw Ingredient Stock' : 'Add Raw Stock Ingredient'}
</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Ingredient Name"
value={ingName}
onChange={(e) => setIngName(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Available Stock"
type="number"
value={ingStock}
onChange={(e) => setIngStock(parseFloat(e.target.value) || 0)}
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel>Measurement Unit</InputLabel>
<Select
value={ingUnit}
label="Measurement Unit"
onChange={(e) => setIngUnit(e.target.value)}
>
<MenuItem value="kg">kg (Kilogram)</MenuItem>
<MenuItem value="g">g (Gram)</MenuItem>
<MenuItem value="litre">litre (Litre)</MenuItem>
<MenuItem value="units">units (Items count)</MenuItem>
</Select>
</FormControl>
<TextField
label="Minimum Alert Limit"
type="number"
value={ingMinStock}
onChange={(e) => setIngMinStock(parseFloat(e.target.value) || 0)}
fullWidth
size="small"
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpenIngForm(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleSaveIngredient}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Save Stock
</Button>
</DialogActions>
</Dialog>
</Box>
);
};