Нет описания

index.tsx 43KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import {
  3. Alert,
  4. FlatList,
  5. Image,
  6. InteractionManager,
  7. KeyboardAvoidingView,
  8. Modal,
  9. Pressable,
  10. StyleSheet,
  11. TextInput,
  12. View,
  13. Platform,
  14. } from 'react-native';
  15. import DateTimePicker from '@react-native-community/datetimepicker';
  16. import * as ImagePicker from 'expo-image-picker';
  17. import { ResizeMode, Video } from 'expo-av';
  18. import { ThemedText } from '@/components/themed-text';
  19. import { ThemedView } from '@/components/themed-view';
  20. import { ThemedButton } from '@/components/themed-button';
  21. import { IconButton } from '@/components/icon-button';
  22. import { IconSymbol } from '@/components/ui/icon-symbol';
  23. import { Colors, Fonts } from '@/constants/theme';
  24. import { useTranslation } from '@/localization/i18n';
  25. import { dbPromise, initCoreTables } from '@/services/db';
  26. import { ZoomImageModal } from '@/components/zoom-image-modal';
  27. import { useLocalSearchParams, useRouter } from 'expo-router';
  28. import { useFocusEffect, useNavigation } from '@react-navigation/native';
  29. import { useColorScheme } from '@/hooks/use-color-scheme';
  30. type FieldRow = {
  31. id: number;
  32. name: string | null;
  33. };
  34. type CropRow = {
  35. id: number;
  36. field_id: number | null;
  37. crop_name: string | null;
  38. };
  39. type CostRow = {
  40. id: number;
  41. field_id: number | null;
  42. crop_id: number | null;
  43. category: string | null;
  44. amount: number | null;
  45. currency: string | null;
  46. vendor: string | null;
  47. notes: string | null;
  48. spent_at: string | null;
  49. photo_uri: string | null;
  50. field_name: string | null;
  51. crop_name: string | null;
  52. };
  53. export default function CostsScreen() {
  54. const { t } = useTranslation();
  55. const router = useRouter();
  56. const navigation = useNavigation();
  57. const params = useLocalSearchParams<{ from?: string | string[] }>();
  58. const theme = useColorScheme() ?? 'light';
  59. const palette = Colors[theme];
  60. const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
  61. const categoryPresets = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc'];
  62. const [currency, setCurrency] = useState('THB');
  63. const [costs, setCosts] = useState<CostRow[]>([]);
  64. const [fields, setFields] = useState<FieldRow[]>([]);
  65. const [crops, setCrops] = useState<CropRow[]>([]);
  66. const [status, setStatus] = useState(t('costs.loading'));
  67. const [newModalOpen, setNewModalOpen] = useState(false);
  68. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  69. const [cropModalOpen, setCropModalOpen] = useState(false);
  70. const [reopenSheetAfterSelect, setReopenSheetAfterSelect] = useState(false);
  71. const [fieldModalTarget, setFieldModalTarget] = useState<'new' | 'edit'>('new');
  72. const [cropModalTarget, setCropModalTarget] = useState<'new' | 'edit'>('new');
  73. const [editModalOpen, setEditModalOpen] = useState(false);
  74. const [editingId, setEditingId] = useState<number | null>(null);
  75. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  76. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  77. useEffect(() => {
  78. navigation.setOptions({
  79. headerLeft: () => (
  80. <Pressable
  81. onPress={() => {
  82. if (fromParam === 'logbook') {
  83. router.replace('/logbook');
  84. return;
  85. }
  86. if (fromParam === 'home') {
  87. router.replace('/');
  88. return;
  89. }
  90. router.back();
  91. }}
  92. hitSlop={10}
  93. style={{ paddingHorizontal: 8 }}>
  94. <IconSymbol name="chevron.left" size={20} color={palette.text} />
  95. </Pressable>
  96. ),
  97. });
  98. }, [fromParam, navigation, palette.text, router]);
  99. const [category, setCategory] = useState('');
  100. const [amount, setAmount] = useState('');
  101. const [vendor, setVendor] = useState('');
  102. const [notes, setNotes] = useState('');
  103. const [spentDate, setSpentDate] = useState('');
  104. const [showSpentPicker, setShowSpentPicker] = useState(false);
  105. const [photoUri, setPhotoUri] = useState<string | null>(null);
  106. const [errors, setErrors] = useState<{ field?: string; amount?: string }>({});
  107. const [editFieldId, setEditFieldId] = useState<number | null>(null);
  108. const [editCropId, setEditCropId] = useState<number | null>(null);
  109. const [editCategory, setEditCategory] = useState('');
  110. const [editAmount, setEditAmount] = useState('');
  111. const [editVendor, setEditVendor] = useState('');
  112. const [editNotes, setEditNotes] = useState('');
  113. const [editSpentDate, setEditSpentDate] = useState('');
  114. const [showEditSpentPicker, setShowEditSpentPicker] = useState(false);
  115. const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
  116. const [zoomUri, setZoomUri] = useState<string | null>(null);
  117. const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
  118. const [editErrors, setEditErrors] = useState<{ field?: string; amount?: string }>({});
  119. const selectedField = useMemo(
  120. () => fields.find((item) => item.id === selectedFieldId),
  121. [fields, selectedFieldId]
  122. );
  123. const selectedCrop = useMemo(
  124. () => crops.find((item) => item.id === selectedCropId),
  125. [crops, selectedCropId]
  126. );
  127. const selectedEditField = useMemo(
  128. () => fields.find((item) => item.id === editFieldId),
  129. [fields, editFieldId]
  130. );
  131. const selectedEditCrop = useMemo(
  132. () => crops.find((item) => item.id === editCropId),
  133. [crops, editCropId]
  134. );
  135. useEffect(() => {
  136. let isActive = true;
  137. async function loadData() {
  138. try {
  139. await initCoreTables();
  140. const db = await dbPromise;
  141. const profileRow = await db.getFirstAsync<{ currency: string | null }>(
  142. 'SELECT currency FROM user_profile WHERE id = 1;'
  143. );
  144. const fieldRows = await db.getAllAsync<FieldRow>(
  145. 'SELECT id, name FROM fields ORDER BY name ASC;'
  146. );
  147. const cropRows = await db.getAllAsync<CropRow>(
  148. 'SELECT id, field_id, crop_name FROM crops ORDER BY id DESC;'
  149. );
  150. const costRows = await db.getAllAsync<CostRow>(
  151. `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes,
  152. c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name
  153. FROM costs c
  154. LEFT JOIN fields f ON f.id = c.field_id
  155. LEFT JOIN crops cr ON cr.id = c.crop_id
  156. ORDER BY c.spent_at DESC;`
  157. );
  158. if (!isActive) return;
  159. setCurrency(profileRow?.currency ?? 'THB');
  160. setFields(fieldRows);
  161. setCrops(cropRows);
  162. setCosts(costRows);
  163. setStatus(costRows.length === 0 ? t('costs.empty') : '');
  164. } catch (error) {
  165. if (isActive) setStatus(`Error: ${String(error)}`);
  166. }
  167. }
  168. loadData();
  169. return () => {
  170. isActive = false;
  171. };
  172. }, [t]);
  173. const fetchCostsPage = useCallback(async () => {
  174. try {
  175. const db = await dbPromise;
  176. const profileRow = await db.getFirstAsync<{ currency: string | null }>(
  177. 'SELECT currency FROM user_profile WHERE id = 1;'
  178. );
  179. const costRows = await db.getAllAsync<CostRow>(
  180. `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes,
  181. c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name
  182. FROM costs c
  183. LEFT JOIN fields f ON f.id = c.field_id
  184. LEFT JOIN crops cr ON cr.id = c.crop_id
  185. ORDER BY c.spent_at DESC;`
  186. );
  187. setCurrency(profileRow?.currency ?? 'THB');
  188. setCosts(costRows);
  189. setStatus(costRows.length === 0 ? t('costs.empty') : '');
  190. } catch (error) {
  191. setStatus(`Error: ${String(error)}`);
  192. }
  193. }, [t]);
  194. useFocusEffect(
  195. useCallback(() => {
  196. fetchCostsPage();
  197. }, [fetchCostsPage])
  198. );
  199. useEffect(() => {
  200. if (!newModalOpen && !editModalOpen && pendingZoomUri) {
  201. const uri = pendingZoomUri;
  202. setPendingZoomUri(null);
  203. InteractionManager.runAfterInteractions(() => {
  204. setTimeout(() => setZoomUri(uri), 150);
  205. });
  206. }
  207. }, [newModalOpen, editModalOpen, pendingZoomUri]);
  208. async function handleSave() {
  209. const parsedAmount = amount.trim() ? Number(amount) : null;
  210. const nextErrors: { field?: string; amount?: string } = {};
  211. if (!selectedFieldId) nextErrors.field = t('costs.fieldRequired');
  212. if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid');
  213. setErrors(nextErrors);
  214. if (Object.keys(nextErrors).length > 0) {
  215. setStatus(nextErrors.field ?? nextErrors.amount ?? t('costs.fieldRequired'));
  216. return false;
  217. }
  218. try {
  219. const db = await dbPromise;
  220. const now = new Date().toISOString();
  221. await db.runAsync(
  222. 'INSERT INTO costs (field_id, crop_id, category, amount, currency, vendor, notes, spent_at, photo_uri, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
  223. selectedFieldId,
  224. selectedCropId,
  225. category.trim() || null,
  226. parsedAmount,
  227. currency,
  228. vendor.trim() || null,
  229. notes.trim() || null,
  230. spentDate.trim() || now,
  231. photoUri,
  232. now
  233. );
  234. resetNewForm();
  235. setStatus(t('costs.saved'));
  236. const costRows = await db.getAllAsync<CostRow>(
  237. `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes,
  238. c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name
  239. FROM costs c
  240. LEFT JOIN fields f ON f.id = c.field_id
  241. LEFT JOIN crops cr ON cr.id = c.crop_id
  242. ORDER BY c.spent_at DESC;`
  243. );
  244. setCosts(costRows);
  245. return true;
  246. } catch (error) {
  247. setStatus(`Error: ${String(error)}`);
  248. return false;
  249. }
  250. }
  251. async function handleUpdate() {
  252. if (!editingId) return;
  253. const parsedAmount = editAmount.trim() ? Number(editAmount) : null;
  254. const nextErrors: { field?: string; amount?: string } = {};
  255. if (!editFieldId) nextErrors.field = t('costs.fieldRequired');
  256. if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid');
  257. setEditErrors(nextErrors);
  258. if (Object.keys(nextErrors).length > 0) {
  259. setStatus(nextErrors.field ?? nextErrors.amount ?? t('costs.fieldRequired'));
  260. return;
  261. }
  262. try {
  263. const db = await dbPromise;
  264. const now = new Date().toISOString();
  265. await db.runAsync(
  266. 'UPDATE costs SET field_id = ?, crop_id = ?, category = ?, amount = ?, currency = ?, vendor = ?, notes = ?, spent_at = ?, photo_uri = ? WHERE id = ?;',
  267. editFieldId,
  268. editCropId,
  269. editCategory.trim() || null,
  270. parsedAmount,
  271. currency,
  272. editVendor.trim() || null,
  273. editNotes.trim() || null,
  274. editSpentDate.trim() || now,
  275. editPhotoUri,
  276. editingId
  277. );
  278. setEditModalOpen(false);
  279. setEditingId(null);
  280. const costRows = await db.getAllAsync<CostRow>(
  281. `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes,
  282. c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name
  283. FROM costs c
  284. LEFT JOIN fields f ON f.id = c.field_id
  285. LEFT JOIN crops cr ON cr.id = c.crop_id
  286. ORDER BY c.spent_at DESC;`
  287. );
  288. setCosts(costRows);
  289. } catch (error) {
  290. setStatus(`Error: ${String(error)}`);
  291. }
  292. }
  293. function startEdit(item: CostRow) {
  294. router.push(`/costs/${item.id}`);
  295. }
  296. function cancelEdit() {
  297. setEditingId(null);
  298. setEditFieldId(null);
  299. setEditCropId(null);
  300. setEditCategory('');
  301. setEditAmount('');
  302. setEditVendor('');
  303. setEditNotes('');
  304. setEditSpentDate('');
  305. setEditPhotoUri(null);
  306. setEditErrors({});
  307. setEditModalOpen(false);
  308. }
  309. function resetNewForm() {
  310. setSelectedFieldId(null);
  311. setSelectedCropId(null);
  312. setCategory('');
  313. setAmount('');
  314. setVendor('');
  315. setNotes('');
  316. setSpentDate('');
  317. setPhotoUri(null);
  318. setErrors({});
  319. }
  320. function confirmDelete(id: number) {
  321. Alert.alert(t('costs.deleteTitle'), t('costs.deleteMessage'), [
  322. { text: t('costs.cancel'), style: 'cancel' },
  323. { text: t('costs.delete'), style: 'destructive', onPress: () => handleDelete(id) },
  324. ]);
  325. }
  326. async function handleDelete(id: number) {
  327. try {
  328. const db = await dbPromise;
  329. await db.runAsync('DELETE FROM costs WHERE id = ?;', id);
  330. const costRows = await db.getAllAsync<CostRow>(
  331. `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes,
  332. c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name
  333. FROM costs c
  334. LEFT JOIN fields f ON f.id = c.field_id
  335. LEFT JOIN crops cr ON cr.id = c.crop_id
  336. ORDER BY c.spent_at DESC;`
  337. );
  338. setCosts(costRows);
  339. setStatus(costRows.length === 0 ? t('costs.empty') : '');
  340. } catch (error) {
  341. setStatus(`Error: ${String(error)}`);
  342. }
  343. }
  344. const inputStyle = [
  345. styles.input,
  346. { borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
  347. ];
  348. return (
  349. <>
  350. <FlatList
  351. data={costs}
  352. keyExtractor={(item) => String(item.id)}
  353. renderItem={({ item }) => (
  354. <Pressable onPress={() => startEdit(item)}>
  355. <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
  356. <View style={styles.cardHeader}>
  357. <ThemedText type="subtitle">
  358. {item.category || t('costs.untitled')}
  359. </ThemedText>
  360. <IconButton
  361. name="trash"
  362. onPress={() => confirmDelete(item.id)}
  363. accessibilityLabel={t('costs.delete')}
  364. variant="danger"
  365. />
  366. </View>
  367. <ThemedText style={styles.meta}>
  368. {item.field_name || t('costs.noField')}
  369. </ThemedText>
  370. {item.crop_name ? <ThemedText style={styles.meta}>{item.crop_name}</ThemedText> : null}
  371. {item.spent_at ? (
  372. <ThemedText style={styles.meta}>{formatDate(item.spent_at)}</ThemedText>
  373. ) : null}
  374. {item.amount !== null ? (
  375. <ThemedText>
  376. {item.amount} {item.currency || currency}
  377. </ThemedText>
  378. ) : null}
  379. {normalizeMediaUri(item.photo_uri) ? (
  380. isVideoUri(normalizeMediaUri(item.photo_uri) as string) ? (
  381. <Video
  382. source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
  383. style={styles.videoPreview}
  384. useNativeControls
  385. resizeMode={ResizeMode.CONTAIN}
  386. isMuted
  387. />
  388. ) : (
  389. <Pressable onPress={() => setZoomUri(normalizeMediaUri(item.photo_uri) as string)}>
  390. <Image
  391. source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
  392. style={styles.listPhoto}
  393. resizeMode="contain"
  394. />
  395. </Pressable>
  396. )
  397. ) : null}
  398. {item.vendor ? <ThemedText>{item.vendor}</ThemedText> : null}
  399. {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
  400. </ThemedView>
  401. </Pressable>
  402. )}
  403. ItemSeparatorComponent={() => <View style={styles.separator} />}
  404. ListHeaderComponent={
  405. <View>
  406. <ThemedView style={styles.hero}>
  407. <Image source={require('@/assets/images/costrecords.jpg')} style={styles.heroImage} />
  408. </ThemedView>
  409. <ThemedView style={styles.titleContainer}>
  410. <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
  411. {t('costs.title')}
  412. </ThemedText>
  413. </ThemedView>
  414. {status ? (
  415. <ThemedView style={styles.section}>
  416. <ThemedText>{status}</ThemedText>
  417. </ThemedView>
  418. ) : null}
  419. <ThemedView style={styles.section}>
  420. <Pressable style={styles.newButton} onPress={() => router.push('/costs/new')}>
  421. <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
  422. <ThemedText style={styles.newButtonText}>{t('costs.new')}</ThemedText>
  423. </Pressable>
  424. </ThemedView>
  425. </View>
  426. }
  427. ListFooterComponent={<View style={styles.footer} />}
  428. />
  429. <Modal transparent visible={fieldModalOpen} animationType="fade">
  430. <Pressable
  431. style={styles.modalOverlay}
  432. onPress={() => {
  433. setFieldModalOpen(false);
  434. setReopenSheetAfterSelect(false);
  435. }}>
  436. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  437. <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
  438. <FlatList
  439. data={fields}
  440. keyExtractor={(item) => String(item.id)}
  441. renderItem={({ item }) => (
  442. <Pressable
  443. onPress={() => {
  444. if (fieldModalTarget === 'edit') {
  445. setEditFieldId(item.id);
  446. setEditErrors((prev) => ({ ...prev, field: undefined }));
  447. if (reopenSheetAfterSelect) setEditModalOpen(true);
  448. } else {
  449. setSelectedFieldId(item.id);
  450. setErrors((prev) => ({ ...prev, field: undefined }));
  451. if (reopenSheetAfterSelect) setNewModalOpen(true);
  452. }
  453. setFieldModalOpen(false);
  454. setReopenSheetAfterSelect(false);
  455. }}
  456. style={styles.modalItem}>
  457. <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
  458. </Pressable>
  459. )}
  460. />
  461. </View>
  462. </Pressable>
  463. </Modal>
  464. <Modal transparent visible={cropModalOpen} animationType="fade">
  465. <Pressable
  466. style={styles.modalOverlay}
  467. onPress={() => {
  468. setCropModalOpen(false);
  469. setReopenSheetAfterSelect(false);
  470. }}>
  471. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  472. <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
  473. <FlatList
  474. data={crops.filter((item) => {
  475. const targetField = cropModalTarget === 'edit' ? editFieldId : selectedFieldId;
  476. return !targetField || item.field_id === targetField;
  477. })}
  478. keyExtractor={(item) => String(item.id)}
  479. renderItem={({ item }) => (
  480. <Pressable
  481. onPress={() => {
  482. if (cropModalTarget === 'edit') {
  483. setEditCropId(item.id);
  484. if (reopenSheetAfterSelect) setEditModalOpen(true);
  485. } else {
  486. setSelectedCropId(item.id);
  487. if (reopenSheetAfterSelect) setNewModalOpen(true);
  488. }
  489. setCropModalOpen(false);
  490. setReopenSheetAfterSelect(false);
  491. }}
  492. style={styles.modalItem}>
  493. <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
  494. </Pressable>
  495. )}
  496. ListEmptyComponent={<ThemedText style={styles.meta}>{t('costs.noCrop')}</ThemedText>}
  497. />
  498. </View>
  499. </Pressable>
  500. </Modal>
  501. <Modal transparent visible={newModalOpen} animationType="slide">
  502. <View style={styles.sheetOverlay}>
  503. <Pressable style={styles.sheetBackdrop} onPress={() => setNewModalOpen(false)} />
  504. <KeyboardAvoidingView
  505. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  506. keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
  507. style={styles.keyboardAvoid}>
  508. <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
  509. <FlatList
  510. data={[{ key: 'new' }]}
  511. keyExtractor={(item) => item.key}
  512. contentContainerStyle={styles.sheetListContent}
  513. renderItem={() => (
  514. <View style={styles.sheetContent}>
  515. <ThemedText type="subtitle">{t('costs.new')}</ThemedText>
  516. <ThemedText>
  517. {t('costs.field')}
  518. <ThemedText style={styles.requiredMark}> *</ThemedText>
  519. </ThemedText>
  520. <ThemedButton
  521. title={selectedField?.name || t('costs.selectField')}
  522. onPress={() => {
  523. setReopenSheetAfterSelect(true);
  524. setFieldModalTarget('new');
  525. setNewModalOpen(false);
  526. setFieldModalOpen(true);
  527. }}
  528. variant="secondary"
  529. />
  530. {errors.field ? (
  531. <ThemedText style={styles.errorText}>{errors.field}</ThemedText>
  532. ) : null}
  533. <ThemedText>{t('costs.crop')}</ThemedText>
  534. <ThemedButton
  535. title={selectedCrop?.crop_name || t('costs.selectCrop')}
  536. onPress={() => {
  537. setReopenSheetAfterSelect(true);
  538. setCropModalTarget('new');
  539. setNewModalOpen(false);
  540. setCropModalOpen(true);
  541. }}
  542. variant="secondary"
  543. />
  544. <ThemedText>{t('costs.category')}</ThemedText>
  545. <View style={styles.chipRow}>
  546. {categoryPresets.map((preset) => {
  547. const label = t(`costs.category.${preset}`);
  548. const normalized = category.trim().toLowerCase();
  549. const isActive = label.toLowerCase() === normalized || preset === normalized;
  550. return (
  551. <Pressable
  552. key={preset}
  553. onPress={() => setCategory(label)}
  554. style={[styles.chip, isActive && styles.chipActive]}>
  555. <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
  556. {label}
  557. </ThemedText>
  558. </Pressable>
  559. );
  560. })}
  561. </View>
  562. <TextInput
  563. value={category}
  564. onChangeText={setCategory}
  565. placeholder={t('costs.categoryPlaceholder')}
  566. style={inputStyle}
  567. />
  568. <ThemedText>
  569. {t('costs.amount')} ({currency})
  570. <ThemedText style={styles.requiredMark}> *</ThemedText>
  571. </ThemedText>
  572. <TextInput
  573. value={amount}
  574. onChangeText={(value) => {
  575. setAmount(value);
  576. if (errors.amount) {
  577. setErrors((prev) => ({ ...prev, amount: undefined }));
  578. }
  579. }}
  580. placeholder={t('costs.amountPlaceholder')}
  581. style={inputStyle}
  582. keyboardType="decimal-pad"
  583. />
  584. {errors.amount ? (
  585. <ThemedText style={styles.errorText}>{errors.amount}</ThemedText>
  586. ) : null}
  587. <ThemedText>{t('costs.vendor')}</ThemedText>
  588. <TextInput
  589. value={vendor}
  590. onChangeText={setVendor}
  591. placeholder={t('costs.vendorPlaceholder')}
  592. style={inputStyle}
  593. />
  594. <ThemedText>{t('costs.date')}</ThemedText>
  595. <Pressable onPress={() => setShowSpentPicker(true)} style={styles.dateInput}>
  596. <ThemedText style={styles.dateValue}>
  597. {spentDate ? formatDateLabel(spentDate) : t('costs.datePlaceholder')}
  598. </ThemedText>
  599. </Pressable>
  600. {showSpentPicker ? (
  601. <>
  602. {Platform.OS === 'ios' ? (
  603. <View style={styles.pickerRow}>
  604. <ThemedButton
  605. title={t('crops.today')}
  606. onPress={() => {
  607. setSpentDate(toDateOnly(new Date()));
  608. setShowSpentPicker(false);
  609. }}
  610. variant="secondary"
  611. />
  612. <ThemedButton
  613. title={t('crops.done')}
  614. onPress={() => setShowSpentPicker(false)}
  615. variant="secondary"
  616. />
  617. </View>
  618. ) : null}
  619. <DateTimePicker
  620. value={spentDate ? new Date(spentDate) : new Date()}
  621. mode="date"
  622. display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
  623. onChange={(event, date) => {
  624. if (date) setSpentDate(toDateOnly(date));
  625. if (Platform.OS !== 'ios') setShowSpentPicker(false);
  626. }}
  627. />
  628. </>
  629. ) : null}
  630. <ThemedText>{t('costs.notes')}</ThemedText>
  631. <TextInput
  632. value={notes}
  633. onChangeText={setNotes}
  634. placeholder={t('costs.notesPlaceholder')}
  635. style={inputStyle}
  636. multiline
  637. />
  638. <ThemedText>{t('costs.addMedia')}</ThemedText>
  639. {normalizeMediaUri(photoUri) ? (
  640. isVideoUri(normalizeMediaUri(photoUri) as string) ? (
  641. <Video
  642. source={{ uri: normalizeMediaUri(photoUri) as string }}
  643. style={styles.videoPreview}
  644. useNativeControls
  645. resizeMode={ResizeMode.CONTAIN}
  646. isMuted
  647. />
  648. ) : (
  649. <Pressable
  650. onPress={() => {
  651. setPendingZoomUri(normalizeMediaUri(photoUri) as string);
  652. setNewModalOpen(false);
  653. }}>
  654. <Image
  655. source={{ uri: normalizeMediaUri(photoUri) as string }}
  656. style={styles.photoPreview}
  657. resizeMode="contain"
  658. />
  659. </Pressable>
  660. )
  661. ) : (
  662. <ThemedText style={styles.photoPlaceholder}>{t('costs.noPhoto')}</ThemedText>
  663. )}
  664. <View style={styles.photoRow}>
  665. <ThemedButton
  666. title={t('costs.pickFromGallery')}
  667. onPress={() => handlePickPhoto(setPhotoUri)}
  668. variant="secondary"
  669. />
  670. <ThemedButton
  671. title={t('costs.takeMedia')}
  672. onPress={() =>
  673. handleTakePhoto(setPhotoUri, (code) =>
  674. setStatus(
  675. code === 'cameraDenied'
  676. ? t('tasks.cameraDenied')
  677. : t('tasks.cameraError')
  678. )
  679. )
  680. }
  681. variant="secondary"
  682. />
  683. </View>
  684. <View style={styles.modalActions}>
  685. <ThemedButton
  686. title={t('costs.cancel')}
  687. onPress={() => setNewModalOpen(false)}
  688. variant="secondary"
  689. />
  690. <ThemedButton
  691. title={t('costs.save')}
  692. onPress={async () => {
  693. const ok = await handleSave();
  694. if (ok) setNewModalOpen(false);
  695. }}
  696. />
  697. </View>
  698. <View style={styles.sheetFooter} />
  699. </View>
  700. )}
  701. />
  702. </View>
  703. </KeyboardAvoidingView>
  704. </View>
  705. </Modal>
  706. <Modal transparent visible={editModalOpen} animationType="slide">
  707. <View style={styles.sheetOverlay}>
  708. <Pressable style={styles.sheetBackdrop} onPress={cancelEdit} />
  709. <KeyboardAvoidingView
  710. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  711. keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
  712. style={styles.keyboardAvoid}>
  713. <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
  714. <FlatList
  715. data={[{ key: 'edit' }]}
  716. keyExtractor={(item) => item.key}
  717. contentContainerStyle={styles.sheetListContent}
  718. renderItem={() => (
  719. <View style={styles.sheetContent}>
  720. <ThemedText type="subtitle">{t('costs.edit')}</ThemedText>
  721. <ThemedText>
  722. {t('costs.field')}
  723. <ThemedText style={styles.requiredMark}> *</ThemedText>
  724. </ThemedText>
  725. <ThemedButton
  726. title={selectedEditField?.name || t('costs.selectField')}
  727. onPress={() => {
  728. setReopenSheetAfterSelect(true);
  729. setFieldModalTarget('edit');
  730. setEditModalOpen(false);
  731. setFieldModalOpen(true);
  732. }}
  733. variant="secondary"
  734. />
  735. {editErrors.field ? (
  736. <ThemedText style={styles.errorText}>{editErrors.field}</ThemedText>
  737. ) : null}
  738. <ThemedText>{t('costs.crop')}</ThemedText>
  739. <ThemedButton
  740. title={selectedEditCrop?.crop_name || t('costs.selectCrop')}
  741. onPress={() => {
  742. setReopenSheetAfterSelect(true);
  743. setCropModalTarget('edit');
  744. setEditModalOpen(false);
  745. setCropModalOpen(true);
  746. }}
  747. variant="secondary"
  748. />
  749. <ThemedText>{t('costs.category')}</ThemedText>
  750. <View style={styles.chipRow}>
  751. {categoryPresets.map((preset) => {
  752. const label = t(`costs.category.${preset}`);
  753. const normalized = editCategory.trim().toLowerCase();
  754. const isActive = label.toLowerCase() === normalized || preset === normalized;
  755. return (
  756. <Pressable
  757. key={`edit-${preset}`}
  758. onPress={() => setEditCategory(label)}
  759. style={[styles.chip, isActive && styles.chipActive]}>
  760. <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
  761. {label}
  762. </ThemedText>
  763. </Pressable>
  764. );
  765. })}
  766. </View>
  767. <TextInput
  768. value={editCategory}
  769. onChangeText={setEditCategory}
  770. placeholder={t('costs.categoryPlaceholder')}
  771. style={inputStyle}
  772. />
  773. <ThemedText>
  774. {t('costs.amount')} ({currency})
  775. <ThemedText style={styles.requiredMark}> *</ThemedText>
  776. </ThemedText>
  777. <TextInput
  778. value={editAmount}
  779. onChangeText={(value) => {
  780. setEditAmount(value);
  781. if (editErrors.amount) {
  782. setEditErrors((prev) => ({ ...prev, amount: undefined }));
  783. }
  784. }}
  785. placeholder={t('costs.amountPlaceholder')}
  786. style={inputStyle}
  787. keyboardType="decimal-pad"
  788. />
  789. {editErrors.amount ? (
  790. <ThemedText style={styles.errorText}>{editErrors.amount}</ThemedText>
  791. ) : null}
  792. <ThemedText>{t('costs.vendor')}</ThemedText>
  793. <TextInput
  794. value={editVendor}
  795. onChangeText={setEditVendor}
  796. placeholder={t('costs.vendorPlaceholder')}
  797. style={inputStyle}
  798. />
  799. <ThemedText>{t('costs.date')}</ThemedText>
  800. <Pressable onPress={() => setShowEditSpentPicker(true)} style={styles.dateInput}>
  801. <ThemedText style={styles.dateValue}>
  802. {editSpentDate ? formatDateLabel(editSpentDate) : t('costs.datePlaceholder')}
  803. </ThemedText>
  804. </Pressable>
  805. {showEditSpentPicker ? (
  806. <>
  807. {Platform.OS === 'ios' ? (
  808. <View style={styles.pickerRow}>
  809. <ThemedButton
  810. title={t('crops.today')}
  811. onPress={() => {
  812. setEditSpentDate(toDateOnly(new Date()));
  813. setShowEditSpentPicker(false);
  814. }}
  815. variant="secondary"
  816. />
  817. <ThemedButton
  818. title={t('crops.done')}
  819. onPress={() => setShowEditSpentPicker(false)}
  820. variant="secondary"
  821. />
  822. </View>
  823. ) : null}
  824. <DateTimePicker
  825. value={editSpentDate ? new Date(editSpentDate) : new Date()}
  826. mode="date"
  827. display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
  828. onChange={(event, date) => {
  829. if (date) setEditSpentDate(toDateOnly(date));
  830. if (Platform.OS !== 'ios') setShowEditSpentPicker(false);
  831. }}
  832. />
  833. </>
  834. ) : null}
  835. <ThemedText>{t('costs.notes')}</ThemedText>
  836. <TextInput
  837. value={editNotes}
  838. onChangeText={setEditNotes}
  839. placeholder={t('costs.notesPlaceholder')}
  840. style={inputStyle}
  841. multiline
  842. />
  843. <ThemedText>{t('costs.addMedia')}</ThemedText>
  844. {normalizeMediaUri(editPhotoUri) ? (
  845. isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
  846. <Video
  847. source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
  848. style={styles.videoPreview}
  849. useNativeControls
  850. resizeMode={ResizeMode.CONTAIN}
  851. isMuted
  852. />
  853. ) : (
  854. <Pressable
  855. onPress={() => {
  856. setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
  857. setEditModalOpen(false);
  858. }}>
  859. <Image
  860. source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
  861. style={styles.photoPreview}
  862. resizeMode="contain"
  863. />
  864. </Pressable>
  865. )
  866. ) : (
  867. <ThemedText style={styles.photoPlaceholder}>{t('costs.noPhoto')}</ThemedText>
  868. )}
  869. <View style={styles.photoRow}>
  870. <ThemedButton
  871. title={t('costs.pickFromGallery')}
  872. onPress={() => handlePickPhoto(setEditPhotoUri)}
  873. variant="secondary"
  874. />
  875. <ThemedButton
  876. title={t('costs.takeMedia')}
  877. onPress={() =>
  878. handleTakePhoto(setEditPhotoUri, (code) =>
  879. setStatus(
  880. code === 'cameraDenied'
  881. ? t('tasks.cameraDenied')
  882. : t('tasks.cameraError')
  883. )
  884. )
  885. }
  886. variant="secondary"
  887. />
  888. </View>
  889. <View style={styles.modalActions}>
  890. <ThemedButton
  891. title={t('costs.cancel')}
  892. onPress={cancelEdit}
  893. variant="secondary"
  894. />
  895. <ThemedButton title={t('costs.update')} onPress={handleUpdate} />
  896. </View>
  897. <View style={styles.sheetFooter} />
  898. </View>
  899. )}
  900. />
  901. </View>
  902. </KeyboardAvoidingView>
  903. </View>
  904. </Modal>
  905. <ZoomImageModal
  906. uri={zoomUri}
  907. visible={Boolean(zoomUri)}
  908. onClose={() => setZoomUri(null)}
  909. />
  910. </>
  911. );
  912. }
  913. function formatDate(value: string) {
  914. try {
  915. return new Date(value).toLocaleString();
  916. } catch {
  917. return value;
  918. }
  919. }
  920. function formatDateLabel(value: string) {
  921. try {
  922. return new Date(value).toISOString().slice(0, 10);
  923. } catch {
  924. return value;
  925. }
  926. }
  927. function toDateOnly(date: Date) {
  928. return date.toISOString().slice(0, 10);
  929. }
  930. async function handlePickPhoto(setter: (value: string | null) => void) {
  931. const result = await ImagePicker.launchImageLibraryAsync({
  932. mediaTypes: getMediaTypes(),
  933. quality: 1,
  934. });
  935. if (result.canceled) return;
  936. const asset = result.assets[0];
  937. setter(asset.uri);
  938. }
  939. async function handleTakePhoto(
  940. setter: (value: string | null) => void,
  941. onError?: (msg: string) => void
  942. ) {
  943. try {
  944. const permission = await ImagePicker.requestCameraPermissionsAsync();
  945. if (!permission.granted) {
  946. onError?.('cameraDenied');
  947. return;
  948. }
  949. const result = await ImagePicker.launchCameraAsync({
  950. mediaTypes: getMediaTypes(),
  951. quality: 1,
  952. });
  953. if (result.canceled) return;
  954. const asset = result.assets[0];
  955. setter(asset.uri);
  956. } catch {
  957. onError?.('cameraError');
  958. }
  959. }
  960. function getMediaTypes() {
  961. const mediaType = (ImagePicker as {
  962. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  963. }).MediaType;
  964. const imageType = mediaType?.Image ?? mediaType?.Images;
  965. const videoType = mediaType?.Video ?? mediaType?.Videos;
  966. if (imageType && videoType) {
  967. return [imageType, videoType];
  968. }
  969. return imageType ?? videoType ?? ['images', 'videos'];
  970. }
  971. function isVideoUri(uri: string) {
  972. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  973. }
  974. function normalizeMediaUri(uri?: string | null) {
  975. if (typeof uri !== 'string') return null;
  976. const trimmed = uri.trim();
  977. return trimmed ? trimmed : null;
  978. }
  979. const styles = StyleSheet.create({
  980. hero: {
  981. backgroundColor: '#E8E6DA',
  982. aspectRatio: 16 / 9,
  983. width: '100%',
  984. },
  985. heroImage: {
  986. width: '100%',
  987. height: '100%',
  988. },
  989. titleContainer: {
  990. gap: 8,
  991. paddingHorizontal: 16,
  992. paddingVertical: 12,
  993. },
  994. section: {
  995. gap: 8,
  996. marginBottom: 16,
  997. paddingHorizontal: 16,
  998. },
  999. newButton: {
  1000. flexDirection: 'row',
  1001. alignItems: 'center',
  1002. gap: 8,
  1003. borderRadius: 10,
  1004. borderWidth: 1,
  1005. borderColor: '#B9B9B9',
  1006. paddingHorizontal: 12,
  1007. paddingVertical: 10,
  1008. alignSelf: 'flex-start',
  1009. },
  1010. newButtonText: {
  1011. fontSize: 15,
  1012. fontWeight: '600',
  1013. },
  1014. card: {
  1015. borderRadius: 12,
  1016. borderWidth: 1,
  1017. borderColor: '#C6C6C6',
  1018. padding: 12,
  1019. marginHorizontal: 16,
  1020. gap: 6,
  1021. backgroundColor: '#FFFFFF',
  1022. },
  1023. cardHeader: {
  1024. flexDirection: 'row',
  1025. alignItems: 'center',
  1026. justifyContent: 'space-between',
  1027. gap: 8,
  1028. },
  1029. meta: {
  1030. opacity: 0.7,
  1031. },
  1032. separator: {
  1033. height: 12,
  1034. },
  1035. footer: {
  1036. height: 24,
  1037. },
  1038. modalOverlay: {
  1039. flex: 1,
  1040. backgroundColor: 'rgba(0,0,0,0.3)',
  1041. justifyContent: 'center',
  1042. padding: 16,
  1043. },
  1044. modalCard: {
  1045. borderRadius: 12,
  1046. borderWidth: 1,
  1047. borderColor: '#C6C6C6',
  1048. padding: 16,
  1049. backgroundColor: '#FFFFFF',
  1050. gap: 8,
  1051. maxHeight: '70%',
  1052. },
  1053. modalItem: {
  1054. paddingVertical: 8,
  1055. },
  1056. input: {
  1057. borderRadius: 10,
  1058. borderWidth: 1,
  1059. borderColor: '#B9B9B9',
  1060. paddingHorizontal: 12,
  1061. paddingVertical: 10,
  1062. fontSize: 15,
  1063. },
  1064. modalActions: {
  1065. flexDirection: 'row',
  1066. justifyContent: 'space-between',
  1067. gap: 12,
  1068. },
  1069. sheetOverlay: {
  1070. flex: 1,
  1071. backgroundColor: 'rgba(0,0,0,0.3)',
  1072. justifyContent: 'flex-end',
  1073. },
  1074. sheetBackdrop: {
  1075. ...StyleSheet.absoluteFillObject,
  1076. },
  1077. sheet: {
  1078. borderTopLeftRadius: 16,
  1079. borderTopRightRadius: 16,
  1080. borderWidth: 1,
  1081. borderColor: '#C6C6C6',
  1082. padding: 16,
  1083. backgroundColor: '#FFFFFF',
  1084. gap: 10,
  1085. maxHeight: '85%',
  1086. },
  1087. sheetContent: {
  1088. gap: 10,
  1089. },
  1090. sheetListContent: {
  1091. paddingBottom: 80,
  1092. },
  1093. sheetFooter: {
  1094. height: 24,
  1095. },
  1096. keyboardAvoid: {
  1097. width: '100%',
  1098. flex: 1,
  1099. justifyContent: 'flex-end',
  1100. },
  1101. photoRow: {
  1102. flexDirection: 'row',
  1103. gap: 12,
  1104. },
  1105. photoPreview: {
  1106. width: '100%',
  1107. height: 200,
  1108. borderRadius: 12,
  1109. },
  1110. listPhoto: {
  1111. width: '100%',
  1112. height: 160,
  1113. borderRadius: 10,
  1114. },
  1115. videoPreview: {
  1116. width: '100%',
  1117. height: 200,
  1118. borderRadius: 12,
  1119. backgroundColor: '#1C1C1C',
  1120. },
  1121. photoPlaceholder: {
  1122. opacity: 0.6,
  1123. },
  1124. dateInput: {
  1125. borderRadius: 10,
  1126. borderWidth: 1,
  1127. borderColor: '#B9B9B9',
  1128. paddingHorizontal: 12,
  1129. paddingVertical: 10,
  1130. },
  1131. dateValue: {
  1132. fontSize: 15,
  1133. opacity: 0.9,
  1134. },
  1135. pickerRow: {
  1136. flexDirection: 'row',
  1137. gap: 8,
  1138. },
  1139. chipRow: {
  1140. flexDirection: 'row',
  1141. flexWrap: 'wrap',
  1142. gap: 8,
  1143. },
  1144. chip: {
  1145. borderRadius: 999,
  1146. borderWidth: 1,
  1147. borderColor: '#C6C6C6',
  1148. paddingHorizontal: 10,
  1149. paddingVertical: 4,
  1150. },
  1151. chipActive: {
  1152. borderColor: '#2F7D4F',
  1153. backgroundColor: '#E7F3EA',
  1154. },
  1155. chipText: {
  1156. fontSize: 12,
  1157. },
  1158. chipTextActive: {
  1159. fontSize: 12,
  1160. color: '#2F7D4F',
  1161. fontWeight: '600',
  1162. },
  1163. requiredMark: {
  1164. color: '#C0392B',
  1165. fontWeight: '700',
  1166. },
  1167. errorText: {
  1168. color: '#C0392B',
  1169. fontSize: 12,
  1170. },
  1171. });