Aucune description

index.tsx 43KB

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