| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061 |
- import { useEffect, useMemo, useState } from 'react';
- import {
- Alert,
- FlatList,
- Image,
- 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 { 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 { 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 theme = useColorScheme() ?? 'light';
- const palette = Colors[theme];
- const categoryPresets = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc'];
- const [currency, setCurrency] = useState('THB');
- const [costs, setCosts] = useState<CostRow[]>([]);
- const [fields, setFields] = useState<FieldRow[]>([]);
- const [crops, setCrops] = useState<CropRow[]>([]);
- 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<number | null>(null);
- const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
- const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
- 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<string | null>(null);
- const [errors, setErrors] = useState<{ field?: string; amount?: string }>({});
- const [editFieldId, setEditFieldId] = useState<number | null>(null);
- const [editCropId, setEditCropId] = useState<number | null>(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<string | null>(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<FieldRow>(
- 'SELECT id, name FROM fields ORDER BY name ASC;'
- );
- const cropRows = await db.getAllAsync<CropRow>(
- 'SELECT id, field_id, crop_name FROM crops ORDER BY id DESC;'
- );
- const costRows = await db.getAllAsync<CostRow>(
- `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]);
- 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<CostRow>(
- `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<CostRow>(
- `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) {
- setEditingId(item.id);
- setEditFieldId(item.field_id ?? null);
- setEditCropId(item.crop_id ?? null);
- setEditCategory(item.category ?? '');
- setEditAmount(item.amount !== null ? String(item.amount) : '');
- setEditVendor(item.vendor ?? '');
- setEditNotes(item.notes ?? '');
- setEditSpentDate(item.spent_at ?? '');
- setEditPhotoUri(item.photo_uri ?? null);
- setEditErrors({});
- setEditModalOpen(true);
- }
- 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<CostRow>(
- `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 (
- <>
- <FlatList
- data={costs}
- keyExtractor={(item) => String(item.id)}
- renderItem={({ item }) => (
- <Pressable onPress={() => startEdit(item)}>
- <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <View style={styles.cardHeader}>
- <ThemedText type="subtitle">
- {item.category || t('costs.untitled')}
- </ThemedText>
- <IconButton
- name="trash"
- onPress={() => confirmDelete(item.id)}
- accessibilityLabel={t('costs.delete')}
- variant="danger"
- />
- </View>
- <ThemedText style={styles.meta}>
- {item.field_name || t('costs.noField')}
- </ThemedText>
- {item.crop_name ? <ThemedText style={styles.meta}>{item.crop_name}</ThemedText> : null}
- {item.spent_at ? (
- <ThemedText style={styles.meta}>{formatDate(item.spent_at)}</ThemedText>
- ) : null}
- {item.amount !== null ? (
- <ThemedText>
- {item.amount} {item.currency || currency}
- </ThemedText>
- ) : null}
- {item.photo_uri ? (
- <Image source={{ uri: item.photo_uri }} style={styles.listPhoto} />
- ) : null}
- {item.vendor ? <ThemedText>{item.vendor}</ThemedText> : null}
- {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
- </ThemedView>
- </Pressable>
- )}
- ItemSeparatorComponent={() => <View style={styles.separator} />}
- ListHeaderComponent={
- <View>
- <ThemedView style={styles.hero}>
- <Image source={require('@/assets/images/costrecords.jpg')} style={styles.heroImage} />
- </ThemedView>
- <ThemedView style={styles.titleContainer}>
- <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
- {t('costs.title')}
- </ThemedText>
- </ThemedView>
- {status ? (
- <ThemedView style={styles.section}>
- <ThemedText>{status}</ThemedText>
- </ThemedView>
- ) : null}
- <ThemedView style={styles.section}>
- <Pressable style={styles.newButton} onPress={() => setNewModalOpen(true)}>
- <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
- <ThemedText style={styles.newButtonText}>{t('costs.new')}</ThemedText>
- </Pressable>
- </ThemedView>
- </View>
- }
- ListFooterComponent={<View style={styles.footer} />}
- />
- <Modal transparent visible={fieldModalOpen} animationType="fade">
- <Pressable
- style={styles.modalOverlay}
- onPress={() => {
- setFieldModalOpen(false);
- setReopenSheetAfterSelect(false);
- }}>
- <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
- <FlatList
- data={fields}
- keyExtractor={(item) => String(item.id)}
- renderItem={({ item }) => (
- <Pressable
- onPress={() => {
- 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}>
- <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
- </Pressable>
- )}
- />
- </View>
- </Pressable>
- </Modal>
- <Modal transparent visible={cropModalOpen} animationType="fade">
- <Pressable
- style={styles.modalOverlay}
- onPress={() => {
- setCropModalOpen(false);
- setReopenSheetAfterSelect(false);
- }}>
- <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
- <FlatList
- data={crops.filter((item) => {
- const targetField = cropModalTarget === 'edit' ? editFieldId : selectedFieldId;
- return !targetField || item.field_id === targetField;
- })}
- keyExtractor={(item) => String(item.id)}
- renderItem={({ item }) => (
- <Pressable
- onPress={() => {
- 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}>
- <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
- </Pressable>
- )}
- ListEmptyComponent={<ThemedText style={styles.meta}>{t('costs.noCrop')}</ThemedText>}
- />
- </View>
- </Pressable>
- </Modal>
- <Modal transparent visible={newModalOpen} animationType="slide">
- <View style={styles.sheetOverlay}>
- <Pressable style={styles.sheetBackdrop} onPress={() => setNewModalOpen(false)} />
- <KeyboardAvoidingView
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
- style={styles.keyboardAvoid}>
- <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <FlatList
- data={[{ key: 'new' }]}
- keyExtractor={(item) => item.key}
- contentContainerStyle={styles.sheetListContent}
- renderItem={() => (
- <View style={styles.sheetContent}>
- <ThemedText type="subtitle">{t('costs.new')}</ThemedText>
- <ThemedText>
- {t('costs.field')}
- <ThemedText style={styles.requiredMark}> *</ThemedText>
- </ThemedText>
- <ThemedButton
- title={selectedField?.name || t('costs.selectField')}
- onPress={() => {
- setReopenSheetAfterSelect(true);
- setFieldModalTarget('new');
- setNewModalOpen(false);
- setFieldModalOpen(true);
- }}
- variant="secondary"
- />
- {errors.field ? (
- <ThemedText style={styles.errorText}>{errors.field}</ThemedText>
- ) : null}
- <ThemedText>{t('costs.crop')}</ThemedText>
- <ThemedButton
- title={selectedCrop?.crop_name || t('costs.selectCrop')}
- onPress={() => {
- setReopenSheetAfterSelect(true);
- setCropModalTarget('new');
- setNewModalOpen(false);
- setCropModalOpen(true);
- }}
- variant="secondary"
- />
- <ThemedText>{t('costs.category')}</ThemedText>
- <View style={styles.chipRow}>
- {categoryPresets.map((preset) => {
- const label = t(`costs.category.${preset}`);
- const normalized = category.trim().toLowerCase();
- const isActive = label.toLowerCase() === normalized || preset === normalized;
- return (
- <Pressable
- key={preset}
- onPress={() => setCategory(label)}
- style={[styles.chip, isActive && styles.chipActive]}>
- <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
- {label}
- </ThemedText>
- </Pressable>
- );
- })}
- </View>
- <TextInput
- value={category}
- onChangeText={setCategory}
- placeholder={t('costs.categoryPlaceholder')}
- style={inputStyle}
- />
- <ThemedText>
- {t('costs.amount')} ({currency})
- <ThemedText style={styles.requiredMark}> *</ThemedText>
- </ThemedText>
- <TextInput
- value={amount}
- onChangeText={(value) => {
- setAmount(value);
- if (errors.amount) {
- setErrors((prev) => ({ ...prev, amount: undefined }));
- }
- }}
- placeholder={t('costs.amountPlaceholder')}
- style={inputStyle}
- keyboardType="decimal-pad"
- />
- {errors.amount ? (
- <ThemedText style={styles.errorText}>{errors.amount}</ThemedText>
- ) : null}
- <ThemedText>{t('costs.vendor')}</ThemedText>
- <TextInput
- value={vendor}
- onChangeText={setVendor}
- placeholder={t('costs.vendorPlaceholder')}
- style={inputStyle}
- />
- <ThemedText>{t('costs.date')}</ThemedText>
- <Pressable onPress={() => setShowSpentPicker(true)} style={styles.dateInput}>
- <ThemedText style={styles.dateValue}>
- {spentDate ? formatDateLabel(spentDate) : t('costs.datePlaceholder')}
- </ThemedText>
- </Pressable>
- {showSpentPicker ? (
- <>
- {Platform.OS === 'ios' ? (
- <View style={styles.pickerRow}>
- <ThemedButton
- title={t('crops.today')}
- onPress={() => {
- setSpentDate(toDateOnly(new Date()));
- setShowSpentPicker(false);
- }}
- variant="secondary"
- />
- <ThemedButton
- title={t('crops.done')}
- onPress={() => setShowSpentPicker(false)}
- variant="secondary"
- />
- </View>
- ) : null}
- <DateTimePicker
- value={spentDate ? new Date(spentDate) : new Date()}
- mode="date"
- display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
- onChange={(event, date) => {
- if (date) setSpentDate(toDateOnly(date));
- if (Platform.OS !== 'ios') setShowSpentPicker(false);
- }}
- />
- </>
- ) : null}
- <ThemedText>{t('costs.notes')}</ThemedText>
- <TextInput
- value={notes}
- onChangeText={setNotes}
- placeholder={t('costs.notesPlaceholder')}
- style={inputStyle}
- multiline
- />
- <ThemedText>{t('costs.photo')}</ThemedText>
- {photoUri ? (
- <Image source={{ uri: photoUri }} style={styles.photoPreview} />
- ) : (
- <ThemedText style={styles.photoPlaceholder}>{t('costs.noPhoto')}</ThemedText>
- )}
- <View style={styles.photoRow}>
- <ThemedButton
- title={t('costs.pickPhoto')}
- onPress={() => handlePickPhoto(setPhotoUri)}
- variant="secondary"
- />
- <ThemedButton
- title={t('costs.takePhoto')}
- onPress={() =>
- handleTakePhoto(setPhotoUri, (code) =>
- setStatus(
- code === 'cameraDenied'
- ? t('tasks.cameraDenied')
- : t('tasks.cameraError')
- )
- )
- }
- variant="secondary"
- />
- </View>
- <View style={styles.modalActions}>
- <ThemedButton
- title={t('costs.cancel')}
- onPress={() => setNewModalOpen(false)}
- variant="secondary"
- />
- <ThemedButton
- title={t('costs.save')}
- onPress={async () => {
- const ok = await handleSave();
- if (ok) setNewModalOpen(false);
- }}
- />
- </View>
- <View style={styles.sheetFooter} />
- </View>
- )}
- />
- </View>
- </KeyboardAvoidingView>
- </View>
- </Modal>
- <Modal transparent visible={editModalOpen} animationType="slide">
- <View style={styles.sheetOverlay}>
- <Pressable style={styles.sheetBackdrop} onPress={cancelEdit} />
- <KeyboardAvoidingView
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
- style={styles.keyboardAvoid}>
- <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <FlatList
- data={[{ key: 'edit' }]}
- keyExtractor={(item) => item.key}
- contentContainerStyle={styles.sheetListContent}
- renderItem={() => (
- <View style={styles.sheetContent}>
- <ThemedText type="subtitle">{t('costs.edit')}</ThemedText>
- <ThemedText>
- {t('costs.field')}
- <ThemedText style={styles.requiredMark}> *</ThemedText>
- </ThemedText>
- <ThemedButton
- title={selectedEditField?.name || t('costs.selectField')}
- onPress={() => {
- setReopenSheetAfterSelect(true);
- setFieldModalTarget('edit');
- setEditModalOpen(false);
- setFieldModalOpen(true);
- }}
- variant="secondary"
- />
- {editErrors.field ? (
- <ThemedText style={styles.errorText}>{editErrors.field}</ThemedText>
- ) : null}
- <ThemedText>{t('costs.crop')}</ThemedText>
- <ThemedButton
- title={selectedEditCrop?.crop_name || t('costs.selectCrop')}
- onPress={() => {
- setReopenSheetAfterSelect(true);
- setCropModalTarget('edit');
- setEditModalOpen(false);
- setCropModalOpen(true);
- }}
- variant="secondary"
- />
- <ThemedText>{t('costs.category')}</ThemedText>
- <View style={styles.chipRow}>
- {categoryPresets.map((preset) => {
- const label = t(`costs.category.${preset}`);
- const normalized = editCategory.trim().toLowerCase();
- const isActive = label.toLowerCase() === normalized || preset === normalized;
- return (
- <Pressable
- key={`edit-${preset}`}
- onPress={() => setEditCategory(label)}
- style={[styles.chip, isActive && styles.chipActive]}>
- <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
- {label}
- </ThemedText>
- </Pressable>
- );
- })}
- </View>
- <TextInput
- value={editCategory}
- onChangeText={setEditCategory}
- placeholder={t('costs.categoryPlaceholder')}
- style={inputStyle}
- />
- <ThemedText>
- {t('costs.amount')} ({currency})
- <ThemedText style={styles.requiredMark}> *</ThemedText>
- </ThemedText>
- <TextInput
- value={editAmount}
- onChangeText={(value) => {
- setEditAmount(value);
- if (editErrors.amount) {
- setEditErrors((prev) => ({ ...prev, amount: undefined }));
- }
- }}
- placeholder={t('costs.amountPlaceholder')}
- style={inputStyle}
- keyboardType="decimal-pad"
- />
- {editErrors.amount ? (
- <ThemedText style={styles.errorText}>{editErrors.amount}</ThemedText>
- ) : null}
- <ThemedText>{t('costs.vendor')}</ThemedText>
- <TextInput
- value={editVendor}
- onChangeText={setEditVendor}
- placeholder={t('costs.vendorPlaceholder')}
- style={inputStyle}
- />
- <ThemedText>{t('costs.date')}</ThemedText>
- <Pressable onPress={() => setShowEditSpentPicker(true)} style={styles.dateInput}>
- <ThemedText style={styles.dateValue}>
- {editSpentDate ? formatDateLabel(editSpentDate) : t('costs.datePlaceholder')}
- </ThemedText>
- </Pressable>
- {showEditSpentPicker ? (
- <>
- {Platform.OS === 'ios' ? (
- <View style={styles.pickerRow}>
- <ThemedButton
- title={t('crops.today')}
- onPress={() => {
- setEditSpentDate(toDateOnly(new Date()));
- setShowEditSpentPicker(false);
- }}
- variant="secondary"
- />
- <ThemedButton
- title={t('crops.done')}
- onPress={() => setShowEditSpentPicker(false)}
- variant="secondary"
- />
- </View>
- ) : null}
- <DateTimePicker
- value={editSpentDate ? new Date(editSpentDate) : new Date()}
- mode="date"
- display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
- onChange={(event, date) => {
- if (date) setEditSpentDate(toDateOnly(date));
- if (Platform.OS !== 'ios') setShowEditSpentPicker(false);
- }}
- />
- </>
- ) : null}
- <ThemedText>{t('costs.notes')}</ThemedText>
- <TextInput
- value={editNotes}
- onChangeText={setEditNotes}
- placeholder={t('costs.notesPlaceholder')}
- style={inputStyle}
- multiline
- />
- <ThemedText>{t('costs.photo')}</ThemedText>
- {editPhotoUri ? (
- <Image source={{ uri: editPhotoUri }} style={styles.photoPreview} />
- ) : (
- <ThemedText style={styles.photoPlaceholder}>{t('costs.noPhoto')}</ThemedText>
- )}
- <View style={styles.photoRow}>
- <ThemedButton
- title={t('costs.pickPhoto')}
- onPress={() => handlePickPhoto(setEditPhotoUri)}
- variant="secondary"
- />
- <ThemedButton
- title={t('costs.takePhoto')}
- onPress={() =>
- handleTakePhoto(setEditPhotoUri, (code) =>
- setStatus(
- code === 'cameraDenied'
- ? t('tasks.cameraDenied')
- : t('tasks.cameraError')
- )
- )
- }
- variant="secondary"
- />
- </View>
- <View style={styles.modalActions}>
- <ThemedButton
- title={t('costs.cancel')}
- onPress={cancelEdit}
- variant="secondary"
- />
- <ThemedButton title={t('costs.update')} onPress={handleUpdate} />
- </View>
- <View style={styles.sheetFooter} />
- </View>
- )}
- />
- </View>
- </KeyboardAvoidingView>
- </View>
- </Modal>
- </>
- );
- }
- 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: getImageMediaTypes(),
- 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({ quality: 1 });
- if (result.canceled) return;
- const asset = result.assets[0];
- setter(asset.uri);
- } catch {
- onError?.('cameraError');
- }
- }
- function getImageMediaTypes() {
- const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
- .MediaType;
- return mediaType?.Image ?? mediaType?.Images ?? ['images'];
- }
- 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: 160,
- borderRadius: 12,
- },
- listPhoto: {
- width: '100%',
- height: 120,
- borderRadius: 10,
- },
- 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,
- },
- });
|