449 lines
17 KiB
TypeScript
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>
|
|
);
|
|
};
|