diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 57b6c454805a..3b98cf83f3cc 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2913,6 +2913,7 @@ const CONST = { SPLIT_TYPE: { AMOUNT: 'amount', PERCENTAGE: 'percentage', + DATE: 'date', }, AMOUNT_MAX_LENGTH: 8, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9ae8679183ab..177d9db38045 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -955,6 +955,7 @@ const ONYXKEYS = { ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT: 'enableGlobalReimbursementsFormDraft', CREATE_DOMAIN_FORM: 'createDomainForm', CREATE_DOMAIN_FORM_DRAFT: 'createDomainFormDraft', + SPLIT_EXPENSE_EDIT_DATES: 'splitExpenseEditDates', }, DERIVED: { REPORT_ATTRIBUTES: 'reportAttributes', @@ -1070,6 +1071,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; [ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS]: FormTypes.EnableGlobalReimbursementsForm; [ONYXKEYS.FORMS.CREATE_DOMAIN_FORM]: FormTypes.CreateDomainForm; + [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d7da1da78f50..964a287fa1a0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -758,6 +758,17 @@ const ROUTES = { return getUrlWithBackToParam(`create/split-expense/overview/${reportID}/${originalTransactionID}${splitExpenseTransactionID ? `/${splitExpenseTransactionID}` : ''}`, backTo); }, }, + SPLIT_EXPENSE_CREATE_DATE_RANGE: { + route: 'create/split-expense/create-date-range/:reportID/:transactionID/:splitExpenseTransactionID?', + getRoute: (reportID: string | undefined, transactionID: string | undefined, backTo?: string) => { + if (!reportID || !transactionID) { + Log.warn(`Invalid ${reportID}(reportID) or ${transactionID}(transactionID) is used to build the SPLIT_EXPENSE_CREATE_DATE_RANGE route`); + } + + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + return getUrlWithBackToParam(`create/split-expense/create-date-range/${reportID}/${transactionID}`, backTo); + }, + }, SPLIT_EXPENSE_EDIT: { route: 'edit/split-expense/overview/:reportID/:transactionID/:splitExpenseTransactionID?', getRoute: (reportID: string | undefined, originalTransactionID: string | undefined, splitExpenseTransactionID?: string, backTo?: string) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 199e49428551..aa54d1717ad2 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -323,6 +323,7 @@ const SCREENS = { STEP_REPORT: 'Money_Request_Report', EDIT_REPORT: 'Money_Request_Edit_Report', SPLIT_EXPENSE: 'Money_Request_Split_Expense', + SPLIT_EXPENSE_CREATE_DATE_RANGE: 'Money_Request_Split_Expense_Create_Date_Range', SPLIT_EXPENSE_EDIT: 'Money_Request_Split_Expense_Edit', DISTANCE_CREATE: 'Money_Request_Distance_Create', STEP_DISTANCE_MAP: 'Money_Request_Step_Distance_Map', diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index 204536cdad85..c23b711f669e 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -77,6 +77,8 @@ function getIconTitleAndTestID( return {icon: icons.MoneyCircle, title: translate('iou.amount'), testID: 'split-amount'}; case CONST.IOU.SPLIT_TYPE.PERCENTAGE: return {icon: icons.Percent, title: translate('iou.percent'), testID: 'split-percentage'}; + case CONST.IOU.SPLIT_TYPE.DATE: + return {icon: icons.CalendarSolid, title: translate('iou.date'), testID: 'split-date'}; default: throw new Error(`Route ${route} has no icon nor title set.`); } diff --git a/src/languages/de.ts b/src/languages/de.ts index c45fe7eef57f..d7b4707af93b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1380,6 +1381,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: 'Die Menge muss größer als Null sein', invalidSubrateLength: 'Es muss mindestens einen Untertarif geben', invalidRate: 'Satz für diesen Workspace ungültig. Bitte wählen Sie einen verfügbaren Satz aus dem Workspace aus.', + endDateBeforeStartDate: 'Das Enddatum darf nicht vor dem Startdatum liegen', + endDateSameAsStartDate: 'Das Enddatum darf nicht mit dem Startdatum identisch sein', }, dismissReceiptError: 'Fehler ausblenden', dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler verwirfst, wird dein hochgeladener Beleg vollständig entfernt. Bist du sicher?', @@ -1525,6 +1528,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Arbeitsbereich auswählen', + date: 'Datum', + splitDates: 'Datumsangaben aufteilen', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} bis ${endDate} (${count} Tage)`, + splitByDate: 'Nach Datum aufteilen', }, transactionMerge: { listPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 2a173a710190..d56abd3489b8 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -162,6 +162,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1119,6 +1120,7 @@ const translations = { iou: { amount: 'Amount', percent: 'Percent', + date: 'Date', taxAmount: 'Tax amount', taxRate: 'Tax rate', approve: ({formattedAmount}: {formattedAmount?: string} = {}) => (formattedAmount ? `Approve ${formattedAmount}` : 'Approve'), @@ -1128,8 +1130,11 @@ const translations = { original: 'Original', split: 'Split', splitExpense: 'Split expense', + splitDates: 'Split dates', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} to ${endDate} (${count} days)`, splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} from ${merchant}`, splitByPercentage: 'Split by percentage', + splitByDate: 'Split by date', addSplit: 'Add split', makeSplitsEven: 'Make splits even', editSplits: 'Edit splits', @@ -1352,6 +1357,8 @@ const translations = { quantityGreaterThanZero: 'Quantity must be greater than zero', invalidSubrateLength: 'There must be at least one subrate', invalidRate: 'Rate not valid for this workspace. Please select an available rate from the workspace.', + endDateBeforeStartDate: "The end date can't be before the start date", + endDateSameAsStartDate: "The end date can't be the same as the start date", }, dismissReceiptError: 'Dismiss error', dismissReceiptErrorConfirmation: 'Heads up! Dismissing this error will remove your uploaded receipt entirely. Are you sure?', diff --git a/src/languages/es.ts b/src/languages/es.ts index f76e7447eb76..dedde21e08e7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2,7 +2,7 @@ import {CONST as COMMON_CONST} from 'expensify-common'; import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type en from './en'; -import type {BeginningOfChatHistoryParams, HarvestCreatedExpenseReportParams, ViolationsRterParams} from './params'; +import type {BeginningOfChatHistoryParams, HarvestCreatedExpenseReportParams, SplitDateRangeParams, ViolationsRterParams} from './params'; import type {TranslationDeepObject} from './types'; /* eslint-disable max-len */ @@ -800,6 +800,7 @@ const translations: TranslationDeepObject = { iou: { amount: 'Importe', percent: 'Porcentaje', + date: 'Fecha', taxAmount: 'Importe del impuesto', taxRate: 'Tasa de impuesto', approve: ({formattedAmount} = {}) => (formattedAmount ? `Aprobar ${formattedAmount}` : 'Aprobar'), @@ -809,8 +810,11 @@ const translations: TranslationDeepObject = { original: 'Original', split: 'Dividir', splitExpense: 'Dividir gasto', + splitDates: 'Fechas de división', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} al ${endDate} (${count} días)`, splitExpenseSubtitle: ({amount, merchant}) => `${amount} de ${merchant}`, splitByPercentage: 'Dividir por porcentaje', + splitByDate: 'Dividir por fecha', addSplit: 'Añadir división', makeSplitsEven: 'Igualar divisiones', editSplits: 'Editar divisiones', @@ -1033,6 +1037,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: 'La cantidad debe ser mayor que cero', invalidSubrateLength: 'Debe haber al menos una subtasa', invalidRate: 'Tasa no válida para este espacio de trabajo. Por favor, selecciona una tasa disponible en el espacio de trabajo.', + endDateBeforeStartDate: 'La fecha de finalización no puede ser anterior a la fecha de inicio', + endDateSameAsStartDate: 'La fecha de finalización no puede ser la misma que la fecha de inicio', }, dismissReceiptError: 'Descartar error', dismissReceiptErrorConfirmation: '¡Atención! Descartar este error eliminará completamente tu recibo cargado. ¿Estás seguro?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 11ff89a8a34d..9a4a60d46bac 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1383,6 +1384,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: 'La quantité doit être supérieure à zéro', invalidSubrateLength: 'Il doit y avoir au moins un sous-taux', invalidRate: 'Taux non valide pour cet espace de travail. Veuillez sélectionner un taux disponible dans l’espace de travail.', + endDateBeforeStartDate: 'La date de fin ne peut pas être antérieure à la date de début', + endDateSameAsStartDate: 'La date de fin ne peut pas être identique à la date de début', }, dismissReceiptError: 'Ignorer l’erreur', dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera entièrement votre reçu téléchargé. Êtes-vous sûr ?', @@ -1528,6 +1531,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Choisir un espace de travail', + date: 'Date', + splitDates: 'Diviser les dates', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `Du ${startDate} au ${endDate} (${count} jours)`, + splitByDate: 'Scinder par date', }, transactionMerge: { listPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 691355dfcad2..ba77bd5b59ed 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1376,6 +1377,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: 'La quantità deve essere maggiore di zero', invalidSubrateLength: 'Deve esserci almeno un sottotariffa', invalidRate: 'Tariffa non valida per questo workspace. Seleziona una tariffa disponibile dal workspace.', + endDateBeforeStartDate: 'La data di fine non può essere precedente alla data di inizio', + endDateSameAsStartDate: 'La data di fine non può essere uguale alla data di inizio', }, dismissReceiptError: 'Ignora errore', dismissReceiptErrorConfirmation: 'Attenzione! Se ignori questo errore, la ricevuta caricata verrà rimossa completamente. Sei sicuro?', @@ -1521,6 +1524,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Scegli uno spazio di lavoro', + date: 'Data', + splitDates: 'Dividi date', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} a ${endDate} (${count} giorni)`, + splitByDate: 'Dividi per data', }, transactionMerge: { listPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 64fe296d8255..f4d6eebd9981 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1377,6 +1378,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: '数量は0より大きくなければなりません', invalidSubrateLength: '少なくとも 1 つのサブレートが必要です', invalidRate: 'このワークスペースでは無効なレートです。ワークスペースから利用可能なレートを選択してください。', + endDateBeforeStartDate: '終了日は開始日より前にはできません', + endDateSameAsStartDate: '終了日は開始日と同じにはできません', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: '注意!このエラーを無視すると、アップロードした領収書が完全に削除されます。本当に実行しますか?', @@ -1522,6 +1525,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'ワークスペースを選択', + date: '日付', + splitDates: '日付を分割', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} から ${endDate} まで(${count} 日間)`, + splitByDate: '日付で分割', }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e925f13487aa..74558ae305a5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1375,6 +1376,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: 'Hoeveelheid moet groter zijn dan nul', invalidSubrateLength: 'Er moet ten minste één subtarief zijn', invalidRate: 'Tarief is niet geldig voor deze workspace. Selecteer een beschikbaar tarief uit de workspace.', + endDateBeforeStartDate: 'De einddatum kan niet vóór de startdatum liggen', + endDateSameAsStartDate: 'De einddatum mag niet hetzelfde zijn als de startdatum', }, dismissReceiptError: 'Foutmelding sluiten', dismissReceiptErrorConfirmation: 'Let op! Als je deze foutmelding negeert, wordt je geüploade bon volledig verwijderd. Weet je het zeker?', @@ -1520,6 +1523,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Kies een workspace', + date: 'Datum', + splitDates: 'Datums splitsen', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} tot ${endDate} (${count} dagen)`, + splitByDate: 'Splitsen op datum', }, transactionMerge: { listPage: { diff --git a/src/languages/params.ts b/src/languages/params.ts index 56524c4ccf42..c01c5f01b46b 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -701,6 +701,12 @@ type SplitExpenseEditTitleParams = { merchant: string; }; +type SplitDateRangeParams = { + startDate: string; + endDate: string; + count: number; +}; + type TotalAmountGreaterOrLessThanOriginalParams = { amount: string; }; @@ -839,6 +845,7 @@ export type { ToggleImportTitleParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, + SplitDateRangeParams, TotalAmountGreaterOrLessThanOriginalParams, ImportMembersSuccessfulDescriptionParams, ImportedTagsMessageParams, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 64311a5798a4..1376bedd0c99 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1373,6 +1374,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: 'Ilość musi być większa niż zero', invalidSubrateLength: 'Musi istnieć co najmniej jedna podstawka', invalidRate: 'Stawka nie jest prawidłowa dla tego przestrzeni roboczej. Wybierz dostępną stawkę z tej przestrzeni roboczej.', + endDateBeforeStartDate: 'Data zakończenia nie może być wcześniejsza niż data rozpoczęcia', + endDateSameAsStartDate: 'Data zakończenia nie może być taka sama jak data rozpoczęcia', }, dismissReceiptError: 'Odrzuć błąd', dismissReceiptErrorConfirmation: 'Uwaga! Odrzucenie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?', @@ -1518,6 +1521,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Wybierz przestrzeń roboczą', + date: 'Data', + splitDates: 'Podziel daty', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} do ${endDate} (${count} dni)`, + splitByDate: 'Podziel według daty', }, transactionMerge: { listPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e50adb79f70d..4f314137f33a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1373,6 +1374,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: 'A quantidade deve ser maior que zero', invalidSubrateLength: 'Deve haver pelo menos uma subtarifa', invalidRate: 'Taxa inválida para este workspace. Selecione uma taxa disponível do workspace.', + endDateBeforeStartDate: 'A data de término não pode ser anterior à data de início', + endDateSameAsStartDate: 'A data de término não pode ser igual à data de início', }, dismissReceiptError: 'Dispensar erro', dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o seu recibo enviado. Tem certeza?', @@ -1518,6 +1521,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Escolha um workspace', + date: 'Data', + splitDates: 'Dividir datas', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} a ${endDate} (${count} dias)`, + splitByDate: 'Dividir por data', }, transactionMerge: { listPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2ec19d37c4e5..4570d036f1f4 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -174,6 +174,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitDateRangeParams, SplitExpenseEditTitleParams, SplitExpenseSubtitleParams, SpreadCategoriesParams, @@ -1353,6 +1354,8 @@ const translations: TranslationDeepObject = { quantityGreaterThanZero: '数量必须大于零', invalidSubrateLength: '必须至少有一个子费率', invalidRate: '此汇率对该工作区无效。请选择此工作区中的可用汇率。', + endDateBeforeStartDate: '结束日期不能早于开始日期', + endDateSameAsStartDate: '结束日期不能与开始日期相同', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒!关闭此错误会完全删除你上传的收据。确定要继续吗?', @@ -1495,6 +1498,10 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: '选择一个工作区', + date: '日期', + splitDates: '拆分日期', + splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} 至 ${endDate}(${count} 天)`, + splitByDate: '按日期拆分', }, transactionMerge: { listPage: { diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index d5171461b66b..16e5387ec5bd 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -889,6 +889,22 @@ function getFormattedDateRangeForPerDiem(date1: Date, date2: Date): string { return `${format(date1, 'MMM d, yyyy')} - ${format(date2, 'MMM d, yyyy')}`; } +/** + * Returns a formatted date range with the number of days in the range. + * Format: "YYYY-MM-DD to YYYY-MM-DD (X days)" + */ +function getFormattedSplitDateRange(translateParam: LocaleContextProps['translate'], startDate: string | undefined, endDate: string | undefined): string { + if (!startDate || !endDate) { + return ''; + } + + const start = new Date(startDate); + const end = new Date(endDate); + const daysCount = differenceInDays(end, start) + 1; + + return translateParam('iou.splitDateRange', {startDate, endDate, count: daysCount}); +} + /** * Checks if the current time falls within the specified time range. */ @@ -970,6 +986,7 @@ const DateUtils = { getFormattedDuration, isFutureDay, getFormattedDateRangeForPerDiem, + getFormattedSplitDateRange, isCurrentTimeWithinRange, formatInTimeZoneWithFallback, }; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 2d5ebd0c8ccc..7cc3666de656 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1107,6 +1107,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) taxValue: CONST.RED_BRICK_ROAD_PENDING_ACTION, groupAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, groupCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION, + splitsStartDate: CONST.RED_BRICK_ROAD_PENDING_ACTION, + splitsEndDate: CONST.RED_BRICK_ROAD_PENDING_ACTION, }, 'string', ); @@ -1147,6 +1149,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) splitExpenses: 'array', isDemoTransaction: 'boolean', splitExpensesTotal: 'number', + splitsStartDate: 'string', + splitsEndDate: 'string', }); case 'accountant': return validateObject>(value, { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 85642f25d618..b21675163e59 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -235,6 +235,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepSubrate').default, [SCREENS.MONEY_REQUEST.RECEIPT_VIEW]: () => require('../../../../pages/iou/request/step/IOURequestStepScan/ReceiptView').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE]: () => require('../../../../pages/iou/SplitExpensePage').default, + [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_CREATE_DATE_RANGE]: () => require('../../../../pages/iou/SplitExpenseCreateDateRagePage').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: () => require('../../../../pages/iou/SplitExpenseEditPage').default, [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: () => require('../../../../pages/iou/request/DistanceRequestStartPage').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MAP]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceMap').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 55ed3cb8603b..048c51fa020f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1484,8 +1484,15 @@ const config: LinkingOptions['config'] = { [CONST.IOU.SPLIT_TYPE.PERCENTAGE]: { path: CONST.IOU.SPLIT_TYPE.PERCENTAGE, }, + [CONST.IOU.SPLIT_TYPE.DATE]: { + path: CONST.IOU.SPLIT_TYPE.DATE, + }, }, }, + [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_CREATE_DATE_RANGE]: { + path: ROUTES.SPLIT_EXPENSE_CREATE_DATE_RANGE.route, + exact: true, + }, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: { path: ROUTES.SPLIT_EXPENSE_EDIT.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2974734b708c..acdaf6f19633 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2753,6 +2753,12 @@ type SplitExpenseParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; + [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_CREATE_DATE_RANGE]: { + reportID: string; + transactionID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: { reportID: string; transactionID: string; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 4ceae127e7fc..982460203e3c 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -109,6 +109,8 @@ type TransactionParams = { splitExpensesTotal?: number; participants?: Participant[]; pendingAction?: PendingAction; + splitsStartDate?: string; + splitsEndDate?: string; distance?: number; }; @@ -345,6 +347,8 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T filename = '', customUnit, splitExpenses, + splitsStartDate, + splitsEndDate, splitExpensesTotal, participants, pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -366,6 +370,12 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T if (splitExpenses) { commentJSON.splitExpenses = splitExpenses; } + if (splitsStartDate) { + commentJSON.splitsStartDate = splitsStartDate; + } + if (splitsEndDate) { + commentJSON.splitsEndDate = splitsEndDate; + } if (splitExpensesTotal) { commentJSON.splitExpensesTotal = splitExpensesTotal; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index c4ab09a23920..f801571c4f0f 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import {format} from 'date-fns'; +import {eachDayOfInterval, format} from 'date-fns'; import {fastMerge, Str} from 'expensify-common'; import cloneDeep from 'lodash/cloneDeep'; // eslint-disable-next-line you-dont-need-lodash-underscore/union-by @@ -14378,7 +14378,7 @@ function markRejectViolationAsResolved(transactionID: string, reportID?: string) function initSplitExpenseItemData( transaction: OnyxEntry, - {amount, transactionID, reportID}: {amount?: number; transactionID?: string; reportID?: string} = {}, + {amount, transactionID, reportID, created}: {amount?: number; transactionID?: string; reportID?: string; created?: string} = {}, ): SplitExpense { const transactionDetails = getTransactionDetails(transaction); const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; @@ -14389,7 +14389,7 @@ function initSplitExpenseItemData( description: transactionDetails?.comment, category: transactionDetails?.category, tags: transaction?.tag ? [transaction?.tag] : [], - created: transactionDetails?.created ?? DateUtils.formatWithUTCTimeZone(DateUtils.getDBTime(), CONST.DATE.FNS_FORMAT_STRING), + created: created ?? transactionDetails?.created ?? DateUtils.formatWithUTCTimeZone(DateUtils.getDBTime(), CONST.DATE.FNS_FORMAT_STRING), merchant: transaction?.modifiedMerchant ? transaction.modifiedMerchant : (transaction?.merchant ?? ''), statusNum: currentReport?.statusNum ?? 0, reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), @@ -14561,6 +14561,49 @@ function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry, startDate: string, endDate: string) { + if (!transaction || !startDate || !endDate) { + return; + } + + // Generate all dates in the range + const dates = eachDayOfInterval({ + start: new Date(startDate), + end: new Date(endDate), + }); + + const transactionDetails = getTransactionDetails(transaction); + const total = transactionDetails?.amount ?? 0; + const currency = transactionDetails?.currency ?? CONST.CURRENCY.USD; + + // Create split expenses for each date with proportional amounts + const lastIndex = dates.length - 1; + const newSplitExpenses: SplitExpense[] = dates.map((date, index) => { + return initSplitExpenseItemData(transaction, { + amount: calculateIOUAmount(lastIndex, total, currency, index === lastIndex, true), + transactionID: NumberUtils.rand64(), + reportID: transaction?.reportID, + created: format(date, CONST.DATE.FNS_FORMAT_STRING), + }); + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`, { + comment: { + splitExpenses: newSplitExpenses, + splitsStartDate: startDate, + splitsEndDate: endDate, + }, + }); +} + function removeSplitExpenseField(draftTransaction: OnyxEntry, splitExpenseTransactionID: string) { if (!draftTransaction || !splitExpenseTransactionID) { return; @@ -14587,11 +14630,14 @@ function updateSplitExpenseField( } const originalTransactionID = splitExpenseDraftTransaction?.comment?.originalTransactionID; + const transactionDetails = getTransactionDetails(splitExpenseDraftTransaction); + let shouldResetDateRange = false; const splitExpenses = originalTransactionDraft?.comment?.splitExpenses?.map((item) => { if (item.transactionID === splitExpenseTransactionID) { - const transactionDetails = getTransactionDetails(splitExpenseDraftTransaction); - + if (transactionDetails?.created !== item.created) { + shouldResetDateRange = true; + } return { ...item, description: transactionDetails?.comment, @@ -14606,6 +14652,9 @@ function updateSplitExpenseField( Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { splitExpenses, + // Reset date range if the created date was modified + splitsStartDate: shouldResetDateRange ? null : originalTransactionDraft?.comment?.splitsStartDate, + splitsEndDate: shouldResetDateRange ? null : originalTransactionDraft?.comment?.splitsEndDate, }, }); } @@ -15435,6 +15484,7 @@ export { initSplitExpenseItemData, addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, + resetSplitExpensesByDateRange, updateSplitExpenseAmountField, updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow, diff --git a/src/pages/iou/SplitExpenseCreateDateRagePage.tsx b/src/pages/iou/SplitExpenseCreateDateRagePage.tsx new file mode 100644 index 000000000000..7cb001e41d9a --- /dev/null +++ b/src/pages/iou/SplitExpenseCreateDateRagePage.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import {View} from 'react-native'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import DatePicker from '@components/DatePicker'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import {useSearchContext} from '@components/Search/SearchContext'; +import useAllTransactions from '@hooks/useAllTransactions'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {resetSplitExpensesByDateRange} from '@libs/actions/IOU'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SplitExpenseParamList} from '@libs/Navigation/types'; +import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; +import {getReportOrDraftReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/SplitExpenseEditDateForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type SplitExpenseCreateDateRagePageProps = PlatformStackScreenProps; + +function SplitExpenseCreateDateRagePage({route}: SplitExpenseCreateDateRagePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const searchContext = useSearchContext(); + + const {reportID, transactionID, backTo} = route.params; + + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: false}); + + const searchHash = searchContext?.currentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${searchHash}`, {canBeMissing: true}); + const allTransactions = useAllTransactions(); + + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`]; + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`]; + + const report = getReportOrDraftReport(reportID); + const currentReport = report ?? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`]; + + const policy = usePolicy(currentReport?.policyID); + const currentPolicy = Object.keys(policy?.employeeList ?? {}).length + ? policy + : currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(currentReport?.policyID)}`]; + + const updateDate = (value: FormOnyxValues) => { + resetSplitExpensesByDateRange(transaction, value[INPUT_IDS.START_DATE], value[INPUT_IDS.END_DATE]); + Navigation.goBack(backTo); + }; + + const isSplitAvailable = report && transaction && isSplitAction(currentReport, [transaction], originalTransaction, currentPolicy); + + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.START_DATE]) { + errors[INPUT_IDS.START_DATE] = translate('common.error.fieldRequired'); + } + if (!values[INPUT_IDS.END_DATE]) { + errors[INPUT_IDS.END_DATE] = translate('common.error.fieldRequired'); + } + + if (values[INPUT_IDS.START_DATE] && values[INPUT_IDS.END_DATE]) { + const startDate = new Date(values[INPUT_IDS.START_DATE]); + const endDate = new Date(values[INPUT_IDS.END_DATE]); + + if (endDate < startDate) { + errors[INPUT_IDS.END_DATE] = translate('iou.error.endDateBeforeStartDate'); + } else if (endDate.getTime() === startDate.getTime()) { + errors[INPUT_IDS.END_DATE] = translate('iou.error.endDateSameAsStartDate'); + } + } + + return errors; + }; + + const handleBackPress = () => { + Navigation.goBack(backTo); + }; + + return ( + + + + + + + + + + + + ); +} + +export default SplitExpenseCreateDateRagePage; diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 53ee30f0f37f..a8d5f782c18a 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -8,6 +8,7 @@ import ConfirmModal from '@components/ConfirmModal'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; import type {SectionListDataType, SplitListItemType} from '@components/SelectionListWithSections/types'; @@ -50,6 +51,7 @@ import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; import {getChildTransactions, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import SplitAmountList from './SplitAmountList'; @@ -60,7 +62,7 @@ type SplitExpensePageProps = PlatformStackScreenProps getChildTransactions(allTransactions, allReports, transactionID), [allReports, allTransactions, transactionID]); const splitFieldDataFromChildTransactions = useMemo(() => childTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction)), [childTransactions]); const splitFieldDataFromOriginalTransaction = useMemo(() => initSplitExpenseItemData(transaction), [transaction]); @@ -241,7 +244,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const onSplitExpenseValueChange = useCallback( (id: string, value: number, mode: ValueOf) => { - if (mode === CONST.IOU.SPLIT_TYPE.AMOUNT) { + if (mode === CONST.IOU.SPLIT_TYPE.AMOUNT || mode === CONST.IOU.SPLIT_TYPE.DATE) { const amountInCents = convertToBackendAmount(value); updateSplitExpenseAmountField(draftTransaction, id, amountInCents); } else { @@ -388,6 +391,34 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ); }, [sumOfSplitExpenses, transactionDetailsAmount, translate, transactionDetails.currency, errorMessage, styles.ph1, styles.mb2, styles.w100, styles.ph5, styles.pb5, onSaveSplitExpense]); + const splitDatesTitle = useMemo(() => { + const startDate = draftTransaction?.comment?.splitsStartDate; + const endDate = draftTransaction?.comment?.splitsEndDate; + return DateUtils.getFormattedSplitDateRange(translate, startDate, endDate); + }, [draftTransaction?.comment?.splitsStartDate, draftTransaction?.comment?.splitsEndDate, translate]); + + const handleDatePress = useCallback(() => { + Navigation.navigate(ROUTES.SPLIT_EXPENSE_CREATE_DATE_RANGE.getRoute(reportID, transactionID, Navigation.getActiveRoute())); + }, [reportID, transactionID]); + + const headerDateContent = useMemo(() => { + return ( + + + + ); + }, [styles.pb3, styles.moneyRequestMenuItem, styles.flex1, translate, splitDatesTitle, handleDatePress]); + const initiallyFocusedOptionKey = useMemo( () => sections.at(0)?.data.find((option) => option.transactionID === splitExpenseTransactionID)?.keyForList, [sections, splitExpenseTransactionID], @@ -397,8 +428,14 @@ function SplitExpensePage({route}: SplitExpensePageProps) { if (splitExpenseTransactionID) { return translate('iou.editSplits'); } - return isPercentageMode ? translate('iou.splitByPercentage') : translate('iou.split'); - }, [splitExpenseTransactionID, isPercentageMode, translate]); + if (isPercentageMode) { + return translate('iou.splitByPercentage'); + } + if (isDateMode) { + return translate('iou.splitByDate'); + } + return translate('iou.split'); + }, [splitExpenseTransactionID, isDateMode, isPercentageMode, translate]); const onSelectRow = useCallback( (item: SplitListItemType) => { @@ -470,6 +507,21 @@ function SplitExpensePage({route}: SplitExpensePageProps) { )} + + {() => ( + + + {headerDateContent} + + {footerContent} + + + )} + ) : ( diff --git a/src/types/form/SplitExpenseEditDateForm.ts b/src/types/form/SplitExpenseEditDateForm.ts new file mode 100644 index 000000000000..4d998d29bea6 --- /dev/null +++ b/src/types/form/SplitExpenseEditDateForm.ts @@ -0,0 +1,20 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + START_DATE: 'startDate', + END_DATE: 'endDate', +} as const; + +type InputID = ValueOf; + +type SplitExpenseEditDateForm = Form< + InputID, + { + [INPUT_IDS.START_DATE]: string; + [INPUT_IDS.END_DATE]: string; + } +>; + +export type {SplitExpenseEditDateForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 2e703c52a00c..41ab635811d3 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -100,3 +100,4 @@ export type {MoneyRequestRejectReasonForm} from './MoneyRequestRejectReasonForm' export type {EnableGlobalReimbursementsForm} from './EnableGlobalReimbursementsForm'; export type {EnterSignerInfoForm} from './EnterSignerInfoForm'; export type {CreateDomainForm} from './CreateDomainForm'; +export type {SplitExpenseEditDateForm} from './SplitExpenseEditDateForm'; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index c844f47f60b3..715f46d494e9 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -93,6 +93,12 @@ type Comment = { /** Total that the user currently owes for splitExpenses */ splitExpensesTotal?: number; + /** Start date for splits */ + splitsStartDate?: string; + + /** End date for splits */ + splitsEndDate?: string; + /** Violations that were dismissed */ dismissedViolations?: Partial>>; diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 510dda01fcd4..32304b86f1ed 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -3,8 +3,10 @@ import {addDays, addMinutes, endOfDay, format, set, setHours, setMinutes, subDay import {fromZonedTime, toZonedTime, format as tzFormat} from 'date-fns-tz'; import Onyx from 'react-native-onyx'; import DateUtils from '@libs/DateUtils'; +import {translate} from '@libs/Localize'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -428,4 +430,55 @@ describe('DateUtils', () => { expect(result).toBe(`Until ${expectedLabel}`); }); }); + + describe('getFormattedSplitDateRange', () => { + const translateEN = (path: TPath, ...params: TranslationParameters) => translate(LOCALE, path, ...params); + + it('should return empty string when startDate is undefined', () => { + const result = DateUtils.getFormattedSplitDateRange(translateEN, undefined, '2024-01-15'); + expect(result).toBe(''); + }); + + it('should return empty string when endDate is undefined', () => { + const result = DateUtils.getFormattedSplitDateRange(translateEN, '2024-01-10', undefined); + expect(result).toBe(''); + }); + + it('should return empty string when both dates are undefined', () => { + const result = DateUtils.getFormattedSplitDateRange(translateEN, undefined, undefined); + expect(result).toBe(''); + }); + + it('should return plural form for multiple days', () => { + const result = DateUtils.getFormattedSplitDateRange(translateEN, '2024-01-10', '2024-01-15'); + expect(result).toContain('2024-01-10'); + expect(result).toContain('to'); + expect(result).toContain('2024-01-15'); + expect(result).toContain('6 days'); + }); + + it('should return correct format for 2 days', () => { + const result = DateUtils.getFormattedSplitDateRange(translateEN, '2024-01-10', '2024-01-11'); + expect(result).toContain('2024-01-10'); + expect(result).toContain('to'); + expect(result).toContain('2024-01-11'); + expect(result).toContain('2 days'); + }); + + it('should handle cross-month date ranges', () => { + const result = DateUtils.getFormattedSplitDateRange(translateEN, '2024-01-25', '2024-02-05'); + expect(result).toContain('2024-01-25'); + expect(result).toContain('to'); + expect(result).toContain('2024-02-05'); + expect(result).toContain('12 days'); + }); + + it('should handle cross-year date ranges', () => { + const result = DateUtils.getFormattedSplitDateRange(translateEN, '2023-12-25', '2024-01-05'); + expect(result).toContain('2023-12-25'); + expect(result).toContain('to'); + expect(result).toContain('2024-01-05'); + expect(result).toContain('12 days'); + }); + }); });