542 lines
14 KiB
TypeScript
542 lines
14 KiB
TypeScript
|
|
/**
|
||
|
|
* Example usage of the restructured recipes API
|
||
|
|
* Demonstrates tenant-dependent routing and React Query hooks
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useState } from 'react';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import {
|
||
|
|
useRecipes,
|
||
|
|
useRecipe,
|
||
|
|
useCreateRecipe,
|
||
|
|
useUpdateRecipe,
|
||
|
|
useDeleteRecipe,
|
||
|
|
useDuplicateRecipe,
|
||
|
|
useActivateRecipe,
|
||
|
|
useRecipeStatistics,
|
||
|
|
useRecipeCategories,
|
||
|
|
useRecipeFeasibility,
|
||
|
|
type RecipeResponse,
|
||
|
|
type RecipeCreate,
|
||
|
|
type RecipeSearchParams,
|
||
|
|
MeasurementUnit,
|
||
|
|
} from '../api';
|
||
|
|
import { useCurrentTenant } from '../stores/tenant.store';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Example: Recipe List Component
|
||
|
|
* Shows how to use the tenant-dependent useRecipes hook
|
||
|
|
*/
|
||
|
|
export const RecipesList: React.FC = () => {
|
||
|
|
const { t } = useTranslation('recipes');
|
||
|
|
const currentTenant = useCurrentTenant();
|
||
|
|
const [filters, setFilters] = useState<RecipeSearchParams>({
|
||
|
|
limit: 20,
|
||
|
|
offset: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Use tenant-dependent recipes hook
|
||
|
|
const {
|
||
|
|
data: recipes,
|
||
|
|
isLoading,
|
||
|
|
error,
|
||
|
|
refetch,
|
||
|
|
} = useRecipes(currentTenant?.id || '', filters, {
|
||
|
|
enabled: !!currentTenant?.id,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!currentTenant) {
|
||
|
|
return <div>{t('messages.no_tenant_selected')}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return <div>{t('messages.loading_recipes')}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
return <div>Error: {error.message}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="recipes-list">
|
||
|
|
<h2>{t('title')}</h2>
|
||
|
|
|
||
|
|
{/* Search and filters */}
|
||
|
|
<div className="filters">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder={t('filters.search_placeholder')}
|
||
|
|
value={filters.search_term || ''}
|
||
|
|
onChange={(e) => setFilters({ ...filters, search_term: e.target.value })}
|
||
|
|
/>
|
||
|
|
<select
|
||
|
|
value={filters.status || ''}
|
||
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||
|
|
>
|
||
|
|
<option value="">{t('filters.all')}</option>
|
||
|
|
<option value="active">{t('status.active')}</option>
|
||
|
|
<option value="draft">{t('status.draft')}</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Recipe cards */}
|
||
|
|
<div className="recipe-grid">
|
||
|
|
{recipes?.map((recipe) => (
|
||
|
|
<RecipeCard key={recipe.id} recipe={recipe} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{(!recipes || recipes.length === 0) && (
|
||
|
|
<div className="no-results">{t('messages.no_recipes_found')}</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Example: Individual Recipe Card
|
||
|
|
*/
|
||
|
|
interface RecipeCardProps {
|
||
|
|
recipe: RecipeResponse;
|
||
|
|
}
|
||
|
|
|
||
|
|
const RecipeCard: React.FC<RecipeCardProps> = ({ recipe }) => {
|
||
|
|
const { t } = useTranslation('recipes');
|
||
|
|
const currentTenant = useCurrentTenant();
|
||
|
|
|
||
|
|
// Mutation hooks for recipe actions
|
||
|
|
const duplicateRecipe = useDuplicateRecipe(currentTenant?.id || '');
|
||
|
|
const activateRecipe = useActivateRecipe(currentTenant?.id || '');
|
||
|
|
const deleteRecipe = useDeleteRecipe(currentTenant?.id || '');
|
||
|
|
|
||
|
|
const handleDuplicate = () => {
|
||
|
|
if (!currentTenant) return;
|
||
|
|
|
||
|
|
duplicateRecipe.mutate({
|
||
|
|
id: recipe.id,
|
||
|
|
data: { new_name: `${recipe.name} (Copy)` }
|
||
|
|
}, {
|
||
|
|
onSuccess: () => {
|
||
|
|
alert(t('messages.recipe_duplicated'));
|
||
|
|
},
|
||
|
|
onError: (error) => {
|
||
|
|
alert(error.message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleActivate = () => {
|
||
|
|
if (!currentTenant) return;
|
||
|
|
|
||
|
|
activateRecipe.mutate(recipe.id, {
|
||
|
|
onSuccess: () => {
|
||
|
|
alert(t('messages.recipe_activated'));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDelete = () => {
|
||
|
|
if (!currentTenant) return;
|
||
|
|
|
||
|
|
if (confirm(t('messages.confirm_delete'))) {
|
||
|
|
deleteRecipe.mutate(recipe.id, {
|
||
|
|
onSuccess: () => {
|
||
|
|
alert(t('messages.recipe_deleted'));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="recipe-card">
|
||
|
|
<h3>{recipe.name}</h3>
|
||
|
|
<p>{recipe.description}</p>
|
||
|
|
|
||
|
|
<div className="recipe-meta">
|
||
|
|
<span className={`status status-${recipe.status}`}>
|
||
|
|
{t(`status.${recipe.status}`)}
|
||
|
|
</span>
|
||
|
|
<span className="category">{recipe.category}</span>
|
||
|
|
<span className="difficulty">
|
||
|
|
{t(`difficulty.${recipe.difficulty_level}`)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="recipe-actions">
|
||
|
|
<button onClick={handleDuplicate} disabled={duplicateRecipe.isPending}>
|
||
|
|
{t('actions.duplicate_recipe')}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{recipe.status === 'draft' && (
|
||
|
|
<button onClick={handleActivate} disabled={activateRecipe.isPending}>
|
||
|
|
{t('actions.activate_recipe')}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<button onClick={handleDelete} disabled={deleteRecipe.isPending}>
|
||
|
|
{t('actions.delete_recipe')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Example: Recipe Detail View
|
||
|
|
*/
|
||
|
|
interface RecipeDetailProps {
|
||
|
|
recipeId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const RecipeDetail: React.FC<RecipeDetailProps> = ({ recipeId }) => {
|
||
|
|
const { t } = useTranslation('recipes');
|
||
|
|
const currentTenant = useCurrentTenant();
|
||
|
|
|
||
|
|
// Get individual recipe with tenant context
|
||
|
|
const {
|
||
|
|
data: recipe,
|
||
|
|
isLoading,
|
||
|
|
error,
|
||
|
|
} = useRecipe(currentTenant?.id || '', recipeId, {
|
||
|
|
enabled: !!(currentTenant?.id && recipeId),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Check feasibility
|
||
|
|
const {
|
||
|
|
data: feasibility,
|
||
|
|
} = useRecipeFeasibility(
|
||
|
|
currentTenant?.id || '',
|
||
|
|
recipeId,
|
||
|
|
1.0, // batch multiplier
|
||
|
|
{
|
||
|
|
enabled: !!(currentTenant?.id && recipeId),
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!currentTenant) {
|
||
|
|
return <div>{t('messages.no_tenant_selected')}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return <div>{t('messages.loading_recipe')}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error || !recipe) {
|
||
|
|
return <div>Recipe not found</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="recipe-detail">
|
||
|
|
<header className="recipe-header">
|
||
|
|
<h1>{recipe.name}</h1>
|
||
|
|
<p>{recipe.description}</p>
|
||
|
|
|
||
|
|
<div className="recipe-info">
|
||
|
|
<span>Yield: {recipe.yield_quantity} {recipe.yield_unit}</span>
|
||
|
|
<span>Prep: {recipe.prep_time_minutes}min</span>
|
||
|
|
<span>Cook: {recipe.cook_time_minutes}min</span>
|
||
|
|
<span>Total: {recipe.total_time_minutes}min</span>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
{/* Feasibility check */}
|
||
|
|
{feasibility && (
|
||
|
|
<div className={`feasibility ${feasibility.feasible ? 'feasible' : 'not-feasible'}`}>
|
||
|
|
<h3>{t('feasibility.title')}</h3>
|
||
|
|
<p>
|
||
|
|
{feasibility.feasible
|
||
|
|
? t('feasibility.feasible')
|
||
|
|
: t('feasibility.not_feasible')
|
||
|
|
}
|
||
|
|
</p>
|
||
|
|
|
||
|
|
{feasibility.missing_ingredients.length > 0 && (
|
||
|
|
<div>
|
||
|
|
<h4>{t('feasibility.missing_ingredients')}</h4>
|
||
|
|
<ul>
|
||
|
|
{feasibility.missing_ingredients.map((ingredient, index) => (
|
||
|
|
<li key={index}>{JSON.stringify(ingredient)}</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Ingredients */}
|
||
|
|
<section className="ingredients">
|
||
|
|
<h2>{t('ingredients.title')}</h2>
|
||
|
|
<ul>
|
||
|
|
{recipe.ingredients?.map((ingredient) => (
|
||
|
|
<li key={ingredient.id}>
|
||
|
|
{ingredient.quantity} {ingredient.unit} - {ingredient.ingredient_id}
|
||
|
|
{ingredient.preparation_method && (
|
||
|
|
<span className="prep-method"> ({ingredient.preparation_method})</span>
|
||
|
|
)}
|
||
|
|
{ingredient.is_optional && (
|
||
|
|
<span className="optional"> ({t('ingredients.is_optional')})</span>
|
||
|
|
)}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* Instructions */}
|
||
|
|
{recipe.instructions && (
|
||
|
|
<section className="instructions">
|
||
|
|
<h2>{t('fields.instructions')}</h2>
|
||
|
|
<div>{JSON.stringify(recipe.instructions, null, 2)}</div>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Example: Recipe Creation Form
|
||
|
|
*/
|
||
|
|
export const CreateRecipeForm: React.FC = () => {
|
||
|
|
const { t } = useTranslation('recipes');
|
||
|
|
const currentTenant = useCurrentTenant();
|
||
|
|
|
||
|
|
const [formData, setFormData] = useState<RecipeCreate>({
|
||
|
|
name: '',
|
||
|
|
finished_product_id: '',
|
||
|
|
yield_quantity: 1,
|
||
|
|
yield_unit: MeasurementUnit.UNITS,
|
||
|
|
difficulty_level: 1,
|
||
|
|
batch_size_multiplier: 1.0,
|
||
|
|
is_seasonal: false,
|
||
|
|
is_signature_item: false,
|
||
|
|
ingredients: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
const createRecipe = useCreateRecipe(currentTenant?.id || '', {
|
||
|
|
onSuccess: (data) => {
|
||
|
|
alert(t('messages.recipe_created'));
|
||
|
|
// Reset form or redirect
|
||
|
|
setFormData({
|
||
|
|
name: '',
|
||
|
|
finished_product_id: '',
|
||
|
|
yield_quantity: 1,
|
||
|
|
yield_unit: MeasurementUnit.UNITS,
|
||
|
|
difficulty_level: 1,
|
||
|
|
batch_size_multiplier: 1.0,
|
||
|
|
is_seasonal: false,
|
||
|
|
is_signature_item: false,
|
||
|
|
ingredients: [],
|
||
|
|
});
|
||
|
|
},
|
||
|
|
onError: (error) => {
|
||
|
|
alert(error.message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const handleSubmit = (e: React.FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
|
||
|
|
if (!currentTenant) {
|
||
|
|
alert('No tenant selected');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!formData.name.trim()) {
|
||
|
|
alert(t('messages.recipe_name_required'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (formData.ingredients.length === 0) {
|
||
|
|
alert(t('messages.at_least_one_ingredient'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
createRecipe.mutate(formData);
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!currentTenant) {
|
||
|
|
return <div>{t('messages.no_tenant_selected')}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<form onSubmit={handleSubmit} className="create-recipe-form">
|
||
|
|
<h2>{t('actions.create_recipe')}</h2>
|
||
|
|
|
||
|
|
<div className="form-group">
|
||
|
|
<label htmlFor="name">{t('fields.name')}</label>
|
||
|
|
<input
|
||
|
|
id="name"
|
||
|
|
type="text"
|
||
|
|
value={formData.name}
|
||
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
|
|
placeholder={t('placeholders.recipe_name')}
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-group">
|
||
|
|
<label htmlFor="description">{t('fields.description')}</label>
|
||
|
|
<textarea
|
||
|
|
id="description"
|
||
|
|
value={formData.description || ''}
|
||
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
|
|
placeholder={t('placeholders.description')}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<div className="form-group">
|
||
|
|
<label htmlFor="yield_quantity">{t('fields.yield_quantity')}</label>
|
||
|
|
<input
|
||
|
|
id="yield_quantity"
|
||
|
|
type="number"
|
||
|
|
min="0.1"
|
||
|
|
step="0.1"
|
||
|
|
value={formData.yield_quantity}
|
||
|
|
onChange={(e) => setFormData({ ...formData, yield_quantity: parseFloat(e.target.value) })}
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-group">
|
||
|
|
<label htmlFor="yield_unit">{t('fields.yield_unit')}</label>
|
||
|
|
<select
|
||
|
|
id="yield_unit"
|
||
|
|
value={formData.yield_unit}
|
||
|
|
onChange={(e) => setFormData({ ...formData, yield_unit: e.target.value as MeasurementUnit })}
|
||
|
|
required
|
||
|
|
>
|
||
|
|
{Object.values(MeasurementUnit).map((unit) => (
|
||
|
|
<option key={unit} value={unit}>
|
||
|
|
{t(`units.${unit}`)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-group">
|
||
|
|
<label htmlFor="difficulty_level">{t('fields.difficulty_level')}</label>
|
||
|
|
<select
|
||
|
|
id="difficulty_level"
|
||
|
|
value={formData.difficulty_level}
|
||
|
|
onChange={(e) => setFormData({ ...formData, difficulty_level: parseInt(e.target.value) })}
|
||
|
|
>
|
||
|
|
{[1, 2, 3, 4, 5].map((level) => (
|
||
|
|
<option key={level} value={level}>
|
||
|
|
{t(`difficulty.${level}`)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-checkboxes">
|
||
|
|
<label>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={formData.is_signature_item}
|
||
|
|
onChange={(e) => setFormData({ ...formData, is_signature_item: e.target.checked })}
|
||
|
|
/>
|
||
|
|
{t('fields.is_signature')}
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<label>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={formData.is_seasonal}
|
||
|
|
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
|
||
|
|
/>
|
||
|
|
{t('fields.is_seasonal')}
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Ingredients section would go here */}
|
||
|
|
<div className="ingredients-section">
|
||
|
|
<h3>{t('ingredients.title')}</h3>
|
||
|
|
<p>{t('messages.no_ingredients')}</p>
|
||
|
|
{/* Add ingredient form components here */}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-actions">
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={createRecipe.isPending}
|
||
|
|
className="btn-primary"
|
||
|
|
>
|
||
|
|
{createRecipe.isPending ? 'Creating...' : t('actions.create_recipe')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Example: Recipe Statistics Dashboard
|
||
|
|
*/
|
||
|
|
export const RecipeStatistics: React.FC = () => {
|
||
|
|
const { t } = useTranslation('recipes');
|
||
|
|
const currentTenant = useCurrentTenant();
|
||
|
|
|
||
|
|
const { data: stats, isLoading } = useRecipeStatistics(currentTenant?.id || '', {
|
||
|
|
enabled: !!currentTenant?.id,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { data: categories } = useRecipeCategories(currentTenant?.id || '', {
|
||
|
|
enabled: !!currentTenant?.id,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!currentTenant) {
|
||
|
|
return <div>{t('messages.no_tenant_selected')}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return <div>Loading statistics...</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="recipe-statistics">
|
||
|
|
<h2>{t('statistics.title')}</h2>
|
||
|
|
|
||
|
|
{stats && (
|
||
|
|
<div className="stats-grid">
|
||
|
|
<div className="stat-card">
|
||
|
|
<h3>{t('statistics.total_recipes')}</h3>
|
||
|
|
<span className="stat-number">{stats.total_recipes}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="stat-card">
|
||
|
|
<h3>{t('statistics.active_recipes')}</h3>
|
||
|
|
<span className="stat-number">{stats.active_recipes}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="stat-card">
|
||
|
|
<h3>{t('statistics.signature_recipes')}</h3>
|
||
|
|
<span className="stat-number">{stats.signature_recipes}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="stat-card">
|
||
|
|
<h3>{t('statistics.seasonal_recipes')}</h3>
|
||
|
|
<span className="stat-number">{stats.seasonal_recipes}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{categories && (
|
||
|
|
<div className="categories-list">
|
||
|
|
<h3>Categories</h3>
|
||
|
|
<ul>
|
||
|
|
{categories.categories.map((category) => (
|
||
|
|
<li key={category}>{category}</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default {
|
||
|
|
RecipesList,
|
||
|
|
RecipeDetail,
|
||
|
|
CreateRecipeForm,
|
||
|
|
RecipeStatistics,
|
||
|
|
};
|