| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536 |
- import { useEffect, useMemo, useState } from 'react';
- import {
- Image,
- KeyboardAvoidingView,
- Modal,
- Platform,
- Pressable,
- ScrollView,
- StyleSheet,
- TextInput,
- View,
- } from 'react-native';
- import * as ImagePicker from 'expo-image-picker';
- import DateTimePicker from '@react-native-community/datetimepicker';
- import { ResizeMode, Video } from 'expo-av';
- import { useRouter } from 'expo-router';
- import { ThemedButton } from '@/components/themed-button';
- import { ThemedText } from '@/components/themed-text';
- import { ThemedView } from '@/components/themed-view';
- import { ZoomImageModal } from '@/components/zoom-image-modal';
- import { Colors } from '@/constants/theme';
- import { useColorScheme } from '@/hooks/use-color-scheme';
- import { useTranslation } from '@/localization/i18n';
- import { dbPromise, initCoreTables } from '@/services/db';
- const CATEGORY_PRESETS = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc'];
- type FieldRow = {
- id: number;
- name: string | null;
- };
- type CropRow = {
- id: number;
- crop_name: string | null;
- };
- export default function NewCostScreen() {
- const { t } = useTranslation();
- const router = useRouter();
- const theme = useColorScheme() ?? 'light';
- const palette = Colors[theme];
- const [status, setStatus] = useState('');
- const [fields, setFields] = useState<FieldRow[]>([]);
- const [crops, setCrops] = useState<CropRow[]>([]);
- const [currency, setCurrency] = useState('THB');
- const [fieldModalOpen, setFieldModalOpen] = useState(false);
- const [cropModalOpen, setCropModalOpen] = useState(false);
- 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 [mediaUris, setMediaUris] = useState<string[]>([]);
- const [activeUri, setActiveUri] = useState<string | null>(null);
- const [errors, setErrors] = useState<{ field?: string; amount?: string }>({});
- const [zoomUri, setZoomUri] = useState<string | null>(null);
- const [saving, setSaving] = useState(false);
- useEffect(() => {
- let isActive = true;
- async function loadData() {
- try {
- await initCoreTables();
- const db = await dbPromise;
- const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
- const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
- const profileRow = await db.getFirstAsync<{ currency: string | null }>(
- 'SELECT currency FROM user_profile WHERE id = 1;'
- );
- if (!isActive) return;
- setFields(fieldRows);
- setCrops(cropRows);
- setCurrency(profileRow?.currency ?? 'THB');
- } catch (error) {
- if (isActive) setStatus(`Error: ${String(error)}`);
- }
- }
- loadData();
- return () => {
- isActive = false;
- };
- }, [t]);
- const selectedField = useMemo(
- () => fields.find((item) => item.id === selectedFieldId),
- [fields, selectedFieldId]
- );
- const selectedCrop = useMemo(
- () => crops.find((item) => item.id === selectedCropId),
- [crops, selectedCropId]
- );
- const inputStyle = [
- styles.input,
- {
- borderColor: palette.border,
- backgroundColor: palette.input,
- color: palette.text,
- },
- ];
- 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) return;
- try {
- setSaving(true);
- const db = await dbPromise;
- const now = new Date().toISOString();
- const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
- const result = 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 || null,
- primaryUri ?? null,
- now
- );
- const costId = Number(result.lastInsertRowId);
- if (costId) {
- const mediaToInsert = uniqueMediaUris([
- ...mediaUris,
- ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
- ]);
- for (const uri of mediaToInsert) {
- await db.runAsync(
- 'INSERT INTO cost_media (cost_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
- costId,
- uri,
- isVideoUri(uri) ? 'video' : 'image',
- now
- );
- }
- }
- setStatus(t('costs.saved'));
- router.back();
- } catch (error) {
- setStatus(`Error: ${String(error)}`);
- } finally {
- setSaving(false);
- }
- }
- return (
- <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
- <KeyboardAvoidingView
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- style={styles.keyboardAvoid}>
- <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
- <ThemedText type="title">{t('costs.new')}</ThemedText>
- {status ? <ThemedText>{status}</ThemedText> : null}
- <ThemedText>
- {t('costs.field')}
- <ThemedText style={styles.requiredMark}> *</ThemedText>
- </ThemedText>
- <ThemedButton
- title={selectedField?.name || t('costs.selectField')}
- onPress={() => 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={() => setCropModalOpen(true)}
- variant="secondary"
- />
- <ThemedText>{t('costs.category')}</ThemedText>
- <View style={styles.chipRow}>
- {CATEGORY_PRESETS.map((preset) => {
- const label = t(`costs.category.${preset}`);
- const isActive = label === category || preset === category;
- return (
- <Pressable
- key={preset}
- style={[styles.chip, isActive ? styles.chipActive : null]}
- onPress={() => setCategory(label)}>
- <ThemedText style={styles.chipText}>{label}</ThemedText>
- </Pressable>
- );
- })}
- </View>
- <TextInput
- value={category}
- onChangeText={setCategory}
- placeholder={t('costs.categoryPlaceholder')}
- placeholderTextColor={palette.placeholder}
- 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')}
- placeholderTextColor={palette.placeholder}
- 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')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- />
- <ThemedText>{t('costs.date')}</ThemedText>
- <Pressable onPress={() => setShowSpentPicker(true)} style={styles.dateInput}>
- <ThemedText style={styles.dateValue}>
- {spentDate || t('costs.datePlaceholder')}
- </ThemedText>
- </Pressable>
- {showSpentPicker ? (
- <DateTimePicker
- value={spentDate ? new Date(spentDate) : new Date()}
- mode="date"
- onChange={(event, date) => {
- setShowSpentPicker(false);
- if (date) setSpentDate(toDateOnly(date));
- }}
- />
- ) : null}
- <ThemedText>{t('costs.notes')}</ThemedText>
- <TextInput
- value={notes}
- onChangeText={setNotes}
- placeholder={t('costs.notesPlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={[inputStyle, styles.multiline]}
- multiline
- />
- <ThemedText>{t('costs.addMedia')}</ThemedText>
- {normalizeMediaUri(activeUri) ? (
- isVideoUri(normalizeMediaUri(activeUri) as string) ? (
- <Video
- source={{ uri: normalizeMediaUri(activeUri) as string }}
- style={styles.mediaPreview}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- />
- ) : (
- <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
- <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
- </Pressable>
- )
- ) : (
- <ThemedView style={[styles.mediaPlaceholder, { borderColor: palette.border }]}>
- <ThemedText style={styles.mediaPlaceholderText}>{t('costs.noPhoto')}</ThemedText>
- </ThemedView>
- )}
- {mediaUris.length > 0 ? (
- <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaRow}>
- {mediaUris.map((uri) => (
- <Pressable
- key={uri}
- style={styles.mediaThumbWrap}
- onPress={() => setActiveUri(uri)}>
- {isVideoUri(uri) ? (
- <Video
- source={{ uri }}
- style={styles.mediaThumb}
- resizeMode={ResizeMode.COVER}
- isMuted
- />
- ) : (
- <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
- )}
- <Pressable
- style={styles.mediaRemove}
- onPress={() => {
- setMediaUris((prev) => {
- const next = prev.filter((item) => item !== uri);
- if (activeUri === uri) setActiveUri(next[0] ?? null);
- return next;
- });
- }}>
- <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
- </Pressable>
- </Pressable>
- ))}
- </ScrollView>
- ) : null}
- <View style={styles.mediaButtons}>
- <ThemedButton
- title={t('costs.pickFromGallery')}
- onPress={() =>
- handlePickMedia((uris) => {
- setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
- if (!activeUri && uris[0]) setActiveUri(uris[0]);
- })
- }
- variant="secondary"
- />
- <ThemedButton
- title={t('costs.takeMedia')}
- onPress={() =>
- handleTakeMedia((uri) => {
- if (!uri) return;
- setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
- if (!activeUri) setActiveUri(uri);
- })
- }
- variant="secondary"
- />
- </View>
- <View style={styles.actions}>
- <ThemedButton title={t('costs.cancel')} onPress={() => router.back()} variant="ghost" />
- <ThemedButton title={saving ? t('costs.saving') : t('costs.save')} onPress={handleSave} />
- </View>
- </ScrollView>
- </KeyboardAvoidingView>
- <Modal transparent visible={fieldModalOpen} animationType="fade">
- <Pressable style={styles.modalOverlay} onPress={() => setFieldModalOpen(false)}>
- <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
- <ScrollView>
- {fields.map((item) => (
- <Pressable
- key={item.id}
- onPress={() => {
- setSelectedFieldId(item.id);
- setErrors((prev) => ({ ...prev, field: undefined }));
- setFieldModalOpen(false);
- }}
- style={styles.modalItem}>
- <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
- </Pressable>
- ))}
- </ScrollView>
- </View>
- </Pressable>
- </Modal>
- <Modal transparent visible={cropModalOpen} animationType="fade">
- <Pressable style={styles.modalOverlay} onPress={() => setCropModalOpen(false)}>
- <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
- <ScrollView>
- {crops.map((item) => (
- <Pressable
- key={item.id}
- onPress={() => {
- setSelectedCropId(item.id);
- setCropModalOpen(false);
- }}
- style={styles.modalItem}>
- <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
- </Pressable>
- ))}
- </ScrollView>
- </View>
- </Pressable>
- </Modal>
- <ZoomImageModal visible={Boolean(zoomUri)} uri={zoomUri} onClose={() => setZoomUri(null)} />
- </ThemedView>
- );
- }
- async function handlePickMedia(onAdd: (uris: string[]) => void) {
- const pickerTypes =
- (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
- (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
- undefined;
- const mediaTypes =
- pickerTypes?.Images && pickerTypes?.Videos
- ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
- : ['images', 'videos'];
- const result = await ImagePicker.launchImageLibraryAsync({
- mediaTypes,
- quality: 1,
- allowsMultipleSelection: true,
- });
- if (result.canceled) return;
- const uris = result.assets.map((asset) => asset.uri).filter(Boolean);
- if (uris.length > 0) onAdd(uris);
- }
- async function handleTakeMedia(onAdd: (uri: string | null) => void) {
- const pickerTypes =
- (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
- (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
- undefined;
- const mediaTypes =
- pickerTypes?.Images && pickerTypes?.Videos
- ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
- : ['images', 'videos'];
- const result = await ImagePicker.launchCameraAsync({
- mediaTypes,
- quality: 1,
- });
- if (result.canceled) return;
- onAdd(result.assets[0]?.uri ?? null);
- }
- function isVideoUri(uri: string) {
- const cleaned = uri.split('?')[0]?.toLowerCase() ?? '';
- return ['.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'].some((ext) => cleaned.endsWith(ext));
- }
- function normalizeMediaUri(uri?: string | null) {
- if (!uri) return null;
- if (uri.startsWith('file://') || uri.startsWith('http://') || uri.startsWith('https://')) return uri;
- return `file://${uri}`;
- }
- function uniqueMediaUris(uris: string[]) {
- return Array.from(new Set(uris.filter(Boolean)));
- }
- function toDateOnly(date: Date) {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
- }
- const styles = StyleSheet.create({
- container: { flex: 1 },
- keyboardAvoid: { flex: 1 },
- content: { padding: 16, paddingBottom: 40 },
- input: {
- borderWidth: 1,
- borderRadius: 12,
- paddingHorizontal: 12,
- paddingVertical: 10,
- marginTop: 6,
- marginBottom: 12,
- fontSize: 16,
- },
- multiline: { minHeight: 90, textAlignVertical: 'top' },
- errorText: { color: '#C0392B', marginBottom: 8 },
- requiredMark: { color: '#C0392B' },
- dateInput: {
- borderWidth: 1,
- borderRadius: 12,
- paddingHorizontal: 12,
- paddingVertical: 12,
- marginTop: 6,
- marginBottom: 12,
- },
- dateValue: { fontSize: 16 },
- chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 },
- chip: {
- paddingHorizontal: 12,
- paddingVertical: 6,
- borderRadius: 999,
- borderWidth: 1,
- borderColor: '#C9D2C4',
- backgroundColor: '#F8F6F0',
- },
- chipActive: { backgroundColor: '#DDE8DA', borderColor: '#88A68F' },
- chipText: { fontSize: 13 },
- mediaPreview: {
- width: '100%',
- height: 200,
- borderRadius: 16,
- backgroundColor: '#F1F1F1',
- marginBottom: 12,
- },
- mediaPlaceholder: {
- width: '100%',
- height: 200,
- borderRadius: 16,
- borderWidth: 1,
- justifyContent: 'center',
- alignItems: 'center',
- marginBottom: 12,
- },
- mediaPlaceholderText: { opacity: 0.7 },
- mediaRow: { marginBottom: 12 },
- mediaThumbWrap: { marginRight: 8 },
- mediaThumb: { width: 72, height: 72, borderRadius: 12 },
- mediaRemove: {
- position: 'absolute',
- top: -6,
- right: -6,
- width: 22,
- height: 22,
- borderRadius: 11,
- backgroundColor: '#2C2C2C',
- justifyContent: 'center',
- alignItems: 'center',
- },
- mediaRemoveText: { color: '#fff', fontSize: 14, lineHeight: 16 },
- mediaButtons: { flexDirection: 'row', gap: 12, flexWrap: 'wrap', marginBottom: 16 },
- actions: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
- modalOverlay: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.4)',
- justifyContent: 'center',
- padding: 20,
- },
- modalCard: {
- borderRadius: 16,
- borderWidth: 1,
- padding: 16,
- maxHeight: '70%',
- },
- modalItem: { paddingVertical: 10 },
- });
|