485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
FlatList,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
StyleSheet,
|
||
Modal,
|
||
ScrollView,
|
||
} from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { router } from 'expo-router';
|
||
import { MaterialIcons } from '@expo/vector-icons';
|
||
import { useHouseholdStore } from '../../src/hooks/useHousehold';
|
||
import { ItemCard } from '../../src/components/inventory/ItemCard';
|
||
import { updateItemQuantity } from '../../src/services/items';
|
||
import { COLORS, CATEGORY_LABELS, BASE_CATEGORIES } from '../../src/constants';
|
||
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
|
||
import { isExpiringSoon, isExpired } from '../../src/utils';
|
||
import Toast from 'react-native-toast-message';
|
||
|
||
type SortKey = 'name_asc' | 'name_desc' | 'qty_asc' | 'qty_desc' | 'expiry_asc' | 'added_desc';
|
||
type MhdFilter = 'all' | 'expiring' | 'expired';
|
||
|
||
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
||
{ key: 'name_asc', label: 'Name A–Z' },
|
||
{ key: 'name_desc', label: 'Name Z–A' },
|
||
{ key: 'qty_asc', label: 'Menge ↑' },
|
||
{ key: 'qty_desc', label: 'Menge ↓' },
|
||
{ key: 'expiry_asc', label: 'MHD ↑' },
|
||
{ key: 'added_desc', label: 'Neueste' },
|
||
];
|
||
|
||
const MHD_OPTIONS: { key: MhdFilter; label: string }[] = [
|
||
{ key: 'all', label: 'Alle' },
|
||
{ key: 'expiring', label: 'Bald ablaufend' },
|
||
{ key: 'expired', label: 'Abgelaufen' },
|
||
];
|
||
|
||
export default function InventoryScreen() {
|
||
const items = useHouseholdStore((s) => s.items);
|
||
const household = useHouseholdStore((s) => s.household);
|
||
const deviceId = useHouseholdStore((s) => s.deviceId);
|
||
|
||
const [search, setSearch] = useState('');
|
||
const [filterVisible, setFilterVisible] = useState(false);
|
||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||
const [selectedLocations, setSelectedLocations] = useState<string[]>([]);
|
||
const [mhdFilter, setMhdFilter] = useState<MhdFilter>('all');
|
||
const [sortKey, setSortKey] = useState<SortKey>('name_asc');
|
||
|
||
const { options: categoryOptions } = useCustomOptions('houseorg_custom_categories', [...BASE_CATEGORIES]);
|
||
|
||
const availableLocations = useMemo(
|
||
() => Array.from(new Set(items.map((i) => i.storageLocation).filter(Boolean))).sort() as string[],
|
||
[items]
|
||
);
|
||
|
||
const activeFilterCount =
|
||
selectedCategories.length +
|
||
selectedLocations.length +
|
||
(mhdFilter !== 'all' ? 1 : 0) +
|
||
(sortKey !== 'name_asc' ? 1 : 0);
|
||
|
||
const filtered = useMemo(() => {
|
||
const term = search.toLowerCase();
|
||
let result = items.filter((item) => {
|
||
if (term && !item.name.toLowerCase().includes(term)) return false;
|
||
if (selectedCategories.length > 0 && !selectedCategories.includes(item.category)) return false;
|
||
if (selectedLocations.length > 0 && !selectedLocations.includes(item.storageLocation)) return false;
|
||
if (mhdFilter === 'expiring' && !isExpiringSoon(item.expiryDate)) return false;
|
||
if (mhdFilter === 'expired' && !isExpired(item.expiryDate)) return false;
|
||
return true;
|
||
});
|
||
|
||
return [...result].sort((a, b) => {
|
||
switch (sortKey) {
|
||
case 'name_asc': return a.name.localeCompare(b.name, 'de');
|
||
case 'name_desc': return b.name.localeCompare(a.name, 'de');
|
||
case 'qty_asc': return a.quantity - b.quantity;
|
||
case 'qty_desc': return b.quantity - a.quantity;
|
||
case 'expiry_asc':
|
||
if (!a.expiryDate && !b.expiryDate) return 0;
|
||
if (!a.expiryDate) return 1;
|
||
if (!b.expiryDate) return -1;
|
||
return a.expiryDate.getTime() - b.expiryDate.getTime();
|
||
case 'added_desc': return b.createdAt.getTime() - a.createdAt.getTime();
|
||
default: return 0;
|
||
}
|
||
});
|
||
}, [items, search, selectedCategories, selectedLocations, mhdFilter, sortKey]);
|
||
|
||
const toggleCategory = (cat: string) =>
|
||
setSelectedCategories((prev) =>
|
||
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||
);
|
||
|
||
const toggleLocation = (loc: string) =>
|
||
setSelectedLocations((prev) =>
|
||
prev.includes(loc) ? prev.filter((l) => l !== loc) : [...prev, loc]
|
||
);
|
||
|
||
const resetFilters = () => {
|
||
setSelectedCategories([]);
|
||
setSelectedLocations([]);
|
||
setMhdFilter('all');
|
||
setSortKey('name_asc');
|
||
};
|
||
|
||
const handleQuantityChange = async (itemId: string, qty: number) => {
|
||
if (!household) return;
|
||
try {
|
||
await updateItemQuantity(household.id, itemId, qty);
|
||
} catch {
|
||
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
|
||
}
|
||
};
|
||
|
||
const activeSummary = [
|
||
selectedCategories.length > 0 &&
|
||
`${selectedCategories.length} Kategorie${selectedCategories.length > 1 ? 'n' : ''}`,
|
||
selectedLocations.length > 0 &&
|
||
`${selectedLocations.length} Ort${selectedLocations.length > 1 ? 'e' : ''}`,
|
||
mhdFilter !== 'all' && (mhdFilter === 'expiring' ? 'Bald ablaufend' : 'Abgelaufen'),
|
||
sortKey !== 'name_asc' && SORT_OPTIONS.find((s) => s.key === sortKey)?.label,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['top']}>
|
||
<View style={styles.searchRow}>
|
||
<View style={styles.searchBox}>
|
||
<MaterialIcons name="search" size={18} color={COLORS.textSecondary} />
|
||
<TextInput
|
||
style={styles.searchInput}
|
||
placeholder="Suchen…"
|
||
placeholderTextColor={COLORS.textSecondary}
|
||
value={search}
|
||
onChangeText={setSearch}
|
||
clearButtonMode="while-editing"
|
||
/>
|
||
</View>
|
||
<TouchableOpacity
|
||
style={[styles.filterBtn, activeFilterCount > 0 && styles.filterBtnActive]}
|
||
onPress={() => setFilterVisible(true)}
|
||
accessibilityLabel="Filter"
|
||
>
|
||
<MaterialIcons
|
||
name="tune"
|
||
size={20}
|
||
color={activeFilterCount > 0 ? COLORS.white : COLORS.text}
|
||
/>
|
||
{activeFilterCount > 0 && (
|
||
<View style={styles.filterBadge}>
|
||
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={styles.addBtn}
|
||
onPress={() =>
|
||
router.push({
|
||
pathname: '/modals/add-item',
|
||
params: { householdId: household?.id ?? '', deviceId: deviceId ?? '' },
|
||
})
|
||
}
|
||
accessibilityLabel="Artikel hinzufügen"
|
||
>
|
||
<MaterialIcons name="add" size={24} color={COLORS.white} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{activeFilterCount > 0 && (
|
||
<View style={styles.activeFilterBar}>
|
||
<Text style={styles.activeFilterText} numberOfLines={1}>
|
||
{activeSummary}
|
||
</Text>
|
||
<TouchableOpacity onPress={resetFilters} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||
<Text style={styles.resetText}>Zurücksetzen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
|
||
<FlatList
|
||
data={filtered}
|
||
keyExtractor={(item) => item.id}
|
||
renderItem={({ item }) => (
|
||
<ItemCard
|
||
item={item}
|
||
onPress={() =>
|
||
router.push({ pathname: '/modals/item-detail', params: { itemId: item.id } })
|
||
}
|
||
onQuantityChange={(qty) => handleQuantityChange(item.id, qty)}
|
||
/>
|
||
)}
|
||
contentContainerStyle={[styles.list, filtered.length === 0 && styles.listEmpty]}
|
||
ListEmptyComponent={
|
||
<View style={styles.empty}>
|
||
<View style={styles.emptyIconWrap}>
|
||
<MaterialIcons
|
||
name={search || activeFilterCount > 0 ? 'search-off' : 'kitchen'}
|
||
size={36}
|
||
color={COLORS.primaryLight}
|
||
/>
|
||
</View>
|
||
<Text style={styles.emptyTitle}>
|
||
{search || activeFilterCount > 0 ? 'Keine Treffer' : 'Noch leer'}
|
||
</Text>
|
||
<Text style={styles.emptyText}>
|
||
{search || activeFilterCount > 0
|
||
? 'Filter oder Suche anpassen.'
|
||
: 'Tippe auf + und fotografiere deinen ersten Artikel.'}
|
||
</Text>
|
||
</View>
|
||
}
|
||
/>
|
||
|
||
<Modal visible={filterVisible} transparent animationType="slide">
|
||
<View style={styles.modalContainer}>
|
||
<TouchableOpacity style={styles.modalOverlay} onPress={() => setFilterVisible(false)} />
|
||
<View style={styles.filterSheet}>
|
||
<View style={styles.filterHandle} />
|
||
<View style={styles.filterHeader}>
|
||
<Text style={styles.filterTitle}>Filter & Sortierung</Text>
|
||
<TouchableOpacity onPress={resetFilters}>
|
||
<Text style={styles.resetText}>Zurücksetzen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<ScrollView
|
||
showsVerticalScrollIndicator={false}
|
||
contentContainerStyle={styles.filterScrollContent}
|
||
>
|
||
<FilterSection title="Sortierung">
|
||
<View style={styles.chipWrap}>
|
||
{SORT_OPTIONS.map(({ key, label }) => (
|
||
<TouchableOpacity
|
||
key={key}
|
||
style={[styles.chip, sortKey === key && styles.chipActive]}
|
||
onPress={() => setSortKey(key)}
|
||
>
|
||
<Text style={[styles.chipText, sortKey === key && styles.chipTextActive]}>
|
||
{label}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
</FilterSection>
|
||
|
||
<FilterSection title="Kategorie">
|
||
<View style={styles.chipWrap}>
|
||
{categoryOptions.map((cat) => (
|
||
<TouchableOpacity
|
||
key={cat}
|
||
style={[styles.chip, selectedCategories.includes(cat) && styles.chipActive]}
|
||
onPress={() => toggleCategory(cat)}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.chipText,
|
||
selectedCategories.includes(cat) && styles.chipTextActive,
|
||
]}
|
||
>
|
||
{CATEGORY_LABELS[cat] ?? cat}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
</FilterSection>
|
||
|
||
{availableLocations.length > 0 && (
|
||
<FilterSection title="Lagerort">
|
||
<View style={styles.chipWrap}>
|
||
{availableLocations.map((loc) => (
|
||
<TouchableOpacity
|
||
key={loc}
|
||
style={[styles.chip, selectedLocations.includes(loc) && styles.chipActive]}
|
||
onPress={() => toggleLocation(loc)}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.chipText,
|
||
selectedLocations.includes(loc) && styles.chipTextActive,
|
||
]}
|
||
>
|
||
{loc}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
</FilterSection>
|
||
)}
|
||
|
||
<FilterSection title="MHD">
|
||
<View style={styles.chipWrap}>
|
||
{MHD_OPTIONS.map(({ key, label }) => (
|
||
<TouchableOpacity
|
||
key={key}
|
||
style={[styles.chip, mhdFilter === key && styles.chipActive]}
|
||
onPress={() => setMhdFilter(key)}
|
||
>
|
||
<Text style={[styles.chipText, mhdFilter === key && styles.chipTextActive]}>
|
||
{label}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
</FilterSection>
|
||
</ScrollView>
|
||
|
||
<TouchableOpacity style={styles.applyBtn} onPress={() => setFilterVisible(false)}>
|
||
<Text style={styles.applyBtnText}>
|
||
{filtered.length === 0
|
||
? 'Keine Treffer'
|
||
: `${filtered.length} Artikel anzeigen`}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
function FilterSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<View style={styles.filterSection}>
|
||
<Text style={styles.filterSectionTitle}>{title}</Text>
|
||
{children}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: COLORS.surface },
|
||
searchRow: {
|
||
flexDirection: 'row',
|
||
gap: 10,
|
||
paddingHorizontal: 16,
|
||
paddingTop: 12,
|
||
paddingBottom: 8,
|
||
},
|
||
searchBox: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: COLORS.white,
|
||
borderRadius: 12,
|
||
paddingHorizontal: 12,
|
||
gap: 8,
|
||
height: 46,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.04,
|
||
shadowRadius: 4,
|
||
elevation: 1,
|
||
},
|
||
searchInput: { flex: 1, fontSize: 15, color: COLORS.text },
|
||
filterBtn: {
|
||
width: 46,
|
||
height: 46,
|
||
borderRadius: 14,
|
||
backgroundColor: COLORS.white,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.04,
|
||
shadowRadius: 4,
|
||
elevation: 1,
|
||
},
|
||
filterBtnActive: { backgroundColor: COLORS.primary },
|
||
filterBadge: {
|
||
position: 'absolute',
|
||
top: -4,
|
||
right: -4,
|
||
width: 18,
|
||
height: 18,
|
||
borderRadius: 9,
|
||
backgroundColor: COLORS.danger,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
filterBadgeText: { fontSize: 10, fontWeight: '700', color: COLORS.white },
|
||
addBtn: {
|
||
width: 46,
|
||
height: 46,
|
||
borderRadius: 14,
|
||
backgroundColor: COLORS.primary,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
shadowColor: COLORS.primary,
|
||
shadowOpacity: 0.35,
|
||
shadowRadius: 8,
|
||
shadowOffset: { width: 0, height: 3 },
|
||
elevation: 4,
|
||
},
|
||
activeFilterBar: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 16,
|
||
paddingBottom: 6,
|
||
gap: 8,
|
||
},
|
||
activeFilterText: { fontSize: 12, color: COLORS.textSecondary, flex: 1 },
|
||
resetText: { fontSize: 12, color: COLORS.primary, fontWeight: '600' },
|
||
list: { paddingVertical: 8, paddingBottom: 32 },
|
||
listEmpty: { flex: 1 },
|
||
empty: { flex: 1, paddingTop: 80, alignItems: 'center', gap: 10, paddingHorizontal: 32 },
|
||
emptyIconWrap: {
|
||
width: 72,
|
||
height: 72,
|
||
borderRadius: 24,
|
||
backgroundColor: '#E8F5E9',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
marginBottom: 4,
|
||
},
|
||
emptyTitle: { fontSize: 17, fontWeight: '600', color: COLORS.text },
|
||
emptyText: { color: COLORS.textSecondary, textAlign: 'center', fontSize: 14, lineHeight: 20 },
|
||
// Modal
|
||
modalContainer: { flex: 1, justifyContent: 'flex-end' },
|
||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' },
|
||
filterSheet: {
|
||
backgroundColor: COLORS.white,
|
||
borderTopLeftRadius: 24,
|
||
borderTopRightRadius: 24,
|
||
maxHeight: '78%',
|
||
paddingTop: 12,
|
||
},
|
||
filterHandle: {
|
||
width: 36,
|
||
height: 4,
|
||
borderRadius: 2,
|
||
backgroundColor: COLORS.border,
|
||
alignSelf: 'center',
|
||
marginBottom: 16,
|
||
},
|
||
filterHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 20,
|
||
marginBottom: 4,
|
||
},
|
||
filterTitle: { fontSize: 17, fontWeight: '700', color: COLORS.text },
|
||
filterScrollContent: { paddingHorizontal: 20, paddingBottom: 8 },
|
||
filterSection: { marginTop: 22 },
|
||
filterSectionTitle: {
|
||
fontSize: 11,
|
||
fontWeight: '600',
|
||
color: COLORS.textSecondary,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 0.7,
|
||
marginBottom: 10,
|
||
},
|
||
chipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||
chip: {
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 8,
|
||
borderRadius: 20,
|
||
backgroundColor: COLORS.surface,
|
||
borderWidth: 1,
|
||
borderColor: COLORS.border,
|
||
},
|
||
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
|
||
chipText: { fontSize: 13, color: COLORS.text, fontWeight: '500' },
|
||
chipTextActive: { color: COLORS.white, fontWeight: '600' },
|
||
applyBtn: {
|
||
margin: 16,
|
||
marginTop: 12,
|
||
backgroundColor: COLORS.primary,
|
||
borderRadius: 14,
|
||
paddingVertical: 16,
|
||
alignItems: 'center',
|
||
shadowColor: COLORS.primary,
|
||
shadowOpacity: 0.28,
|
||
shadowRadius: 8,
|
||
shadowOffset: { width: 0, height: 3 },
|
||
elevation: 4,
|
||
},
|
||
applyBtnText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
|
||
});
|