Нет описания

costs.tsx 37KB

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