Files
HouseOrg/app/(tabs)/index.tsx
T
2026-06-01 23:16:10 +02:00

485 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 AZ' },
{ key: 'name_desc', label: 'Name ZA' },
{ 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' },
});