First commit
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { DatePickerModal } from '../../src/components/ui/DatePicker';
|
||||
import { useHouseholdStore } from '../../src/hooks/useHousehold';
|
||||
import { updateItem, deleteItem, updateItemQuantity } from '../../src/services/items';
|
||||
import { QuantityControl } from '../../src/components/ui/QuantityControl';
|
||||
import { COLORS, CATEGORY_LABELS, STORAGE_LOCATIONS } from '../../src/constants';
|
||||
import { ChipSelectInput } from '../../src/components/ui/ChipSelectInput';
|
||||
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
|
||||
import { formatDate, isExpired, isExpiringSoon } from '../../src/utils';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
export default function ItemDetailModal() {
|
||||
const { itemId } = useLocalSearchParams<{ itemId: string }>();
|
||||
const household = useHouseholdStore((s) => s.household);
|
||||
const items = useHouseholdStore((s) => s.items);
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newPhotoUri, setNewPhotoUri] = useState<string | null>(null);
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
|
||||
const [name, setName] = useState(item?.name ?? '');
|
||||
const [description, setDescription] = useState(item?.description ?? '');
|
||||
const [storageLocation, setStorageLocation] = useState(item?.storageLocation ?? '');
|
||||
const [shoppingLocation, setShoppingLocation] = useState(item?.shoppingLocation ?? '');
|
||||
const [price, setPrice] = useState(item?.price != null ? String(item.price) : '');
|
||||
const [minStock, setMinStock] = useState(String(item?.minStockThreshold ?? 0));
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(item?.expiryDate ?? null);
|
||||
|
||||
const { options: locationOptions, customOptions: customLocations, addOption: addLocation, removeOption: removeLocation } = useCustomOptions(
|
||||
'houseorg_custom_locations',
|
||||
STORAGE_LOCATIONS
|
||||
);
|
||||
|
||||
if (!item || !household) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text>Artikel nicht gefunden.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const pickPhoto = () => {
|
||||
Alert.alert('Foto', '', [
|
||||
{
|
||||
text: 'Kamera',
|
||||
onPress: async () => {
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
|
||||
if (!result.canceled) setNewPhotoUri(result.assets[0].uri);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Galerie',
|
||||
onPress: async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
|
||||
if (!result.canceled) setNewPhotoUri(result.assets[0].uri);
|
||||
},
|
||||
},
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateItem(household.id, item.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
storageLocation: storageLocation.trim(),
|
||||
shoppingLocation: shoppingLocation.trim(),
|
||||
price: price ? parseFloat(price) : null,
|
||||
minStockThreshold: parseFloat(minStock) || 0,
|
||||
expiryDate,
|
||||
...(newPhotoUri ? { photoUri: newPhotoUri } : {}),
|
||||
});
|
||||
setNewPhotoUri(null);
|
||||
setEditing(false);
|
||||
Toast.show({ type: 'success', text1: 'Gespeichert' });
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert('Artikel löschen', `Möchtest du "${item.name}" wirklich löschen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteItem(household.id, item.id);
|
||||
router.back();
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Löschen' });
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleQuantityChange = async (qty: number) => {
|
||||
try {
|
||||
await updateItemQuantity(household.id, item.id, qty);
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
|
||||
}
|
||||
};
|
||||
|
||||
const expired = isExpired(item.expiryDate);
|
||||
const expiringSoon = isExpiringSoon(item.expiryDate);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{editing ? (
|
||||
<TouchableOpacity style={styles.photoEditContainer} onPress={pickPhoto} activeOpacity={0.85}>
|
||||
{(newPhotoUri ?? item.photoUrl) ? (
|
||||
<>
|
||||
<Image source={{ uri: newPhotoUri ?? item.photoUrl! }} style={styles.photo} />
|
||||
<View style={styles.photoEditOverlay}>
|
||||
<MaterialIcons name="edit" size={18} color={COLORS.white} />
|
||||
<Text style={styles.photoEditText}>Foto ändern</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.photoPlaceholder}>
|
||||
<MaterialIcons name="add-a-photo" size={32} color={COLORS.primary} />
|
||||
<Text style={styles.photoPlaceholderText}>Foto hinzufügen</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
item.photoUrl && <Image source={{ uri: item.photoUrl }} style={styles.photo} />
|
||||
)}
|
||||
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerInfo}>
|
||||
{editing ? (
|
||||
<TextInput style={styles.titleInput} value={name} onChangeText={setName} />
|
||||
) : (
|
||||
<Text style={styles.title}>{item.name}</Text>
|
||||
)}
|
||||
<Text style={styles.category}>{CATEGORY_LABELS[item.category]}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => (editing ? handleSave() : setEditing(true))} disabled={saving}>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color={COLORS.primary} />
|
||||
) : (
|
||||
<MaterialIcons
|
||||
name={editing ? 'check' : 'edit'}
|
||||
size={24}
|
||||
color={editing ? COLORS.primary : COLORS.textSecondary}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.quantitySection}>
|
||||
<Text style={styles.sectionLabel}>Bestand</Text>
|
||||
<QuantityControl
|
||||
quantity={item.quantity}
|
||||
unit={item.unit}
|
||||
minStockThreshold={item.minStockThreshold}
|
||||
onChangeQuantity={handleQuantityChange}
|
||||
/>
|
||||
{item.onShoppingList && (
|
||||
<Text style={styles.onListHint}>Auf der Einkaufsliste</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{(expiringSoon || expired) && (
|
||||
<View style={[styles.mhdAlert, expired ? styles.mhdAlertExpired : styles.mhdAlertWarn]}>
|
||||
<MaterialIcons
|
||||
name={expired ? 'error' : 'warning'}
|
||||
size={18}
|
||||
color={expired ? COLORS.danger : COLORS.warning}
|
||||
/>
|
||||
<Text style={styles.mhdAlertText}>
|
||||
{expired ? 'Abgelaufen!' : `MHD läuft in ≤ 3 Tagen ab`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.detailsCard}>
|
||||
<DetailRow label="Beschreibung" value={editing ? undefined : item.description}>
|
||||
{editing && (
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
/>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Lagerort" value={editing ? undefined : item.storageLocation || '—'}>
|
||||
{editing && (
|
||||
<ChipSelectInput
|
||||
value={storageLocation}
|
||||
onSelect={setStorageLocation}
|
||||
options={locationOptions}
|
||||
onAddOption={addLocation}
|
||||
onRemoveOption={removeLocation}
|
||||
removableOptions={customLocations}
|
||||
addPlaceholder="Neuer Lagerort…"
|
||||
/>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Einkaufsort" value={editing ? undefined : item.shoppingLocation || '—'}>
|
||||
{editing && (
|
||||
<TextInput style={styles.editInput} value={shoppingLocation} onChangeText={setShoppingLocation} />
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Mindestbestand" value={editing ? undefined : `${item.minStockThreshold} ${item.unit}`}>
|
||||
{editing && (
|
||||
<TextInput style={styles.editInput} value={minStock} onChangeText={setMinStock} keyboardType="numeric" />
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Preis" value={editing ? undefined : item.price != null ? `${item.price.toFixed(2)} €` : '—'}>
|
||||
{editing && (
|
||||
<TextInput style={styles.editInput} value={price} onChangeText={setPrice} keyboardType="numeric" />
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="MHD" value={editing ? undefined : formatDate(item.expiryDate)}>
|
||||
{editing && (
|
||||
<TouchableOpacity onPress={() => setShowDatePicker(true)} style={styles.datePicker}>
|
||||
<Text style={styles.datePickerText}>{formatDate(expiryDate) || 'Datum wählen'}</Text>
|
||||
<MaterialIcons name="calendar-today" size={18} color={COLORS.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</DetailRow>
|
||||
{showDatePicker && (
|
||||
<DatePickerModal
|
||||
value={expiryDate ?? new Date()}
|
||||
minimumDate={new Date()}
|
||||
onChange={setExpiryDate}
|
||||
onDismiss={() => setShowDatePicker(false)}
|
||||
/>
|
||||
)}
|
||||
<DetailRow label="Barcode" value={item.barcode || '—'} last />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.deleteBtn} onPress={handleDelete}>
|
||||
<MaterialIcons name="delete" size={18} color={COLORS.danger} />
|
||||
<Text style={styles.deleteBtnText}>Artikel löschen</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
<Toast />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
last,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
children?: React.ReactNode;
|
||||
last?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.detailRow, !last && styles.detailRowBorder]}>
|
||||
<Text style={styles.detailLabel}>{label}</Text>
|
||||
{children ?? <Text style={styles.detailValue}>{value}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: COLORS.white },
|
||||
content: { paddingBottom: 48 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
photo: { width: '100%', height: 220, resizeMode: 'cover' },
|
||||
photoEditContainer: { width: '100%', height: 220 },
|
||||
photoEditOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
photoEditText: { color: COLORS.white, fontSize: 14, fontWeight: '600' },
|
||||
photoPlaceholder: {
|
||||
width: '100%',
|
||||
height: 220,
|
||||
backgroundColor: COLORS.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
photoPlaceholderText: { color: COLORS.primary, fontSize: 14, fontWeight: '600' },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'flex-start', padding: 20, gap: 12 },
|
||||
headerInfo: { flex: 1 },
|
||||
title: { fontSize: 24, fontWeight: '700', color: COLORS.text },
|
||||
titleInput: { fontSize: 24, fontWeight: '700', color: COLORS.text, borderBottomWidth: 2, borderBottomColor: COLORS.primary },
|
||||
category: { fontSize: 14, color: COLORS.textSecondary, marginTop: 4 },
|
||||
quantitySection: { paddingHorizontal: 20, paddingBottom: 16, gap: 8 },
|
||||
sectionLabel: { fontSize: 12, fontWeight: '600', color: COLORS.textSecondary, textTransform: 'uppercase', letterSpacing: 0.4 },
|
||||
onListHint: { fontSize: 12, color: COLORS.warning, fontWeight: '500' },
|
||||
mhdAlert: { flexDirection: 'row', alignItems: 'center', gap: 8, marginHorizontal: 20, marginBottom: 12, padding: 12, borderRadius: 10 },
|
||||
mhdAlertWarn: { backgroundColor: '#FFF3CD' },
|
||||
mhdAlertExpired: { backgroundColor: '#F8D7DA' },
|
||||
mhdAlertText: { fontSize: 14, fontWeight: '500', color: COLORS.text },
|
||||
detailsCard: { marginHorizontal: 16, borderRadius: 14, overflow: 'hidden', borderWidth: 1, borderColor: COLORS.border },
|
||||
detailRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 14, gap: 12 },
|
||||
detailRowBorder: { borderBottomWidth: 1, borderBottomColor: COLORS.border },
|
||||
detailLabel: { fontSize: 14, color: COLORS.textSecondary, fontWeight: '500', minWidth: 100 },
|
||||
detailValue: { fontSize: 14, color: COLORS.text, flex: 1, textAlign: 'right' },
|
||||
editInput: { fontSize: 14, color: COLORS.text, flex: 1, textAlign: 'right', borderBottomWidth: 1, borderBottomColor: COLORS.primary },
|
||||
datePicker: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
datePickerText: { fontSize: 14, color: COLORS.primary },
|
||||
deleteBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 32, marginHorizontal: 16, padding: 14, borderRadius: 12, borderWidth: 1.5, borderColor: COLORS.danger },
|
||||
deleteBtnText: { color: COLORS.danger, fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
Reference in New Issue
Block a user