index.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. import React, { useEffect, useState } from 'react';
  2. import { View, Text, TouchableOpacity, ScrollView, Alert } from 'react-native';
  3. import { useNavigation } from '@react-navigation/native';
  4. import moment from 'moment';
  5. import { PageWrapper, Header, Input, WarningModal } from 'src/components';
  6. import RegionItem from '../Components/RegionItemNew';
  7. import RangeCalendar from 'src/components/Calendars/RangeCalendar';
  8. import { StoreType, storage } from 'src/storage';
  9. import { Colors } from 'src/theme';
  10. import { NAVIGATION_PAGES } from 'src/types';
  11. import { RegionAddData } from '../utils/types';
  12. import {
  13. useGetTripQuery,
  14. usePostDeleteTripMutation,
  15. usePostUpdateTripMutation,
  16. usePostSetNewTripMutation
  17. } from '@api/trips';
  18. import { styles } from './styles';
  19. import { ActivityIndicator } from 'react-native-paper';
  20. interface DateValue {
  21. year: number | null;
  22. month: number | null;
  23. day: number | null;
  24. }
  25. interface RegionWithDates extends RegionAddData {
  26. _instanceId?: string;
  27. visitStartDate?: DateValue | null;
  28. visitEndDate?: DateValue | null;
  29. year_from?: number;
  30. year_to?: number;
  31. month_from?: number;
  32. month_to?: number;
  33. day_from?: number | null;
  34. day_to?: number | null;
  35. }
  36. const AddNewTripScreen = ({ route }: { route: any }) => {
  37. const editTripId = route.params?.editTripId ?? null;
  38. const token = storage.get('token', StoreType.STRING) as string;
  39. const { data: editData } = useGetTripQuery(token, editTripId, Boolean(editTripId));
  40. const navigation = useNavigation();
  41. const [description, setDescription] = useState<string>('');
  42. const [regions, setRegions] = useState<RegionWithDates[] | null>(null);
  43. const [disabled, setDisabled] = useState(true);
  44. const [isLoading, setIsLoading] = useState<string | null>(null);
  45. const [isWarningModalVisible, setIsWarningModalVisible] = useState(false);
  46. const [pendingDelete, setPendingDelete] = useState(false);
  47. const [calendarVisibleForIndex, setCalendarVisibleForIndex] = useState<number | null>(null);
  48. const [regionErrors, setRegionErrors] = useState<{ [instanceId: string]: string }>({});
  49. const { mutate: saveNewTrip } = usePostSetNewTripMutation();
  50. const { mutate: updateTrip } = usePostUpdateTripMutation();
  51. const { mutate: deleteTrip } = usePostDeleteTripMutation();
  52. const scrollRef = React.useRef<ScrollView>(null);
  53. const instanceCounterRef = React.useRef(1);
  54. const scrollToError = (errors: { [instanceId: string]: string }) => {
  55. if (!regions || !Object.keys(errors).length) return;
  56. const firstInstanceId = Object.keys(errors)[0];
  57. const idx = regions.findIndex((r) => r._instanceId === firstInstanceId);
  58. if (idx === -1) return;
  59. const itemHeight = 160;
  60. scrollRef.current?.scrollTo({ y: idx * itemHeight, animated: true });
  61. };
  62. const autoFillAfterAppend = (list: RegionWithDates[]) => {
  63. if (!list || list.length === 0) return list;
  64. const updated = [...list];
  65. let lastWithDateIndex: number | null = null;
  66. for (let i = updated.length - 1; i >= 0; i--) {
  67. const r = updated[i];
  68. r._instanceId = `r-${instanceCounterRef.current++}`;
  69. if (
  70. r.visitStartDate?.year &&
  71. r.visitStartDate?.month &&
  72. r.visitStartDate?.day &&
  73. r.visitEndDate?.year &&
  74. r.visitEndDate?.month &&
  75. r.visitEndDate?.day
  76. ) {
  77. lastWithDateIndex = i;
  78. break;
  79. }
  80. }
  81. if (lastWithDateIndex === null) return updated;
  82. const lastDate: DateValue = updated[lastWithDateIndex].visitEndDate as DateValue;
  83. for (let i = lastWithDateIndex + 1; i < updated.length; i++) {
  84. const r = updated[i];
  85. const hasStart = !!(
  86. r.visitStartDate?.year &&
  87. r.visitStartDate?.month &&
  88. r.visitStartDate?.day
  89. );
  90. const hasEnd = !!(r.visitEndDate?.year && r.visitEndDate?.month && r.visitEndDate?.day);
  91. if (!hasStart || !hasEnd) {
  92. updated[i] = {
  93. ...r,
  94. _instanceId: `r-${instanceCounterRef.current++}`,
  95. visitStartDate: lastDate,
  96. visitEndDate: lastDate
  97. };
  98. break;
  99. }
  100. }
  101. return updated;
  102. };
  103. useEffect(() => {
  104. if (route.params?.regionsToSave) {
  105. const filled = autoFillAfterAppend(route.params.regionsToSave);
  106. setRegions(filled);
  107. }
  108. }, [route.params?.regionsToSave]);
  109. function extractNumberAndExtension(path: string | null) {
  110. if (!path) return null;
  111. const slashIndex = path.lastIndexOf('/');
  112. return path.substring(slashIndex + 1);
  113. }
  114. useEffect(() => {
  115. if (editData && editData.trip) {
  116. setDescription(editData.trip.description);
  117. setRegions(
  118. editData.trip.regions.map((region: any) => {
  119. const instanceId = `r-${instanceCounterRef.current++}`;
  120. return {
  121. ...region,
  122. _instanceId: instanceId,
  123. id: region.region,
  124. flag1: extractNumberAndExtension(region.flag1),
  125. flag2: extractNumberAndExtension(region.flag2),
  126. visitStartDate: {
  127. year: region.year_from || null,
  128. month: region.month_from || null,
  129. day: region.day_from || null
  130. },
  131. visitEndDate: {
  132. year: region.year_to || null,
  133. month: region.month_to || null,
  134. day: region.day_to || null
  135. }
  136. };
  137. })
  138. );
  139. }
  140. }, [editData]);
  141. useEffect(() => {
  142. setDisabled(!regions?.length);
  143. }, [regions]);
  144. const formatDateForDisplay = (d?: DateValue | null) => {
  145. if (!d || !d.year) return 'Select dates';
  146. const m = moment(`${d.year}-${d.month}-${d.day}`, 'YYYY-M-D');
  147. return m.format('D MMM YYYY');
  148. };
  149. const dateValueToISO = (d?: DateValue | null) => {
  150. if (!d || !d.year) return null;
  151. const mm = String(d.month).padStart(2, '0');
  152. const dd = String(d.day).padStart(2, '0');
  153. return `${d.year}-${mm}-${dd}`;
  154. };
  155. const parseISOToDateValue = (iso?: string | null): DateValue | null => {
  156. if (!iso) return null;
  157. const m = moment(iso, 'YYYY-MM-DD');
  158. if (!m.isValid()) return null;
  159. return { year: m.year(), month: m.month() + 1, day: m.date() };
  160. };
  161. const validateRegionsDates = (regionsToValidate?: RegionWithDates[] | null) => {
  162. const list = regionsToValidate ?? regions;
  163. const errors: { [instanceId: string]: string } = {};
  164. if (!list || list.length === 0) {
  165. setRegionErrors(errors);
  166. return false;
  167. }
  168. for (let i = 0; i < list.length; i++) {
  169. const r = list[i];
  170. const s = r.visitStartDate;
  171. const e = r.visitEndDate;
  172. const id = r._instanceId ?? `idx-${i}`;
  173. if (!s?.year || !s?.month || !s?.day) {
  174. errors[id] = 'Please select visit dates';
  175. continue;
  176. }
  177. if (!e?.year || !e?.month || !e?.day) {
  178. errors[id] = 'Please select visit dates';
  179. continue;
  180. }
  181. const sM = moment(`${s.year}-${s.month}-${s.day}`, 'YYYY-M-D');
  182. const eM = moment(`${e.year}-${e.month}-${e.day}`, 'YYYY-M-D');
  183. if (sM.isAfter(eM)) {
  184. errors[id] = 'Start date cannot be after end date';
  185. continue;
  186. }
  187. // if (i > 0) {
  188. // const prevEnd = list[i - 1]?.visitEndDate;
  189. // if (prevEnd?.year) {
  190. // const prevEndM = moment(`${prevEnd.year}-${prevEnd.month}-${prevEnd.day}`, 'YYYY-M-D');
  191. // if (sM.isBefore(prevEndM)) {
  192. // errors[i] = 'This region must start before previous';
  193. // continue;
  194. // }
  195. // }
  196. // }
  197. }
  198. setRegionErrors(errors);
  199. return Object.keys(errors).length === 0;
  200. };
  201. const isFullDate = (d?: DateValue | null) => {
  202. return !!(d?.year && d?.month && d?.day);
  203. };
  204. const openRangeCalendarForRegion = (index: number) => {
  205. if (!regions) return;
  206. setCalendarVisibleForIndex(index);
  207. };
  208. const closeRangeCalendar = (startDate?: string | null, endDate?: string | null) => {
  209. const clickedInstanceIndex = calendarVisibleForIndex;
  210. setCalendarVisibleForIndex(null);
  211. if (clickedInstanceIndex === null || clickedInstanceIndex === undefined || !startDate) return;
  212. const startVal = parseISOToDateValue(startDate);
  213. const endVal = parseISOToDateValue(endDate ?? startDate);
  214. if (!startVal || !endVal) return;
  215. const openedInstanceId = regions?.[clickedInstanceIndex]?._instanceId;
  216. if (!openedInstanceId) {
  217. return;
  218. }
  219. const updatedBeforeSort = (regions ?? []).map((r) =>
  220. r._instanceId === openedInstanceId
  221. ? { ...r, visitStartDate: startVal, visitEndDate: endVal }
  222. : r
  223. );
  224. const sortKey = (r: RegionWithDates) => {
  225. const iso = dateValueToISO(r.visitStartDate);
  226. return iso ?? '9999-12-31';
  227. };
  228. const sorted = [...updatedBeforeSort].sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
  229. const newIndex = sorted.findIndex((r) => r._instanceId === openedInstanceId);
  230. if (newIndex !== -1) {
  231. const next = sorted[newIndex + 1];
  232. if (next && !isFullDate(next.visitStartDate)) {
  233. sorted[newIndex + 1] = {
  234. ...next,
  235. visitStartDate: endVal,
  236. visitEndDate: endVal
  237. };
  238. }
  239. const prev = sorted[newIndex - 1];
  240. if (prev && !isFullDate(prev.visitStartDate)) {
  241. sorted[newIndex - 1] = {
  242. ...prev,
  243. visitStartDate: startVal,
  244. visitEndDate: startVal
  245. };
  246. }
  247. }
  248. setRegions(sorted);
  249. validateRegionsDates(sorted);
  250. setRegionErrors((prev) => {
  251. const clone = { ...prev };
  252. delete clone[clickedInstanceIndex];
  253. return clone;
  254. });
  255. };
  256. const moveRegionUp = (index: number) => {
  257. if (index <= 0 || !regions) return;
  258. const newRegions = [...regions];
  259. [newRegions[index - 1], newRegions[index]] = [newRegions[index], newRegions[index - 1]];
  260. setRegions(newRegions);
  261. };
  262. const moveRegionDown = (index: number) => {
  263. if (!regions || index >= regions.length - 1) return;
  264. const newRegions = [...regions];
  265. [newRegions[index + 1], newRegions[index]] = [newRegions[index], newRegions[index + 1]];
  266. setRegions(newRegions);
  267. };
  268. const handleDeleteRegion = (index: number) => {
  269. if (!regions) return;
  270. const updated = [...regions];
  271. updated.splice(index, 1);
  272. setRegions(updated);
  273. };
  274. const handleDeleteTrip = async () => {
  275. setIsWarningModalVisible(false);
  276. setPendingDelete(true);
  277. };
  278. const computePayloadDates = (regionsList: RegionWithDates[]) => {
  279. if (!regionsList || regionsList.length === 0) return { date_from: null, date_to: null };
  280. const starts = regionsList.map((r) => dateValueToISO(r.visitStartDate) as string);
  281. const ends = regionsList.map((r) => dateValueToISO(r.visitEndDate) as string);
  282. const minStart = starts.reduce(
  283. (acc, cur) => (!acc || cur < acc ? cur : acc),
  284. null as string | null
  285. );
  286. const maxEnd = ends.reduce(
  287. (acc, cur) => (!acc || cur > acc ? cur : acc),
  288. null as string | null
  289. );
  290. return { date_from: minStart, date_to: maxEnd };
  291. };
  292. const handleSaveNewTrip = () => {
  293. if (regions) {
  294. if (!validateRegionsDates(regions)) {
  295. scrollToError(regionErrors);
  296. return;
  297. }
  298. setIsLoading('save');
  299. const regionsData = regions.map((region) => {
  300. return {
  301. id: region.id,
  302. quality: 3,
  303. hidden: region.hidden,
  304. year_from: region.visitStartDate?.year || null,
  305. year_to: region.visitEndDate?.year || null,
  306. month_from: region.visitStartDate?.month || null,
  307. month_to: region.visitEndDate?.month || null,
  308. day_from: region.visitStartDate?.day || null,
  309. day_to: region.visitEndDate?.day || null
  310. };
  311. });
  312. if (regionsData.length > 30) {
  313. Alert.alert('One trip cannot have more than 30 regions.');
  314. setIsLoading(null);
  315. return;
  316. }
  317. const { date_from, date_to } = computePayloadDates(regions);
  318. saveNewTrip(
  319. {
  320. token,
  321. date_from,
  322. date_to,
  323. description,
  324. regions: regionsData
  325. },
  326. {
  327. onSuccess: (res) => {
  328. if (res && res.result === 'OK') {
  329. navigation.popTo(...([NAVIGATION_PAGES.TRIPS_2025, { saved: true }] as never));
  330. }
  331. setIsLoading(null);
  332. },
  333. onError: () => {
  334. setIsLoading(null);
  335. }
  336. }
  337. );
  338. }
  339. };
  340. const handleUpdateTrip = () => {
  341. if (regions) {
  342. if (!validateRegionsDates(regions)) {
  343. scrollToError(regionErrors);
  344. return;
  345. }
  346. setIsLoading('update');
  347. const regionsData = regions.map((region) => {
  348. return {
  349. id: region.id,
  350. quality: 3,
  351. hidden: region.hidden,
  352. year_from: region.visitStartDate?.year || null,
  353. year_to: region.visitEndDate?.year || null,
  354. month_from: region.visitStartDate?.month || null,
  355. month_to: region.visitEndDate?.month || null,
  356. day_from: region.visitStartDate?.day || null,
  357. day_to: region.visitEndDate?.day || null
  358. };
  359. });
  360. if (regionsData.length > 30) {
  361. Alert.alert('One trip cannot have more than 30 regions.');
  362. setIsLoading(null);
  363. return;
  364. }
  365. const { date_from, date_to } = computePayloadDates(regions);
  366. updateTrip(
  367. {
  368. token,
  369. trip_id: editTripId,
  370. date_from,
  371. date_to,
  372. description,
  373. regions: regionsData
  374. },
  375. {
  376. onSuccess: (res) => {
  377. if (res && res.result === 'OK') {
  378. navigation.popTo(...([NAVIGATION_PAGES.TRIPS_2025, { updated: true }] as never));
  379. }
  380. setIsLoading(null);
  381. },
  382. onError: () => {
  383. setIsLoading(null);
  384. }
  385. }
  386. );
  387. }
  388. };
  389. return (
  390. <PageWrapper style={{ flex: 1 }}>
  391. <Header label={editTripId ? 'Edit Trip' : 'Add New Trip'} />
  392. <ScrollView
  393. ref={scrollRef}
  394. contentContainerStyle={{ flexGrow: 1, gap: 16 }}
  395. showsVerticalScrollIndicator={false}
  396. >
  397. <Input
  398. placeholder="Add description and all interesting moments of your trip"
  399. inputMode={'text'}
  400. onChange={(text) => setDescription(text)}
  401. value={description}
  402. header="Description"
  403. height={54}
  404. multiline={true}
  405. />
  406. <View style={{ marginBottom: 8 }}>
  407. <Text style={styles.regionsLabel}>Regions</Text>
  408. {regions && regions.length ? (
  409. <View style={styles.regionsContainer}>
  410. {regions.map((region, index) => {
  411. const startLabel = formatDateForDisplay(region.visitStartDate);
  412. const endLabel = formatDateForDisplay(region.visitEndDate);
  413. const datesLabel =
  414. region.visitStartDate?.year &&
  415. region.visitEndDate?.year &&
  416. dateValueToISO(region.visitStartDate) === dateValueToISO(region.visitEndDate)
  417. ? startLabel
  418. : region.visitStartDate?.year && region.visitEndDate?.year
  419. ? `${startLabel} - ${endLabel}`
  420. : 'Select visit dates';
  421. return (
  422. <RegionItem
  423. key={`${region.id}-${index}`}
  424. region={region}
  425. index={index}
  426. total={regions.length}
  427. onDelete={() => handleDeleteRegion(index)}
  428. onSelectDates={() => openRangeCalendarForRegion(index)}
  429. datesLabel={datesLabel}
  430. onMoveUp={() => moveRegionUp(index)}
  431. onMoveDown={() => moveRegionDown(index)}
  432. errorMessage={regionErrors[region?._instanceId ?? index]}
  433. startLabel={startLabel}
  434. endLabel={endLabel}
  435. />
  436. );
  437. })}
  438. </View>
  439. ) : (
  440. <Text style={styles.noRegiosText}>No regions at the moment</Text>
  441. )}
  442. </View>
  443. </ScrollView>
  444. <View style={{ flexDirection: 'column', gap: 6, backgroundColor: 'transparent' }}>
  445. <TouchableOpacity
  446. style={[styles.addRegionBtn]}
  447. onPress={() =>
  448. navigation.navigate(
  449. ...([
  450. NAVIGATION_PAGES.ADD_REGIONS_NEW,
  451. { regionsParams: regions, editId: editTripId }
  452. ] as never)
  453. )
  454. }
  455. >
  456. <Text style={styles.addRegionBtntext}>Add new visit</Text>
  457. </TouchableOpacity>
  458. <View style={styles.tabContainer}>
  459. {editTripId ? (
  460. <>
  461. <TouchableOpacity
  462. style={[styles.tabStyle, styles.deleteTab]}
  463. onPress={() => setIsWarningModalVisible(true)}
  464. disabled={isLoading === 'delete'}
  465. >
  466. {isLoading === 'delete' ? (
  467. <ActivityIndicator size={18} color={Colors.RED} />
  468. ) : (
  469. <Text style={[styles.tabText, styles.deleteTabText]}>Delete trip</Text>
  470. )}
  471. </TouchableOpacity>
  472. <TouchableOpacity
  473. style={[
  474. styles.tabStyle,
  475. styles.addNewTab,
  476. disabled && { backgroundColor: Colors.LIGHT_GRAY, borderColor: Colors.LIGHT_GRAY }
  477. ]}
  478. onPress={handleUpdateTrip}
  479. disabled={disabled || isLoading === 'update'}
  480. >
  481. {isLoading === 'update' ? (
  482. <ActivityIndicator size={18} color={Colors.WHITE} />
  483. ) : (
  484. <Text style={[styles.tabText, styles.addNewTabText]}>Save trip</Text>
  485. )}
  486. </TouchableOpacity>
  487. </>
  488. ) : (
  489. <TouchableOpacity
  490. style={[
  491. styles.tabStyle,
  492. styles.addNewTab,
  493. disabled && { backgroundColor: Colors.LIGHT_GRAY, borderColor: Colors.LIGHT_GRAY },
  494. { paddingVertical: 12 }
  495. ]}
  496. onPress={handleSaveNewTrip}
  497. disabled={disabled || isLoading === 'save'}
  498. >
  499. {isLoading === 'save' ? (
  500. <ActivityIndicator size={18} color={Colors.WHITE} />
  501. ) : (
  502. <Text style={[styles.tabText, styles.addNewTabText]}>Save new trip</Text>
  503. )}
  504. </TouchableOpacity>
  505. )}
  506. </View>
  507. </View>
  508. <RangeCalendar
  509. isModalVisible={calendarVisibleForIndex !== null}
  510. allowRangeSelection={true}
  511. closeModal={(startDate?: string | null, endDate?: string | null) =>
  512. closeRangeCalendar(startDate, endDate)
  513. }
  514. initialStartDate={
  515. calendarVisibleForIndex !== null &&
  516. regions?.[calendarVisibleForIndex]?.visitStartDate &&
  517. regions[calendarVisibleForIndex].visitStartDate?.day
  518. ? `${regions[calendarVisibleForIndex].visitStartDate.year}-${regions[calendarVisibleForIndex].visitStartDate.month}-${regions[calendarVisibleForIndex].visitStartDate.day}`
  519. : undefined
  520. }
  521. initialEndDate={
  522. calendarVisibleForIndex !== null &&
  523. regions?.[calendarVisibleForIndex]?.visitEndDate &&
  524. regions[calendarVisibleForIndex].visitEndDate?.day
  525. ? `${regions[calendarVisibleForIndex].visitEndDate.year}-${regions[calendarVisibleForIndex].visitEndDate.month}-${regions[calendarVisibleForIndex].visitEndDate.day}`
  526. : undefined
  527. }
  528. />
  529. <WarningModal
  530. type={'delete'}
  531. isVisible={isWarningModalVisible}
  532. onClose={() => setIsWarningModalVisible(false)}
  533. title="Delete Trip"
  534. message="Are you sure you want to delete your trip?"
  535. action={handleDeleteTrip}
  536. onModalHide={() => {
  537. if (pendingDelete) {
  538. setPendingDelete(false);
  539. setIsLoading('delete');
  540. deleteTrip(
  541. {
  542. token,
  543. trip_id: editTripId
  544. },
  545. {
  546. onSuccess: (res) => {
  547. if (res && res.result === 'OK') {
  548. navigation.popTo(
  549. ...([NAVIGATION_PAGES.TRIPS_2025, { deleted: true }] as never)
  550. );
  551. }
  552. setIsLoading(null);
  553. },
  554. onError: () => {
  555. setIsLoading(null);
  556. }
  557. }
  558. );
  559. }
  560. }}
  561. />
  562. </PageWrapper>
  563. );
  564. };
  565. export default AddNewTripScreen;