435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
createIngredientCatalogItem,
|
|
deleteIngredientCatalogItem,
|
|
fetchIngredientCatalogItems,
|
|
fetchIngredientCategories,
|
|
updateIngredientCatalogItem,
|
|
} from '@/src/api/ingredient-catalog.api';
|
|
import { Button } from '@/src/components/Button';
|
|
import { ErrorView } from '@/src/components/ErrorView';
|
|
import { Input } from '@/src/components/Input';
|
|
import { Screen } from '@/src/components/Screen';
|
|
import { useAppearance } from '@/src/context/AppearanceContext';
|
|
import type {
|
|
IngredientCategory,
|
|
IngredientNutritionItem,
|
|
UpsertIngredientNutritionItemInput,
|
|
} from '@/src/types/ingredient-catalog';
|
|
import { extractApiError } from '@/src/utils/apiError';
|
|
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
|
|
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
|
|
|
|
type IngredientSortColumn = 'name' | 'category' | 'calories' | 'protein' | 'fat' | 'carbs' | 'fiber';
|
|
|
|
const EMPTY_FORM: UpsertIngredientNutritionItemInput = {
|
|
categoryId: 0,
|
|
name: '',
|
|
namePl: '',
|
|
caloriesPer100G: 0,
|
|
proteinGPer100G: 0,
|
|
fatGPer100G: 0,
|
|
carbsGPer100G: 0,
|
|
fiberGPer100G: null,
|
|
sortOrder: 0,
|
|
isActive: true,
|
|
};
|
|
|
|
export default function IngredientsScreen() {
|
|
const { t, i18n } = useTranslation();
|
|
const { colors } = useAppearance();
|
|
const queryClient = useQueryClient();
|
|
const [category, setCategory] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [form, setForm] = useState<UpsertIngredientNutritionItemInput>({ ...EMPTY_FORM });
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [sort, setSort] = useState<{ column: IngredientSortColumn; direction: SortDirection }>({
|
|
column: 'name',
|
|
direction: 'asc',
|
|
});
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
|
return () => clearTimeout(timer);
|
|
}, [search]);
|
|
|
|
const categoriesQuery = useQuery({
|
|
queryKey: ['ingredient-categories'],
|
|
queryFn: fetchIngredientCategories,
|
|
});
|
|
|
|
const itemsQuery = useQuery({
|
|
queryKey: ['ingredient-catalog', category, debouncedSearch],
|
|
queryFn: () =>
|
|
fetchIngredientCatalogItems({
|
|
category: category || undefined,
|
|
search: debouncedSearch || undefined,
|
|
}),
|
|
});
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const payload = {
|
|
...form,
|
|
name: form.name.trim(),
|
|
namePl: form.namePl?.trim() || null,
|
|
fiberGPer100G: form.fiberGPer100G ?? null,
|
|
};
|
|
if (editingId) return updateIngredientCatalogItem(editingId, payload);
|
|
return createIngredientCatalogItem(payload);
|
|
},
|
|
onSuccess: () => {
|
|
setShowForm(false);
|
|
setEditingId(null);
|
|
setFormError(null);
|
|
void queryClient.invalidateQueries({ queryKey: ['ingredient-categories'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['ingredient-catalog'] });
|
|
},
|
|
onError: (err) => {
|
|
setFormError(extractApiError(err, t('errors.saveIngredientFailed')));
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: deleteIngredientCatalogItem,
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['ingredient-categories'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['ingredient-catalog'] });
|
|
},
|
|
});
|
|
|
|
const categoryLabel = (code: string, fallback: string) =>
|
|
t(`ingredientCatalog.categories.${code}`, { defaultValue: fallback });
|
|
|
|
const totalCount = useMemo(
|
|
() => (categoriesQuery.data ?? []).reduce((sum, c) => sum + c.itemCount, 0),
|
|
[categoriesQuery.data],
|
|
);
|
|
|
|
const openCreateForm = () => {
|
|
setEditingId(null);
|
|
setForm({ ...EMPTY_FORM, categoryId: categoriesQuery.data?.[0]?.id ?? 0 });
|
|
setFormError(null);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const openEditForm = (item: IngredientNutritionItem) => {
|
|
setEditingId(item.id);
|
|
setForm({
|
|
categoryId: item.categoryId,
|
|
name: item.name,
|
|
namePl: item.namePl ?? '',
|
|
caloriesPer100G: item.caloriesPer100G,
|
|
proteinGPer100G: item.proteinGPer100G,
|
|
fatGPer100G: item.fatGPer100G,
|
|
carbsGPer100G: item.carbsGPer100G,
|
|
fiberGPer100G: item.fiberGPer100G,
|
|
sortOrder: 0,
|
|
isActive: true,
|
|
});
|
|
setFormError(null);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const saveForm = () => {
|
|
if (!form.categoryId || !form.name.trim()) {
|
|
setFormError(t('ingredientCatalog.formInvalid'));
|
|
return;
|
|
}
|
|
saveMutation.mutate();
|
|
};
|
|
|
|
const deleteItem = (id: number) => {
|
|
Alert.alert(t('common.delete'), t('ingredientCatalog.deleteConfirm'), [
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{ text: t('common.delete'), style: 'destructive', onPress: () => deleteMutation.mutate(id) },
|
|
]);
|
|
};
|
|
|
|
const loading = categoriesQuery.isLoading || itemsQuery.isLoading;
|
|
const error = categoriesQuery.error ?? itemsQuery.error;
|
|
const items = itemsQuery.data ?? [];
|
|
|
|
const sortedItems = useMemo(() => {
|
|
const { column, direction } = sort;
|
|
return sortBy(items, direction, (item) => {
|
|
switch (column) {
|
|
case 'name':
|
|
return ingredientDisplayName(item, i18n.language);
|
|
case 'category':
|
|
return item.categoryCode;
|
|
case 'calories':
|
|
return item.caloriesPer100G;
|
|
case 'protein':
|
|
return Number(item.proteinGPer100G);
|
|
case 'fat':
|
|
return Number(item.fatGPer100G);
|
|
case 'carbs':
|
|
return Number(item.carbsGPer100G);
|
|
case 'fiber':
|
|
return item.fiberGPer100G != null ? Number(item.fiberGPer100G) : null;
|
|
}
|
|
});
|
|
}, [items, sort, i18n.language]);
|
|
|
|
const toggleSort = (column: IngredientSortColumn) => {
|
|
setSort((current) => nextSort(current, column));
|
|
};
|
|
|
|
return (
|
|
<Screen
|
|
title={t('ingredientCatalog.title')}
|
|
subtitle={t('ingredientCatalog.subtitle')}
|
|
loading={loading}
|
|
>
|
|
<Button title={`+ ${t('ingredientCatalog.addIngredient')}`} variant="secondary" onPress={openCreateForm} />
|
|
|
|
{showForm ? (
|
|
<View style={[styles.formBox, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
|
<Text style={[styles.formTitle, { color: colors.text }]}>
|
|
{editingId ? t('ingredientCatalog.editIngredient') : t('ingredientCatalog.addIngredient')}
|
|
</Text>
|
|
{formError ? <Text style={{ color: '#dc2626', marginBottom: 8 }}>{formError}</Text> : null}
|
|
<Text style={[styles.label, { color: colors.text }]}>{t('ingredientCatalog.columns.category')}</Text>
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
|
|
{(categoriesQuery.data ?? []).map((cat: IngredientCategory) => (
|
|
<Chip
|
|
key={cat.id}
|
|
label={categoryLabel(cat.code, cat.name)}
|
|
active={form.categoryId === cat.id}
|
|
onPress={() => setForm((prev) => ({ ...prev, categoryId: cat.id }))}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
<Input
|
|
label={t('ingredientCatalog.nameEn')}
|
|
value={form.name}
|
|
onChangeText={(v) => setForm((prev) => ({ ...prev, name: v }))}
|
|
/>
|
|
<Input
|
|
label={t('ingredientCatalog.namePlLabel')}
|
|
value={form.namePl ?? ''}
|
|
onChangeText={(v) => setForm((prev) => ({ ...prev, namePl: v }))}
|
|
/>
|
|
{(
|
|
[
|
|
['caloriesPer100G', 'ingredientCatalog.columns.calories'],
|
|
['proteinGPer100G', 'ingredientCatalog.columns.protein'],
|
|
['fatGPer100G', 'ingredientCatalog.columns.fat'],
|
|
['carbsGPer100G', 'ingredientCatalog.columns.carbs'],
|
|
['fiberGPer100G', 'ingredientCatalog.columns.fiber'],
|
|
] as const
|
|
).map(([key, labelKey]) => (
|
|
<Input
|
|
key={key}
|
|
label={t(labelKey)}
|
|
keyboardType="decimal-pad"
|
|
value={form[key]?.toString() ?? ''}
|
|
onChangeText={(v) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
[key]: v === '' ? null : Number(v),
|
|
}))
|
|
}
|
|
/>
|
|
))}
|
|
<View style={styles.formActions}>
|
|
<Button title={t('common.save')} loading={saveMutation.isPending} onPress={saveForm} />
|
|
<Button title={t('common.cancel')} variant="secondary" onPress={() => setShowForm(false)} />
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
|
|
<Chip
|
|
label={`${t('ingredientCatalog.allCategories')} (${totalCount})`}
|
|
active={category === ''}
|
|
onPress={() => setCategory('')}
|
|
/>
|
|
{(categoriesQuery.data ?? []).map((cat: IngredientCategory) => (
|
|
<Chip
|
|
key={cat.code}
|
|
label={`${categoryLabel(cat.code, cat.name)} (${cat.itemCount})`}
|
|
active={category === cat.code}
|
|
onPress={() => setCategory(cat.code)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
|
|
<Input
|
|
label={t('common.search')}
|
|
value={search}
|
|
onChangeText={setSearch}
|
|
placeholder={t('ingredientCatalog.searchPlaceholder')}
|
|
/>
|
|
|
|
{(itemsQuery.data ?? []).length > 0 ? (
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sortRow}>
|
|
{(
|
|
[
|
|
['name', 'ingredientCatalog.columns.name'],
|
|
['category', 'ingredientCatalog.columns.category'],
|
|
['calories', 'ingredientCatalog.columns.calories'],
|
|
['protein', 'ingredientCatalog.columns.protein'],
|
|
['fat', 'ingredientCatalog.columns.fat'],
|
|
['carbs', 'ingredientCatalog.columns.carbs'],
|
|
['fiber', 'ingredientCatalog.columns.fiber'],
|
|
] as const
|
|
).map(([column, labelKey]) => (
|
|
<SortChip
|
|
key={column}
|
|
label={`${t(labelKey)}${sortIndicator(sort, column)}`}
|
|
active={sort.column === column}
|
|
onPress={() => toggleSort(column)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<ErrorView
|
|
message={extractApiError(error, t('errors.loadIngredientCatalogFailed'))}
|
|
onRetry={() => {
|
|
categoriesQuery.refetch();
|
|
itemsQuery.refetch();
|
|
}}
|
|
/>
|
|
) : (itemsQuery.data ?? []).length === 0 ? (
|
|
<View style={styles.empty}>
|
|
<Text style={[styles.emptyTitle, { color: colors.text }]}>{t('ingredientCatalog.noResultsTitle')}</Text>
|
|
<Text style={{ color: colors.textMuted }}>{t('ingredientCatalog.noResultsDescription')}</Text>
|
|
</View>
|
|
) : (
|
|
<ScrollView contentContainerStyle={styles.list}>
|
|
{sortedItems.map((item) => (
|
|
<View
|
|
key={item.id}
|
|
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
|
>
|
|
<View style={styles.cardHeader}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={[styles.cardTitle, { color: colors.text }]}>
|
|
{ingredientDisplayName(item, i18n.language)}
|
|
</Text>
|
|
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
|
|
{categoryLabel(item.categoryCode, item.categoryName)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.cardActions}>
|
|
<Pressable onPress={() => openEditForm(item)}>
|
|
<Text style={{ color: colors.brand, fontWeight: '600' }}>{t('common.edit')}</Text>
|
|
</Pressable>
|
|
<Pressable onPress={() => deleteItem(item.id)}>
|
|
<Text style={{ color: '#dc2626', fontWeight: '600' }}>{t('common.delete')}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
<View style={styles.macros}>
|
|
<Macro label={t('ingredientCatalog.columns.calories')} value={`${item.caloriesPer100G}`} />
|
|
<Macro label={t('ingredientCatalog.columns.protein')} value={formatNum(item.proteinGPer100G)} />
|
|
<Macro label={t('ingredientCatalog.columns.fat')} value={formatNum(item.fatGPer100G)} />
|
|
<Macro label={t('ingredientCatalog.columns.carbs')} value={formatNum(item.carbsGPer100G)} />
|
|
{item.fiberGPer100G != null ? (
|
|
<Macro label={t('ingredientCatalog.columns.fiber')} value={formatNum(item.fiberGPer100G)} />
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
))}
|
|
<Text style={[styles.note, { color: colors.textMuted }]}>{t('ingredientCatalog.per100gNote')}</Text>
|
|
</ScrollView>
|
|
)}
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
function SortChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
const { colors } = useAppearance();
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={[
|
|
styles.sortChip,
|
|
{
|
|
backgroundColor: active ? colors.brand : colors.surface,
|
|
borderColor: active ? colors.brand : colors.border,
|
|
},
|
|
]}
|
|
>
|
|
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 12 }}>{label}</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function Chip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
const { colors } = useAppearance();
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={[
|
|
styles.chip,
|
|
{
|
|
backgroundColor: active ? colors.brand : colors.surface,
|
|
borderColor: active ? colors.brand : colors.border,
|
|
},
|
|
]}
|
|
>
|
|
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>{label}</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function Macro({ label, value }: { label: string; value: string }) {
|
|
const { colors } = useAppearance();
|
|
return (
|
|
<View style={styles.macro}>
|
|
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{label}</Text>
|
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{value}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function formatNum(value: number): string {
|
|
return Number(value).toFixed(1);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
chips: { gap: 8, paddingBottom: 12 },
|
|
sortRow: { gap: 8, paddingBottom: 8 },
|
|
sortChip: {
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
},
|
|
chip: {
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
},
|
|
formBox: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 8, marginBottom: 8 },
|
|
formTitle: { fontSize: 17, fontWeight: '700', marginBottom: 4 },
|
|
formActions: { flexDirection: 'row', gap: 8, marginTop: 4 },
|
|
label: { fontSize: 14, fontWeight: '600' },
|
|
list: { gap: 12, paddingBottom: 24 },
|
|
card: {
|
|
borderWidth: 1,
|
|
borderRadius: 16,
|
|
padding: 16,
|
|
},
|
|
cardHeader: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 },
|
|
cardActions: { gap: 8, alignItems: 'flex-end' },
|
|
cardTitle: { fontSize: 16, fontWeight: '700', marginBottom: 2 },
|
|
macros: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
|
|
macro: { minWidth: 72 },
|
|
note: { fontSize: 12, textAlign: 'center', marginTop: 8 },
|
|
empty: { paddingVertical: 32, alignItems: 'center', gap: 8 },
|
|
emptyTitle: { fontSize: 16, fontWeight: '700' },
|
|
});
|