import { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, FlatList, Image, InteractionManager, KeyboardAvoidingView, Modal, Pressable, StyleSheet, TextInput, View, Platform, } from 'react-native'; import DateTimePicker from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; import { ResizeMode, Video } from 'expo-av'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { ThemedButton } from '@/components/themed-button'; import { IconButton } from '@/components/icon-button'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors, Fonts } from '@/constants/theme'; import { useTranslation } from '@/localization/i18n'; import { dbPromise, initCoreTables } from '@/services/db'; import { ZoomImageModal } from '@/components/zoom-image-modal'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { useColorScheme } from '@/hooks/use-color-scheme'; type FieldRow = { id: number; name: string | null; }; type CropRow = { id: number; field_id: number | null; crop_name: string | null; }; type CostRow = { id: number; field_id: number | null; crop_id: number | null; category: string | null; amount: number | null; currency: string | null; vendor: string | null; notes: string | null; spent_at: string | null; photo_uri: string | null; field_name: string | null; crop_name: string | null; }; export default function CostsScreen() { const { t } = useTranslation(); const router = useRouter(); const navigation = useNavigation(); const params = useLocalSearchParams<{ from?: string | string[] }>(); const theme = useColorScheme() ?? 'light'; const palette = Colors[theme]; const fromParam = Array.isArray(params.from) ? params.from[0] : params.from; const categoryPresets = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc']; const [currency, setCurrency] = useState('THB'); const [costs, setCosts] = useState([]); const [fields, setFields] = useState([]); const [crops, setCrops] = useState([]); const [status, setStatus] = useState(t('costs.loading')); const [newModalOpen, setNewModalOpen] = useState(false); const [fieldModalOpen, setFieldModalOpen] = useState(false); const [cropModalOpen, setCropModalOpen] = useState(false); const [reopenSheetAfterSelect, setReopenSheetAfterSelect] = useState(false); const [fieldModalTarget, setFieldModalTarget] = useState<'new' | 'edit'>('new'); const [cropModalTarget, setCropModalTarget] = useState<'new' | 'edit'>('new'); const [editModalOpen, setEditModalOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [selectedFieldId, setSelectedFieldId] = useState(null); const [selectedCropId, setSelectedCropId] = useState(null); useEffect(() => { navigation.setOptions({ headerLeft: () => ( { if (fromParam === 'logbook') { router.replace('/logbook'); return; } if (fromParam === 'home') { router.replace('/'); return; } router.back(); }} hitSlop={10} style={{ paddingHorizontal: 8 }}> ), }); }, [fromParam, navigation, palette.text, router]); const [category, setCategory] = useState(''); const [amount, setAmount] = useState(''); const [vendor, setVendor] = useState(''); const [notes, setNotes] = useState(''); const [spentDate, setSpentDate] = useState(''); const [showSpentPicker, setShowSpentPicker] = useState(false); const [photoUri, setPhotoUri] = useState(null); const [errors, setErrors] = useState<{ field?: string; amount?: string }>({}); const [editFieldId, setEditFieldId] = useState(null); const [editCropId, setEditCropId] = useState(null); const [editCategory, setEditCategory] = useState(''); const [editAmount, setEditAmount] = useState(''); const [editVendor, setEditVendor] = useState(''); const [editNotes, setEditNotes] = useState(''); const [editSpentDate, setEditSpentDate] = useState(''); const [showEditSpentPicker, setShowEditSpentPicker] = useState(false); const [editPhotoUri, setEditPhotoUri] = useState(null); const [zoomUri, setZoomUri] = useState(null); const [pendingZoomUri, setPendingZoomUri] = useState(null); const [editErrors, setEditErrors] = useState<{ field?: string; amount?: string }>({}); const selectedField = useMemo( () => fields.find((item) => item.id === selectedFieldId), [fields, selectedFieldId] ); const selectedCrop = useMemo( () => crops.find((item) => item.id === selectedCropId), [crops, selectedCropId] ); const selectedEditField = useMemo( () => fields.find((item) => item.id === editFieldId), [fields, editFieldId] ); const selectedEditCrop = useMemo( () => crops.find((item) => item.id === editCropId), [crops, editCropId] ); useEffect(() => { let isActive = true; async function loadData() { try { await initCoreTables(); const db = await dbPromise; const profileRow = await db.getFirstAsync<{ currency: string | null }>( 'SELECT currency FROM user_profile WHERE id = 1;' ); const fieldRows = await db.getAllAsync( 'SELECT id, name FROM fields ORDER BY name ASC;' ); const cropRows = await db.getAllAsync( 'SELECT id, field_id, crop_name FROM crops ORDER BY id DESC;' ); const costRows = await db.getAllAsync( `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes, c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name FROM costs c LEFT JOIN fields f ON f.id = c.field_id LEFT JOIN crops cr ON cr.id = c.crop_id ORDER BY c.spent_at DESC;` ); if (!isActive) return; setCurrency(profileRow?.currency ?? 'THB'); setFields(fieldRows); setCrops(cropRows); setCosts(costRows); setStatus(costRows.length === 0 ? t('costs.empty') : ''); } catch (error) { if (isActive) setStatus(`Error: ${String(error)}`); } } loadData(); return () => { isActive = false; }; }, [t]); const fetchCostsPage = useCallback(async () => { try { const db = await dbPromise; const profileRow = await db.getFirstAsync<{ currency: string | null }>( 'SELECT currency FROM user_profile WHERE id = 1;' ); const costRows = await db.getAllAsync( `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes, c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name FROM costs c LEFT JOIN fields f ON f.id = c.field_id LEFT JOIN crops cr ON cr.id = c.crop_id ORDER BY c.spent_at DESC;` ); setCurrency(profileRow?.currency ?? 'THB'); setCosts(costRows); setStatus(costRows.length === 0 ? t('costs.empty') : ''); } catch (error) { setStatus(`Error: ${String(error)}`); } }, [t]); useFocusEffect( useCallback(() => { fetchCostsPage(); }, [fetchCostsPage]) ); useEffect(() => { if (!newModalOpen && !editModalOpen && pendingZoomUri) { const uri = pendingZoomUri; setPendingZoomUri(null); InteractionManager.runAfterInteractions(() => { setTimeout(() => setZoomUri(uri), 150); }); } }, [newModalOpen, editModalOpen, pendingZoomUri]); async function handleSave() { const parsedAmount = amount.trim() ? Number(amount) : null; const nextErrors: { field?: string; amount?: string } = {}; if (!selectedFieldId) nextErrors.field = t('costs.fieldRequired'); if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid'); setErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { setStatus(nextErrors.field ?? nextErrors.amount ?? t('costs.fieldRequired')); return false; } try { const db = await dbPromise; const now = new Date().toISOString(); await db.runAsync( 'INSERT INTO costs (field_id, crop_id, category, amount, currency, vendor, notes, spent_at, photo_uri, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);', selectedFieldId, selectedCropId, category.trim() || null, parsedAmount, currency, vendor.trim() || null, notes.trim() || null, spentDate.trim() || now, photoUri, now ); resetNewForm(); setStatus(t('costs.saved')); const costRows = await db.getAllAsync( `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes, c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name FROM costs c LEFT JOIN fields f ON f.id = c.field_id LEFT JOIN crops cr ON cr.id = c.crop_id ORDER BY c.spent_at DESC;` ); setCosts(costRows); return true; } catch (error) { setStatus(`Error: ${String(error)}`); return false; } } async function handleUpdate() { if (!editingId) return; const parsedAmount = editAmount.trim() ? Number(editAmount) : null; const nextErrors: { field?: string; amount?: string } = {}; if (!editFieldId) nextErrors.field = t('costs.fieldRequired'); if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid'); setEditErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { setStatus(nextErrors.field ?? nextErrors.amount ?? t('costs.fieldRequired')); return; } try { const db = await dbPromise; const now = new Date().toISOString(); await db.runAsync( 'UPDATE costs SET field_id = ?, crop_id = ?, category = ?, amount = ?, currency = ?, vendor = ?, notes = ?, spent_at = ?, photo_uri = ? WHERE id = ?;', editFieldId, editCropId, editCategory.trim() || null, parsedAmount, currency, editVendor.trim() || null, editNotes.trim() || null, editSpentDate.trim() || now, editPhotoUri, editingId ); setEditModalOpen(false); setEditingId(null); const costRows = await db.getAllAsync( `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes, c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name FROM costs c LEFT JOIN fields f ON f.id = c.field_id LEFT JOIN crops cr ON cr.id = c.crop_id ORDER BY c.spent_at DESC;` ); setCosts(costRows); } catch (error) { setStatus(`Error: ${String(error)}`); } } function startEdit(item: CostRow) { router.push(`/costs/${item.id}`); } function cancelEdit() { setEditingId(null); setEditFieldId(null); setEditCropId(null); setEditCategory(''); setEditAmount(''); setEditVendor(''); setEditNotes(''); setEditSpentDate(''); setEditPhotoUri(null); setEditErrors({}); setEditModalOpen(false); } function resetNewForm() { setSelectedFieldId(null); setSelectedCropId(null); setCategory(''); setAmount(''); setVendor(''); setNotes(''); setSpentDate(''); setPhotoUri(null); setErrors({}); } function confirmDelete(id: number) { Alert.alert(t('costs.deleteTitle'), t('costs.deleteMessage'), [ { text: t('costs.cancel'), style: 'cancel' }, { text: t('costs.delete'), style: 'destructive', onPress: () => handleDelete(id) }, ]); } async function handleDelete(id: number) { try { const db = await dbPromise; await db.runAsync('DELETE FROM costs WHERE id = ?;', id); const costRows = await db.getAllAsync( `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes, c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name FROM costs c LEFT JOIN fields f ON f.id = c.field_id LEFT JOIN crops cr ON cr.id = c.crop_id ORDER BY c.spent_at DESC;` ); setCosts(costRows); setStatus(costRows.length === 0 ? t('costs.empty') : ''); } catch (error) { setStatus(`Error: ${String(error)}`); } } const inputStyle = [ styles.input, { borderColor: palette.border, backgroundColor: palette.input, color: palette.text }, ]; return ( <> String(item.id)} renderItem={({ item }) => ( startEdit(item)}> {item.category || t('costs.untitled')} confirmDelete(item.id)} accessibilityLabel={t('costs.delete')} variant="danger" /> {item.field_name || t('costs.noField')} {item.crop_name ? {item.crop_name} : null} {item.spent_at ? ( {formatDate(item.spent_at)} ) : null} {item.amount !== null ? ( {item.amount} {item.currency || currency} ) : null} {normalizeMediaUri(item.photo_uri) ? ( isVideoUri(normalizeMediaUri(item.photo_uri) as string) ? ( )} ItemSeparatorComponent={() => } ListHeaderComponent={ {t('costs.title')} {status ? ( {status} ) : null} router.push('/costs/new')}> {t('costs.new')} } ListFooterComponent={} /> { setFieldModalOpen(false); setReopenSheetAfterSelect(false); }}> {t('costs.selectField')} String(item.id)} renderItem={({ item }) => ( { if (fieldModalTarget === 'edit') { setEditFieldId(item.id); setEditErrors((prev) => ({ ...prev, field: undefined })); if (reopenSheetAfterSelect) setEditModalOpen(true); } else { setSelectedFieldId(item.id); setErrors((prev) => ({ ...prev, field: undefined })); if (reopenSheetAfterSelect) setNewModalOpen(true); } setFieldModalOpen(false); setReopenSheetAfterSelect(false); }} style={styles.modalItem}> {item.name || t('costs.untitled')} )} /> { setCropModalOpen(false); setReopenSheetAfterSelect(false); }}> {t('costs.selectCrop')} { const targetField = cropModalTarget === 'edit' ? editFieldId : selectedFieldId; return !targetField || item.field_id === targetField; })} keyExtractor={(item) => String(item.id)} renderItem={({ item }) => ( { if (cropModalTarget === 'edit') { setEditCropId(item.id); if (reopenSheetAfterSelect) setEditModalOpen(true); } else { setSelectedCropId(item.id); if (reopenSheetAfterSelect) setNewModalOpen(true); } setCropModalOpen(false); setReopenSheetAfterSelect(false); }} style={styles.modalItem}> {item.crop_name || t('costs.untitled')} )} ListEmptyComponent={{t('costs.noCrop')}} /> setNewModalOpen(false)} /> item.key} contentContainerStyle={styles.sheetListContent} renderItem={() => ( {t('costs.new')} {t('costs.field')} * { setReopenSheetAfterSelect(true); setFieldModalTarget('new'); setNewModalOpen(false); setFieldModalOpen(true); }} variant="secondary" /> {errors.field ? ( {errors.field} ) : null} {t('costs.crop')} { setReopenSheetAfterSelect(true); setCropModalTarget('new'); setNewModalOpen(false); setCropModalOpen(true); }} variant="secondary" /> {t('costs.category')} {categoryPresets.map((preset) => { const label = t(`costs.category.${preset}`); const normalized = category.trim().toLowerCase(); const isActive = label.toLowerCase() === normalized || preset === normalized; return ( setCategory(label)} style={[styles.chip, isActive && styles.chipActive]}> {label} ); })} {t('costs.amount')} ({currency}) * { setAmount(value); if (errors.amount) { setErrors((prev) => ({ ...prev, amount: undefined })); } }} placeholder={t('costs.amountPlaceholder')} style={inputStyle} keyboardType="decimal-pad" /> {errors.amount ? ( {errors.amount} ) : null} {t('costs.vendor')} {t('costs.date')} setShowSpentPicker(true)} style={styles.dateInput}> {spentDate ? formatDateLabel(spentDate) : t('costs.datePlaceholder')} {showSpentPicker ? ( <> {Platform.OS === 'ios' ? ( { setSpentDate(toDateOnly(new Date())); setShowSpentPicker(false); }} variant="secondary" /> setShowSpentPicker(false)} variant="secondary" /> ) : null} { if (date) setSpentDate(toDateOnly(date)); if (Platform.OS !== 'ios') setShowSpentPicker(false); }} /> ) : null} {t('costs.notes')} {t('costs.addMedia')} {normalizeMediaUri(photoUri) ? ( isVideoUri(normalizeMediaUri(photoUri) as string) ? ( item.key} contentContainerStyle={styles.sheetListContent} renderItem={() => ( {t('costs.edit')} {t('costs.field')} * { setReopenSheetAfterSelect(true); setFieldModalTarget('edit'); setEditModalOpen(false); setFieldModalOpen(true); }} variant="secondary" /> {editErrors.field ? ( {editErrors.field} ) : null} {t('costs.crop')} { setReopenSheetAfterSelect(true); setCropModalTarget('edit'); setEditModalOpen(false); setCropModalOpen(true); }} variant="secondary" /> {t('costs.category')} {categoryPresets.map((preset) => { const label = t(`costs.category.${preset}`); const normalized = editCategory.trim().toLowerCase(); const isActive = label.toLowerCase() === normalized || preset === normalized; return ( setEditCategory(label)} style={[styles.chip, isActive && styles.chipActive]}> {label} ); })} {t('costs.amount')} ({currency}) * { setEditAmount(value); if (editErrors.amount) { setEditErrors((prev) => ({ ...prev, amount: undefined })); } }} placeholder={t('costs.amountPlaceholder')} style={inputStyle} keyboardType="decimal-pad" /> {editErrors.amount ? ( {editErrors.amount} ) : null} {t('costs.vendor')} {t('costs.date')} setShowEditSpentPicker(true)} style={styles.dateInput}> {editSpentDate ? formatDateLabel(editSpentDate) : t('costs.datePlaceholder')} {showEditSpentPicker ? ( <> {Platform.OS === 'ios' ? ( { setEditSpentDate(toDateOnly(new Date())); setShowEditSpentPicker(false); }} variant="secondary" /> setShowEditSpentPicker(false)} variant="secondary" /> ) : null} { if (date) setEditSpentDate(toDateOnly(date)); if (Platform.OS !== 'ios') setShowEditSpentPicker(false); }} /> ) : null} {t('costs.notes')} {t('costs.addMedia')} {normalizeMediaUri(editPhotoUri) ? ( isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? ( setZoomUri(null)} /> ); } function formatDate(value: string) { try { return new Date(value).toLocaleString(); } catch { return value; } } function formatDateLabel(value: string) { try { return new Date(value).toISOString().slice(0, 10); } catch { return value; } } function toDateOnly(date: Date) { return date.toISOString().slice(0, 10); } async function handlePickPhoto(setter: (value: string | null) => void) { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: getMediaTypes(), quality: 1, }); if (result.canceled) return; const asset = result.assets[0]; setter(asset.uri); } async function handleTakePhoto( setter: (value: string | null) => void, onError?: (msg: string) => void ) { try { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (!permission.granted) { onError?.('cameraDenied'); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: getMediaTypes(), quality: 1, }); if (result.canceled) return; const asset = result.assets[0]; setter(asset.uri); } catch { onError?.('cameraError'); } } function getMediaTypes() { const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown }; }).MediaType; const imageType = mediaType?.Image ?? mediaType?.Images; const videoType = mediaType?.Video ?? mediaType?.Videos; if (imageType && videoType) { return [imageType, videoType]; } return imageType ?? videoType ?? ['images', 'videos']; } function isVideoUri(uri: string) { return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri); } function normalizeMediaUri(uri?: string | null) { if (typeof uri !== 'string') return null; const trimmed = uri.trim(); return trimmed ? trimmed : null; } const styles = StyleSheet.create({ hero: { backgroundColor: '#E8E6DA', aspectRatio: 16 / 9, width: '100%', }, heroImage: { width: '100%', height: '100%', }, titleContainer: { gap: 8, paddingHorizontal: 16, paddingVertical: 12, }, section: { gap: 8, marginBottom: 16, paddingHorizontal: 16, }, newButton: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, borderWidth: 1, borderColor: '#B9B9B9', paddingHorizontal: 12, paddingVertical: 10, alignSelf: 'flex-start', }, newButtonText: { fontSize: 15, fontWeight: '600', }, card: { borderRadius: 12, borderWidth: 1, borderColor: '#C6C6C6', padding: 12, marginHorizontal: 16, gap: 6, backgroundColor: '#FFFFFF', }, cardHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, }, meta: { opacity: 0.7, }, separator: { height: 12, }, footer: { height: 24, }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.3)', justifyContent: 'center', padding: 16, }, modalCard: { borderRadius: 12, borderWidth: 1, borderColor: '#C6C6C6', padding: 16, backgroundColor: '#FFFFFF', gap: 8, maxHeight: '70%', }, modalItem: { paddingVertical: 8, }, input: { borderRadius: 10, borderWidth: 1, borderColor: '#B9B9B9', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, }, modalActions: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, }, sheetOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.3)', justifyContent: 'flex-end', }, sheetBackdrop: { ...StyleSheet.absoluteFillObject, }, sheet: { borderTopLeftRadius: 16, borderTopRightRadius: 16, borderWidth: 1, borderColor: '#C6C6C6', padding: 16, backgroundColor: '#FFFFFF', gap: 10, maxHeight: '85%', }, sheetContent: { gap: 10, }, sheetListContent: { paddingBottom: 80, }, sheetFooter: { height: 24, }, keyboardAvoid: { width: '100%', flex: 1, justifyContent: 'flex-end', }, photoRow: { flexDirection: 'row', gap: 12, }, photoPreview: { width: '100%', height: 200, borderRadius: 12, }, listPhoto: { width: '100%', height: 160, borderRadius: 10, }, videoPreview: { width: '100%', height: 200, borderRadius: 12, backgroundColor: '#1C1C1C', }, photoPlaceholder: { opacity: 0.6, }, dateInput: { borderRadius: 10, borderWidth: 1, borderColor: '#B9B9B9', paddingHorizontal: 12, paddingVertical: 10, }, dateValue: { fontSize: 15, opacity: 0.9, }, pickerRow: { flexDirection: 'row', gap: 8, }, chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, }, chip: { borderRadius: 999, borderWidth: 1, borderColor: '#C6C6C6', paddingHorizontal: 10, paddingVertical: 4, }, chipActive: { borderColor: '#2F7D4F', backgroundColor: '#E7F3EA', }, chipText: { fontSize: 12, }, chipTextActive: { fontSize: 12, color: '#2F7D4F', fontWeight: '600', }, requiredMark: { color: '#C0392B', fontWeight: '700', }, errorText: { color: '#C0392B', fontSize: 12, }, });