Нема описа

new.tsx 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Image,
  4. KeyboardAvoidingView,
  5. Modal,
  6. Platform,
  7. Pressable,
  8. ScrollView,
  9. StyleSheet,
  10. TextInput,
  11. View,
  12. } from 'react-native';
  13. import * as ImagePicker from 'expo-image-picker';
  14. import DateTimePicker from '@react-native-community/datetimepicker';
  15. import { ResizeMode, Video } from 'expo-av';
  16. import { useRouter } from 'expo-router';
  17. import { ThemedButton } from '@/components/themed-button';
  18. import { ThemedText } from '@/components/themed-text';
  19. import { ThemedView } from '@/components/themed-view';
  20. import { ZoomImageModal } from '@/components/zoom-image-modal';
  21. import { Colors } from '@/constants/theme';
  22. import { useColorScheme } from '@/hooks/use-color-scheme';
  23. import { useTranslation } from '@/localization/i18n';
  24. import { dbPromise, initCoreTables } from '@/services/db';
  25. const CATEGORY_PRESETS = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc'];
  26. type FieldRow = {
  27. id: number;
  28. name: string | null;
  29. };
  30. type CropRow = {
  31. id: number;
  32. crop_name: string | null;
  33. };
  34. export default function NewCostScreen() {
  35. const { t } = useTranslation();
  36. const router = useRouter();
  37. const theme = useColorScheme() ?? 'light';
  38. const palette = Colors[theme];
  39. const [status, setStatus] = useState('');
  40. const [fields, setFields] = useState<FieldRow[]>([]);
  41. const [crops, setCrops] = useState<CropRow[]>([]);
  42. const [currency, setCurrency] = useState('THB');
  43. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  44. const [cropModalOpen, setCropModalOpen] = useState(false);
  45. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  46. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  47. const [category, setCategory] = useState('');
  48. const [amount, setAmount] = useState('');
  49. const [vendor, setVendor] = useState('');
  50. const [notes, setNotes] = useState('');
  51. const [spentDate, setSpentDate] = useState('');
  52. const [showSpentPicker, setShowSpentPicker] = useState(false);
  53. const [mediaUris, setMediaUris] = useState<string[]>([]);
  54. const [activeUri, setActiveUri] = useState<string | null>(null);
  55. const [errors, setErrors] = useState<{ field?: string; amount?: string }>({});
  56. const [zoomUri, setZoomUri] = useState<string | null>(null);
  57. const [saving, setSaving] = useState(false);
  58. useEffect(() => {
  59. let isActive = true;
  60. async function loadData() {
  61. try {
  62. await initCoreTables();
  63. const db = await dbPromise;
  64. const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
  65. const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
  66. const profileRow = await db.getFirstAsync<{ currency: string | null }>(
  67. 'SELECT currency FROM user_profile WHERE id = 1;'
  68. );
  69. if (!isActive) return;
  70. setFields(fieldRows);
  71. setCrops(cropRows);
  72. setCurrency(profileRow?.currency ?? 'THB');
  73. } catch (error) {
  74. if (isActive) setStatus(`Error: ${String(error)}`);
  75. }
  76. }
  77. loadData();
  78. return () => {
  79. isActive = false;
  80. };
  81. }, [t]);
  82. const selectedField = useMemo(
  83. () => fields.find((item) => item.id === selectedFieldId),
  84. [fields, selectedFieldId]
  85. );
  86. const selectedCrop = useMemo(
  87. () => crops.find((item) => item.id === selectedCropId),
  88. [crops, selectedCropId]
  89. );
  90. const inputStyle = [
  91. styles.input,
  92. {
  93. borderColor: palette.border,
  94. backgroundColor: palette.input,
  95. color: palette.text,
  96. },
  97. ];
  98. async function handleSave() {
  99. const parsedAmount = amount.trim() ? Number(amount) : null;
  100. const nextErrors: { field?: string; amount?: string } = {};
  101. if (!selectedFieldId) nextErrors.field = t('costs.fieldRequired');
  102. if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid');
  103. setErrors(nextErrors);
  104. if (Object.keys(nextErrors).length > 0) return;
  105. try {
  106. setSaving(true);
  107. const db = await dbPromise;
  108. const now = new Date().toISOString();
  109. const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
  110. const result = await db.runAsync(
  111. 'INSERT INTO costs (field_id, crop_id, category, amount, currency, vendor, notes, spent_at, photo_uri, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
  112. selectedFieldId,
  113. selectedCropId,
  114. category.trim() || null,
  115. parsedAmount,
  116. currency,
  117. vendor.trim() || null,
  118. notes.trim() || null,
  119. spentDate || null,
  120. primaryUri ?? null,
  121. now
  122. );
  123. const costId = Number(result.lastInsertRowId);
  124. if (costId) {
  125. const mediaToInsert = uniqueMediaUris([
  126. ...mediaUris,
  127. ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
  128. ]);
  129. for (const uri of mediaToInsert) {
  130. await db.runAsync(
  131. 'INSERT INTO cost_media (cost_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  132. costId,
  133. uri,
  134. isVideoUri(uri) ? 'video' : 'image',
  135. now
  136. );
  137. }
  138. }
  139. setStatus(t('costs.saved'));
  140. router.back();
  141. } catch (error) {
  142. setStatus(`Error: ${String(error)}`);
  143. } finally {
  144. setSaving(false);
  145. }
  146. }
  147. return (
  148. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  149. <KeyboardAvoidingView
  150. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  151. style={styles.keyboardAvoid}>
  152. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  153. <ThemedText type="title">{t('costs.new')}</ThemedText>
  154. {status ? <ThemedText>{status}</ThemedText> : null}
  155. <ThemedText>
  156. {t('costs.field')}
  157. <ThemedText style={styles.requiredMark}> *</ThemedText>
  158. </ThemedText>
  159. <ThemedButton
  160. title={selectedField?.name || t('costs.selectField')}
  161. onPress={() => setFieldModalOpen(true)}
  162. variant="secondary"
  163. />
  164. {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
  165. <ThemedText>{t('costs.crop')}</ThemedText>
  166. <ThemedButton
  167. title={selectedCrop?.crop_name || t('costs.selectCrop')}
  168. onPress={() => setCropModalOpen(true)}
  169. variant="secondary"
  170. />
  171. <ThemedText>{t('costs.category')}</ThemedText>
  172. <View style={styles.chipRow}>
  173. {CATEGORY_PRESETS.map((preset) => {
  174. const label = t(`costs.category.${preset}`);
  175. const isActive = label === category || preset === category;
  176. return (
  177. <Pressable
  178. key={preset}
  179. style={[styles.chip, isActive ? styles.chipActive : null]}
  180. onPress={() => setCategory(label)}>
  181. <ThemedText style={styles.chipText}>{label}</ThemedText>
  182. </Pressable>
  183. );
  184. })}
  185. </View>
  186. <TextInput
  187. value={category}
  188. onChangeText={setCategory}
  189. placeholder={t('costs.categoryPlaceholder')}
  190. placeholderTextColor={palette.placeholder}
  191. style={inputStyle}
  192. />
  193. <ThemedText>
  194. {t('costs.amount')} ({currency})
  195. <ThemedText style={styles.requiredMark}> *</ThemedText>
  196. </ThemedText>
  197. <TextInput
  198. value={amount}
  199. onChangeText={(value) => {
  200. setAmount(value);
  201. if (errors.amount) setErrors((prev) => ({ ...prev, amount: undefined }));
  202. }}
  203. placeholder={t('costs.amountPlaceholder')}
  204. placeholderTextColor={palette.placeholder}
  205. style={inputStyle}
  206. keyboardType="decimal-pad"
  207. />
  208. {errors.amount ? <ThemedText style={styles.errorText}>{errors.amount}</ThemedText> : null}
  209. <ThemedText>{t('costs.vendor')}</ThemedText>
  210. <TextInput
  211. value={vendor}
  212. onChangeText={setVendor}
  213. placeholder={t('costs.vendorPlaceholder')}
  214. placeholderTextColor={palette.placeholder}
  215. style={inputStyle}
  216. />
  217. <ThemedText>{t('costs.date')}</ThemedText>
  218. <Pressable onPress={() => setShowSpentPicker(true)} style={styles.dateInput}>
  219. <ThemedText style={styles.dateValue}>
  220. {spentDate || t('costs.datePlaceholder')}
  221. </ThemedText>
  222. </Pressable>
  223. {showSpentPicker ? (
  224. <DateTimePicker
  225. value={spentDate ? new Date(spentDate) : new Date()}
  226. mode="date"
  227. onChange={(event, date) => {
  228. setShowSpentPicker(false);
  229. if (date) setSpentDate(toDateOnly(date));
  230. }}
  231. />
  232. ) : null}
  233. <ThemedText>{t('costs.notes')}</ThemedText>
  234. <TextInput
  235. value={notes}
  236. onChangeText={setNotes}
  237. placeholder={t('costs.notesPlaceholder')}
  238. placeholderTextColor={palette.placeholder}
  239. style={[inputStyle, styles.multiline]}
  240. multiline
  241. />
  242. <ThemedText>{t('costs.addMedia')}</ThemedText>
  243. {normalizeMediaUri(activeUri) ? (
  244. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  245. <Video
  246. source={{ uri: normalizeMediaUri(activeUri) as string }}
  247. style={styles.mediaPreview}
  248. useNativeControls
  249. resizeMode={ResizeMode.CONTAIN}
  250. />
  251. ) : (
  252. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  253. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  254. </Pressable>
  255. )
  256. ) : (
  257. <ThemedView style={[styles.mediaPlaceholder, { borderColor: palette.border }]}>
  258. <ThemedText style={styles.mediaPlaceholderText}>{t('costs.noPhoto')}</ThemedText>
  259. </ThemedView>
  260. )}
  261. {mediaUris.length > 0 ? (
  262. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaRow}>
  263. {mediaUris.map((uri) => (
  264. <Pressable
  265. key={uri}
  266. style={styles.mediaThumbWrap}
  267. onPress={() => setActiveUri(uri)}>
  268. {isVideoUri(uri) ? (
  269. <Video
  270. source={{ uri }}
  271. style={styles.mediaThumb}
  272. resizeMode={ResizeMode.COVER}
  273. isMuted
  274. />
  275. ) : (
  276. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  277. )}
  278. <Pressable
  279. style={styles.mediaRemove}
  280. onPress={() => {
  281. setMediaUris((prev) => {
  282. const next = prev.filter((item) => item !== uri);
  283. if (activeUri === uri) setActiveUri(next[0] ?? null);
  284. return next;
  285. });
  286. }}>
  287. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  288. </Pressable>
  289. </Pressable>
  290. ))}
  291. </ScrollView>
  292. ) : null}
  293. <View style={styles.mediaButtons}>
  294. <ThemedButton
  295. title={t('costs.pickFromGallery')}
  296. onPress={() =>
  297. handlePickMedia((uris) => {
  298. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  299. if (!activeUri && uris[0]) setActiveUri(uris[0]);
  300. })
  301. }
  302. variant="secondary"
  303. />
  304. <ThemedButton
  305. title={t('costs.takeMedia')}
  306. onPress={() =>
  307. handleTakeMedia((uri) => {
  308. if (!uri) return;
  309. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  310. if (!activeUri) setActiveUri(uri);
  311. })
  312. }
  313. variant="secondary"
  314. />
  315. </View>
  316. <View style={styles.actions}>
  317. <ThemedButton title={t('costs.cancel')} onPress={() => router.back()} variant="ghost" />
  318. <ThemedButton title={saving ? t('costs.saving') : t('costs.save')} onPress={handleSave} />
  319. </View>
  320. </ScrollView>
  321. </KeyboardAvoidingView>
  322. <Modal transparent visible={fieldModalOpen} animationType="fade">
  323. <Pressable style={styles.modalOverlay} onPress={() => setFieldModalOpen(false)}>
  324. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  325. <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
  326. <ScrollView>
  327. {fields.map((item) => (
  328. <Pressable
  329. key={item.id}
  330. onPress={() => {
  331. setSelectedFieldId(item.id);
  332. setErrors((prev) => ({ ...prev, field: undefined }));
  333. setFieldModalOpen(false);
  334. }}
  335. style={styles.modalItem}>
  336. <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
  337. </Pressable>
  338. ))}
  339. </ScrollView>
  340. </View>
  341. </Pressable>
  342. </Modal>
  343. <Modal transparent visible={cropModalOpen} animationType="fade">
  344. <Pressable style={styles.modalOverlay} onPress={() => setCropModalOpen(false)}>
  345. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  346. <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
  347. <ScrollView>
  348. {crops.map((item) => (
  349. <Pressable
  350. key={item.id}
  351. onPress={() => {
  352. setSelectedCropId(item.id);
  353. setCropModalOpen(false);
  354. }}
  355. style={styles.modalItem}>
  356. <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
  357. </Pressable>
  358. ))}
  359. </ScrollView>
  360. </View>
  361. </Pressable>
  362. </Modal>
  363. <ZoomImageModal visible={Boolean(zoomUri)} uri={zoomUri} onClose={() => setZoomUri(null)} />
  364. </ThemedView>
  365. );
  366. }
  367. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  368. const pickerTypes =
  369. (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
  370. (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
  371. undefined;
  372. const mediaTypes =
  373. pickerTypes?.Images && pickerTypes?.Videos
  374. ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
  375. : ['images', 'videos'];
  376. const result = await ImagePicker.launchImageLibraryAsync({
  377. mediaTypes,
  378. quality: 1,
  379. allowsMultipleSelection: true,
  380. });
  381. if (result.canceled) return;
  382. const uris = result.assets.map((asset) => asset.uri).filter(Boolean);
  383. if (uris.length > 0) onAdd(uris);
  384. }
  385. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  386. const pickerTypes =
  387. (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
  388. (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
  389. undefined;
  390. const mediaTypes =
  391. pickerTypes?.Images && pickerTypes?.Videos
  392. ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
  393. : ['images', 'videos'];
  394. const result = await ImagePicker.launchCameraAsync({
  395. mediaTypes,
  396. quality: 1,
  397. });
  398. if (result.canceled) return;
  399. onAdd(result.assets[0]?.uri ?? null);
  400. }
  401. function isVideoUri(uri: string) {
  402. const cleaned = uri.split('?')[0]?.toLowerCase() ?? '';
  403. return ['.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'].some((ext) => cleaned.endsWith(ext));
  404. }
  405. function normalizeMediaUri(uri?: string | null) {
  406. if (!uri) return null;
  407. if (uri.startsWith('file://') || uri.startsWith('http://') || uri.startsWith('https://')) return uri;
  408. return `file://${uri}`;
  409. }
  410. function uniqueMediaUris(uris: string[]) {
  411. return Array.from(new Set(uris.filter(Boolean)));
  412. }
  413. function toDateOnly(date: Date) {
  414. const year = date.getFullYear();
  415. const month = String(date.getMonth() + 1).padStart(2, '0');
  416. const day = String(date.getDate()).padStart(2, '0');
  417. return `${year}-${month}-${day}`;
  418. }
  419. const styles = StyleSheet.create({
  420. container: { flex: 1 },
  421. keyboardAvoid: { flex: 1 },
  422. content: { padding: 16, paddingBottom: 40 },
  423. input: {
  424. borderWidth: 1,
  425. borderRadius: 12,
  426. paddingHorizontal: 12,
  427. paddingVertical: 10,
  428. marginTop: 6,
  429. marginBottom: 12,
  430. fontSize: 16,
  431. },
  432. multiline: { minHeight: 90, textAlignVertical: 'top' },
  433. errorText: { color: '#C0392B', marginBottom: 8 },
  434. requiredMark: { color: '#C0392B' },
  435. dateInput: {
  436. borderWidth: 1,
  437. borderRadius: 12,
  438. paddingHorizontal: 12,
  439. paddingVertical: 12,
  440. marginTop: 6,
  441. marginBottom: 12,
  442. },
  443. dateValue: { fontSize: 16 },
  444. chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 },
  445. chip: {
  446. paddingHorizontal: 12,
  447. paddingVertical: 6,
  448. borderRadius: 999,
  449. borderWidth: 1,
  450. borderColor: '#C9D2C4',
  451. backgroundColor: '#F8F6F0',
  452. },
  453. chipActive: { backgroundColor: '#DDE8DA', borderColor: '#88A68F' },
  454. chipText: { fontSize: 13 },
  455. mediaPreview: {
  456. width: '100%',
  457. height: 200,
  458. borderRadius: 16,
  459. backgroundColor: '#F1F1F1',
  460. marginBottom: 12,
  461. },
  462. mediaPlaceholder: {
  463. width: '100%',
  464. height: 200,
  465. borderRadius: 16,
  466. borderWidth: 1,
  467. justifyContent: 'center',
  468. alignItems: 'center',
  469. marginBottom: 12,
  470. },
  471. mediaPlaceholderText: { opacity: 0.7 },
  472. mediaRow: { marginBottom: 12 },
  473. mediaThumbWrap: { marginRight: 8 },
  474. mediaThumb: { width: 72, height: 72, borderRadius: 12 },
  475. mediaRemove: {
  476. position: 'absolute',
  477. top: -6,
  478. right: -6,
  479. width: 22,
  480. height: 22,
  481. borderRadius: 11,
  482. backgroundColor: '#2C2C2C',
  483. justifyContent: 'center',
  484. alignItems: 'center',
  485. },
  486. mediaRemoveText: { color: '#fff', fontSize: 14, lineHeight: 16 },
  487. mediaButtons: { flexDirection: 'row', gap: 12, flexWrap: 'wrap', marginBottom: 16 },
  488. actions: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
  489. modalOverlay: {
  490. flex: 1,
  491. backgroundColor: 'rgba(0,0,0,0.4)',
  492. justifyContent: 'center',
  493. padding: 20,
  494. },
  495. modalCard: {
  496. borderRadius: 16,
  497. borderWidth: 1,
  498. padding: 16,
  499. maxHeight: '70%',
  500. },
  501. modalItem: { paddingVertical: 10 },
  502. });