From 3b22116ec5d6dd187a522ed97f4a0cb01e772d42 Mon Sep 17 00:00:00 2001 From: Nicolay Arefyeu Date: Mon, 25 Aug 2025 13:17:53 +0300 Subject: [PATCH 1/8] Duplicate workspace --- src/ONYXKEYS.ts | 7 + src/ROUTES.ts | 8 + src/SCREENS.ts | 2 + src/components/WorkspaceConfirmationForm.tsx | 32 +- src/hooks/useWorkspaceConfirmationAvatar.tsx | 28 ++ src/languages/de.ts | 18 + src/languages/en.ts | 18 + src/languages/es.ts | 18 + src/languages/fr.ts | 18 + src/languages/it.ts | 18 + src/languages/ja.ts | 18 + src/languages/nl.ts | 18 + src/languages/pl.ts | 18 + src/languages/pt-BR.ts | 18 + src/languages/zh-hans.ts | 18 + .../parameters/DuplicateWorkspaceParams.ts | 20 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 7 + .../Navigators/RightModalNavigator.tsx | 4 + src/libs/Navigation/linkingConfig/config.ts | 6 + src/libs/Navigation/types.ts | 11 + src/libs/actions/Policy/Policy.ts | 307 ++++++++++++++- src/libs/getFirstAlphaNumericCharacter.ts | 8 + src/pages/workspace/WorkspacesListPage.tsx | 52 ++- src/pages/workspace/WorkspacesListRow.tsx | 17 +- .../duplicate/WorkspaceDuplicateForm.tsx | 162 ++++++++ .../duplicate/WorkspaceDuplicatePage.tsx | 36 ++ .../WorkspaceDuplicateSelectFeaturesForm.tsx | 351 ++++++++++++++++++ .../WorkspaceDuplicateSelectFeaturesPage.tsx | 33 ++ src/pages/workspace/duplicate/utils.ts | 79 ++++ src/types/form/WorkspaceDuplicateForm.tsx | 18 + src/types/form/index.ts | 1 + src/types/onyx/DuplicateWorkspace.ts | 22 ++ src/types/onyx/index.ts | 2 + 35 files changed, 1364 insertions(+), 32 deletions(-) create mode 100644 src/hooks/useWorkspaceConfirmationAvatar.tsx create mode 100644 src/libs/API/parameters/DuplicateWorkspaceParams.ts create mode 100644 src/libs/getFirstAlphaNumericCharacter.ts create mode 100644 src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx create mode 100644 src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx create mode 100644 src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx create mode 100644 src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesPage.tsx create mode 100644 src/pages/workspace/duplicate/utils.ts create mode 100644 src/types/form/WorkspaceDuplicateForm.tsx create mode 100644 src/types/onyx/DuplicateWorkspace.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 57bf7fb6f0231..a70273e8d4332 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -466,6 +466,9 @@ const ONYXKEYS = { NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', + /** Stores the information about duplicated workspace */ + DUPLICATE_WORKSPACE: 'duplicateWorkspace', + /** Stores the information about currently edited advanced approval workflow */ APPROVAL_WORKFLOW: 'approvalWorkflow', @@ -683,6 +686,8 @@ const ONYXKEYS = { WORKSPACE_CATEGORY_FORM: 'workspaceCategoryForm', WORKSPACE_CONFIRMATION_FORM: 'workspaceConfirmationForm', WORKSPACE_CONFIRMATION_FORM_DRAFT: 'workspaceConfirmationFormDraft', + WORKSPACE_DUPLICATE_FORM: 'workspaceDuplicateForm', + WORKSPACE_DUPLICATE_FORM_DRAFT: 'workspaceDuplicateFormDraft', WORKSPACE_CATEGORY_FORM_DRAFT: 'workspaceCategoryFormDraft', WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM: 'workspaceCategoryDescriptionHintForm', WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM_DRAFT: 'workspaceCategoryDescriptionHintFormDraft', @@ -889,6 +894,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM]: FormTypes.WorkspaceCategoryForm; [ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM]: FormTypes.WorkspaceConfirmationForm; + [ONYXKEYS.FORMS.WORKSPACE_DUPLICATE_FORM]: FormTypes.WorkspaceDuplicateForm; [ONYXKEYS.FORMS.ONBOARDING_WORKSPACE_DETAILS_FORM]: FormTypes.WorkspaceConfirmationForm; [ONYXKEYS.FORMS.WORKSPACE_TAG_FORM]: FormTypes.WorkspaceTagForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; @@ -1190,6 +1196,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ADD_NEW_COMPANY_CARD]: OnyxTypes.AddNewCompanyCardFeed; [ONYXKEYS.ASSIGN_CARD]: OnyxTypes.AssignCard; [ONYXKEYS.MOBILE_SELECTION_MODE]: boolean; + [ONYXKEYS.DUPLICATE_WORKSPACE]: OnyxTypes.DuplicateWorkspace; [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_BILLING_FUND_ID]: number; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d3ec33acb3709..fc327c404852f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1876,6 +1876,14 @@ const ROUTES = { return getUrlWithBackToParam(`workspaces/${policyID}/per-diem`, backTo); }, }, + WORKSPACE_DUPLICATE: { + route: 'workspace/:policyID/duplicate', + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspace/${policyID}/duplicate`, backTo), + }, + WORKSPACE_DUPLICATE_SELECT_FEATURES: { + route: 'workspace/:policyID/duplicate/select-features', + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspace/${policyID}/duplicate/select-features`, backTo), + }, WORKSPACE_RECEIPT_PARTNERS: { route: 'workspaces/:policyID/receipt-partners', getRoute: (policyID: string | undefined, backTo?: string) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9a7c01be8fbb4..4d9043da65a09 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -192,6 +192,7 @@ const SCREENS = { REPORT_DETAILS: 'Report_Details', REPORT_CHANGE_WORKSPACE: 'ReportChangeWorkspace', WORKSPACE_CONFIRMATION: 'Workspace_Confirmation', + WORKSPACE_DUPLICATE: 'Workspace_Duplicate', REPORT_SETTINGS: 'Report_Settings', REPORT_DESCRIPTION: 'Report_Description', PARTICIPANTS: 'Participants', @@ -388,6 +389,7 @@ const SCREENS = { }, WORKSPACE_CONFIRMATION: {ROOT: 'Workspace_Confirmation_Root'}, + WORKSPACE_DUPLICATE: {ROOT: 'Workspace_Duplicate_Root', SELECT_FEATURES: 'Workspace_Duplicate_Select_Features'}, WORKSPACES_LIST: 'Workspaces_List', diff --git a/src/components/WorkspaceConfirmationForm.tsx b/src/components/WorkspaceConfirmationForm.tsx index d789e153755f0..31a933b66fde4 100644 --- a/src/components/WorkspaceConfirmationForm.tsx +++ b/src/components/WorkspaceConfirmationForm.tsx @@ -4,9 +4,11 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceConfirmationAvatar from '@hooks/useWorkspaceConfirmationAvatar'; import {generateDefaultWorkspaceName, generatePolicyID} from '@libs/actions/Policy/Policy'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import {addErrorMessage} from '@libs/ErrorUtils'; +import getFirstAlphaNumericCharacter from '@libs/getFirstAlphaNumericCharacter'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {isRequiredFulfilled} from '@libs/ValidationUtils'; @@ -14,7 +16,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WorkspaceConfirmationForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import Avatar from './Avatar'; import AvatarWithImagePicker from './AvatarWithImagePicker'; import CurrencyPicker from './CurrencyPicker'; import FormProvider from './Form/FormProvider'; @@ -26,13 +27,6 @@ import ScrollView from './ScrollView'; import Text from './Text'; import TextInput from './TextInput'; -function getFirstAlphaNumericCharacter(str = '') { - return str - .normalize('NFD') - .replace(/[^0-9a-z]/gi, '') - .toUpperCase()[0]; -} - type WorkspaceConfirmationSubmitFunctionParams = { name: string; currency: string; @@ -100,22 +94,12 @@ function WorkspaceConfirmationForm({onSubmit, policyOwnerEmail = '', onBackButto const stashedLocalAvatarImage = workspaceAvatar?.avatarUri ?? undefined; - const DefaultAvatar = useCallback( - () => ( - - ), - [workspaceAvatar?.avatarUri, workspaceNameFirstCharacter, styles.alignSelfCenter, styles.avatarXLarge, policyID], - ); + const DefaultAvatar = useWorkspaceConfirmationAvatar({ + policyID, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if left side can be empty string + source: stashedLocalAvatarImage || getDefaultWorkspaceAvatar(workspaceNameFirstCharacter), + name: workspaceNameFirstCharacter, + }); return ( <> diff --git a/src/hooks/useWorkspaceConfirmationAvatar.tsx b/src/hooks/useWorkspaceConfirmationAvatar.tsx new file mode 100644 index 0000000000000..90967f4a92e96 --- /dev/null +++ b/src/hooks/useWorkspaceConfirmationAvatar.tsx @@ -0,0 +1,28 @@ +import React, {useCallback} from 'react'; +import Avatar from '@components/Avatar'; +import * as Expensicons from '@components/Icon/Expensicons'; +import type {AvatarSource} from '@libs/UserUtils'; +import CONST from '@src/CONST'; +import useThemeStyles from './useThemeStyles'; + +function useWorkspaceConfirmationAvatar({policyID, source, name}: {policyID: string | undefined; source: AvatarSource; name: string}) { + const styles = useThemeStyles(); + + return useCallback( + () => ( + + ), + [name, policyID, source, styles.alignSelfCenter, styles.avatarXLarge], + ); +} + +export default useWorkspaceConfirmationAvatar; diff --git a/src/languages/de.ts b/src/languages/de.ts index 0b679990bc6d0..5c0fe3af7ba2b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -320,6 +320,7 @@ const translations = { count: 'Zählen', cancel: 'Abbrechen', dismiss: 'Verwerfen', + proceed: 'Fortfahren', yes: 'Ja', no: 'No', ok: 'OK', @@ -3460,12 +3461,14 @@ const translations = { customField1: 'Benutzerdefiniertes Feld 1', customField2: 'Benutzerdefiniertes Feld 2', customFieldHint: 'Fügen Sie benutzerdefinierten Code hinzu, der für alle Ausgaben dieses Mitglieds gilt.', + reports: 'Berichte', reportFields: 'Berichtsfelder', reportTitle: 'Berichtstitel', reportField: 'Berichtsfeld', taxes: 'Steuern', bills: 'Rechnungen', invoices: 'Rechnungen', + perDiem: 'Per diem', travel: 'Reisen', members: 'Mitglieder', accounting: 'Buchhaltung', @@ -3478,6 +3481,7 @@ const translations = { testTransactions: 'Transaktionen testen', issueAndManageCards: 'Karten ausstellen und verwalten', reconcileCards: 'Karten abstimmen', + selectAll: 'Alle auswählen', selected: () => ({ one: '1 ausgewählt', other: (count: number) => `${count} ausgewählt`, @@ -3491,6 +3495,8 @@ const translations = { memberNotFound: 'Mitglied nicht gefunden. Um ein neues Mitglied zum Arbeitsbereich einzuladen, verwenden Sie bitte die Einladungsschaltfläche oben.', notAuthorized: `Sie haben keinen Zugriff auf diese Seite. Wenn Sie versuchen, diesem Arbeitsbereich beizutreten, bitten Sie einfach den Besitzer des Arbeitsbereichs, Sie als Mitglied hinzuzufügen. Etwas anderes? Kontaktieren Sie ${CONST.EMAIL.CONCIERGE}.`, goToWorkspace: 'Zum Arbeitsbereich gehen', + duplicateWorkspace: 'Arbeitsbereich duplizieren', + duplicateWorkspacePrefix: 'Duplizieren', goToWorkspaces: 'Zu Arbeitsbereichen gehen', clearFilter: 'Filter löschen', workspaceName: 'Arbeitsbereichsname', @@ -4899,6 +4905,18 @@ const translations = { taxCode: 'Steuercode', updateTaxCodeFailureMessage: 'Beim Aktualisieren des Steuercodes ist ein Fehler aufgetreten, bitte versuchen Sie es erneut.', }, + duplicateWorkspace: { + title: 'Benennen Sie Ihren neuen Arbeitsbereich', + selectFeatures: 'Auswählen der zu kopierenden Features', + whichFeatures: 'Welche Funktionen möchten Sie in Ihren neuen Arbeitsbereich kopieren?', + confirmDuplicate: '\n\nMöchten Sie fortfahren?', + categories: 'Kategorien und Ihre Auto-Kategorisierungsregeln', + reimbursementAccount: 'Erstattungskonto', + delayedSubmission: 'verspätete Einreichung', + welcomeNote: 'Bitte beginnen Sie mit der Nutzung meines neuen Arbeitsbereichs', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `Sie sind dabei, ${newWorkspaceName ?? ''} zu erstellen und mit ${totalMembers ?? 0} Mitgliedern aus dem ursprünglichen Arbeitsbereich zu teilen.`, + }, emptyWorkspace: { title: 'Sie haben keine Arbeitsbereiche', subtitle: 'Verfolgen Sie Belege, erstatten Sie Ausgaben, verwalten Sie Reisen, senden Sie Rechnungen und mehr.', diff --git a/src/languages/en.ts b/src/languages/en.ts index a436b5a5d4e2b..f1dd43658dfb5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -312,6 +312,7 @@ const translations = { count: 'Count', cancel: 'Cancel', dismiss: 'Dismiss', + proceed: 'Proceed', yes: 'Yes', no: 'No', ok: 'OK', @@ -3457,12 +3458,14 @@ const translations = { customField1: 'Custom field 1', customField2: 'Custom field 2', customFieldHint: 'Add custom coding that applies to all spend from this member.', + reports: 'Reports', reportFields: 'Report fields', reportTitle: 'Report title', reportField: 'Report field', taxes: 'Taxes', bills: 'Bills', invoices: 'Invoices', + perDiem: 'Per diem', travel: 'Travel', members: 'Members', accounting: 'Accounting', @@ -3475,6 +3478,7 @@ const translations = { testTransactions: 'Test transactions', issueAndManageCards: 'Issue and manage cards', reconcileCards: 'Reconcile cards', + selectAll: 'Select all', selected: () => ({ one: '1 selected', other: (count: number) => `${count} selected`, @@ -3488,6 +3492,8 @@ const translations = { memberNotFound: 'Member not found. To invite a new member to the workspace, please use the invite button above.', notAuthorized: `You don't have access to this page. If you're trying to join this workspace, just ask the workspace owner to add you as a member. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}.`, goToWorkspace: 'Go to workspace', + duplicateWorkspace: 'Duplicate Workspace', + duplicateWorkspacePrefix: 'Duplicate', goToWorkspaces: 'Go to workspaces', clearFilter: 'Clear filter', workspaceName: 'Workspace name', @@ -4882,6 +4888,18 @@ const translations = { taxCode: 'Tax code', updateTaxCodeFailureMessage: 'An error occurred while updating the tax code, please try again', }, + duplicateWorkspace: { + title: 'Name your new workspace', + selectFeatures: 'Select features to copy', + whichFeatures: 'Which features do you want to copy over to your new workspace?', + confirmDuplicate: '\n\nDo you want to continue?', + categories: 'categories and your auto-categorization rules', + reimbursementAccount: 'reimbursement account', + welcomeNote: 'Please start using my new workspace', + delayedSubmission: 'delayed submission', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `You’re about to create and share ${newWorkspaceName ?? ''} with ${totalMembers ?? 0} members from the original workspace.`, + }, emptyWorkspace: { title: 'You have no workspaces', subtitle: 'Track receipts, reimburse expenses, manage travel, send invoices, and more.', diff --git a/src/languages/es.ts b/src/languages/es.ts index ab5b7d735ab71..082eb66a269f7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -302,6 +302,7 @@ const translations = { count: 'Contar', cancel: 'Cancelar', dismiss: 'Descartar', + proceed: 'Proceed', yes: 'Sí', no: 'No', ok: 'OK', @@ -3445,11 +3446,13 @@ const translations = { customField1: 'Campo personalizado 1', customField2: 'Campo personalizado 2', customFieldHint: 'Añade una codificación personalizada que se aplique a todos los gastos de este miembro.', + reports: 'Informes', reportFields: 'Campos de informe', reportTitle: 'El título del informe.', taxes: 'Impuestos', bills: 'Pagar facturas', invoices: 'Facturas', + perDiem: 'Per diem', travel: 'Viajes', members: 'Miembros', accounting: 'Contabilidad', @@ -3462,6 +3465,7 @@ const translations = { testTransactions: 'Transacciones de prueba', issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', + selectAll: 'Seleccionar todo', selected: () => ({ one: '1 seleccionado', other: (count: number) => `${count} seleccionados`, @@ -3475,6 +3479,8 @@ const translations = { memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón invitar que está arriba.', notAuthorized: `No tienes acceso a esta página. Si estás intentando unirte a este espacio de trabajo, pide al dueño del espacio de trabajo que te añada como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, goToWorkspace: 'Ir al espacio de trabajo', + duplicateWorkspace: 'Duplicar espacio de trabajo', + duplicateWorkspacePrefix: 'Duplicar', goToWorkspaces: 'Ir a espacios de trabajo', clearFilter: 'Borrar filtro', workspaceName: 'Nombre del espacio de trabajo', @@ -4892,6 +4898,18 @@ const translations = { taxCode: 'Código de impuesto', updateTaxCodeFailureMessage: 'Se produjo un error al actualizar el código tributario, inténtelo nuevamente', }, + duplicateWorkspace: { + title: 'Nombra tu nuevo espacio de trabajo', + selectFeatures: 'Selecciona las funciones a copiar', + whichFeatures: '¿Qué funciones deseas copiar a tu nuevo espacio de trabajo?', + confirmDuplicate: '\n\n¿Quieres continuar?', + categories: 'categorías y tus reglas de auto-categorización', + reimbursementAccount: 'cuenta de reembolso', + delayedSubmission: 'presentación retrasada', + welcomeNote: 'Por favor, comience a utilizar mi nuevo espacio de trabajo.', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `Estás a punto de crear y compartir ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} miembros del espacio de trabajo original.`, + }, emptyWorkspace: { title: 'No tienes espacios de trabajo', subtitle: 'Organiza recibos, reembolsa gastos, gestiona viajes, envía facturas y mucho más.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 20ac165a9497b..aef6f5e511866 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -320,6 +320,7 @@ const translations = { count: 'Compter', cancel: 'Annuler', dismiss: 'Ignorer', + proceed: 'Procéder', yes: 'Oui', no: 'No', ok: "D'accord", @@ -3468,12 +3469,14 @@ const translations = { customField1: 'Champ personnalisé 1', customField2: 'Champ personnalisé 2', customFieldHint: "Ajoutez un codage personnalisé qui s'applique à toutes les dépenses de ce membre.", + reports: 'Rapports', reportFields: 'Champs de rapport', reportTitle: 'Titre du rapport', reportField: 'Champ de rapport', taxes: 'Taxes', bills: 'Bills', invoices: 'Factures', + perDiem: 'Per diem', travel: 'Voyage', members: 'Membres', accounting: 'Comptabilité', @@ -3486,6 +3489,7 @@ const translations = { testTransactions: 'Tester les transactions', issueAndManageCards: 'Émettre et gérer des cartes', reconcileCards: 'Rapprocher les cartes', + selectAll: 'Sélectionner tout', selected: () => ({ one: '1 sélectionné', other: (count: number) => `${count} sélectionné(s)`, @@ -3499,6 +3503,8 @@ const translations = { memberNotFound: "Membre introuvable. Pour inviter un nouveau membre à l'espace de travail, veuillez utiliser le bouton d'invitation ci-dessus.", notAuthorized: `Vous n'avez pas accès à cette page. Si vous essayez de rejoindre cet espace de travail, demandez simplement au propriétaire de l'espace de travail de vous ajouter en tant que membre. Autre chose ? Contactez ${CONST.EMAIL.CONCIERGE}.`, goToWorkspace: "Aller à l'espace de travail", + duplicateWorkspace: 'Dupliquer l’espace de travail', + duplicateWorkspacePrefix: 'Dupliquer', goToWorkspaces: 'Aller aux espaces de travail', clearFilter: 'Effacer le filtre', workspaceName: "Nom de l'espace de travail", @@ -4915,6 +4921,18 @@ const translations = { taxCode: 'Code fiscal', updateTaxCodeFailureMessage: "Une erreur s'est produite lors de la mise à jour du code fiscal, veuillez réessayer.", }, + duplicateWorkspace: { + title: 'Nombra tu nuevo espacio de trabajo', + selectFeatures: 'Selecciona las funciones que quieres copiar', + whichFeatures: '¿Qué funciones quieres copiar a tu nuevo espacio de trabajo?', + confirmDuplicate: '\n\nVoulez-vous continuer?', + categories: 'Categorías y tus reglas de categorización automática', + reimbursementAccount: 'Cuenta de reembolso', + delayedSubmission: 'Envío retrasado', + welcomeNote: 'Empieza a usar mi nuevo espacio de trabajo', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `Vous êtes sur le point de créer et de partager ${newWorkspaceName ?? ''} avec ${totalMembers ?? 0} membres de l'espace de travail d'origine.`, + }, emptyWorkspace: { title: "Vous n'avez aucun espace de travail", subtitle: 'Suivez les reçus, remboursez les dépenses, gérez les déplacements, envoyez des factures, et plus encore.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 7582da608e59e..68cfc3b25dc0d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -320,6 +320,7 @@ const translations = { count: 'Contare', cancel: 'Annulla', dismiss: 'Ignora', + proceed: 'Proceed', yes: 'Sì', no: 'No', ok: 'OK', @@ -3473,12 +3474,14 @@ const translations = { customField1: 'Campo personalizzato 1', customField2: 'Campo personalizzato 2', customFieldHint: 'Aggiungi una codifica personalizzata che si applica a tutte le spese di questo membro.', + reports: 'Rapporti', reportFields: 'Campi del rapporto', reportTitle: 'Titolo del rapporto', reportField: 'Campo del report', taxes: 'Tasse', bills: 'Fatture', invoices: 'Fatture', + perDiem: 'Per diem', travel: 'Viaggio', members: 'Membri', accounting: 'Contabilità', @@ -3491,6 +3494,7 @@ const translations = { testTransactions: 'Transazioni di prova', issueAndManageCards: 'Emetti e gestisci carte', reconcileCards: 'Riconcilia carte', + selectAll: 'Seleziona tutto', selected: () => ({ one: '1 selezionato', other: (count: number) => `${count} selezionati`, @@ -3505,6 +3509,8 @@ const translations = { notAuthorized: `Non hai accesso a questa pagina. Se stai cercando di unirti a questo spazio di lavoro, chiedi semplicemente al proprietario dello spazio di lavoro di aggiungerti come membro. Qualcos'altro? Contatta ${CONST.EMAIL.CONCIERGE}.`, goToWorkspace: 'Vai allo spazio di lavoro', goToWorkspaces: 'Vai agli spazi di lavoro', + duplicateWorkspace: 'Area di lavoro duplicata', + duplicateWorkspacePrefix: 'Duplicate', clearFilter: 'Cancella filtro', workspaceName: 'Nome del workspace', workspaceOwner: 'Proprietario', @@ -4914,6 +4920,18 @@ const translations = { taxCode: 'Codice fiscale', updateTaxCodeFailureMessage: "Si è verificato un errore durante l'aggiornamento del codice fiscale, riprova.", }, + duplicateWorkspace: { + title: 'Assegna un nome al tuo nuovo spazio di lavoro', + selectFeatures: 'Seleziona le funzionalità da copiare', + whichFeatures: 'Quali funzionalità vuoi copiare nel tuo nuovo spazio di lavoro?', + confirmDuplicate: '\n\nVuoi continuare?', + categories: 'Categorie e regole di categorizzazione automatica', + reimbursementAccount: 'Account di rimborso', + delayedSubmission: 'Invio ritardato', + welcomeNote: 'Inizia a utilizzare il mio nuovo spazio di lavoro', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `Stai per creare e condividere ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} membri dall'area di lavoro originale.`, + }, emptyWorkspace: { title: 'Non hai spazi di lavoro', subtitle: 'Traccia ricevute, rimborsa spese, gestisci viaggi, invia fatture e altro ancora.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9d07d3cac601a..50d094fe98214 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -320,6 +320,7 @@ const translations = { count: 'カウント', cancel: 'キャンセル', dismiss: '却下する', + proceed: 'Proceed', yes: 'はい', no: 'いいえ', ok: 'OK', @@ -3473,12 +3474,14 @@ const translations = { customField1: 'カスタムフィールド1', customField2: 'カスタムフィールド2', customFieldHint: 'このメンバーのすべての支出に適用されるカスタムコーディングを追加します。', + reports: 'レポート', reportFields: 'レポートフィールド', reportTitle: 'レポートタイトル', reportField: 'レポートフィールド', taxes: '税金', bills: '請求書', invoices: '請求書', + perDiem: 'Per diem', travel: '旅行', members: 'メンバー', accounting: '会計', @@ -3491,6 +3494,7 @@ const translations = { testTransactions: 'トランザクションをテストする', issueAndManageCards: 'カードの発行と管理', reconcileCards: 'カードを照合する', + selectAll: 'すべて選択', selected: () => ({ one: '1 件選択済み', other: (count: number) => `${count} 件選択済み`, @@ -3504,6 +3508,8 @@ const translations = { memberNotFound: 'メンバーが見つかりません。新しいメンバーをワークスペースに招待するには、上の招待ボタンを使用してください。', notAuthorized: `このページにアクセスする権限がありません。このワークスペースに参加しようとしている場合は、ワークスペースのオーナーにメンバーとして追加してもらってください。他に何かお困りですか?${CONST.EMAIL.CONCIERGE}にお問い合わせください。`, goToWorkspace: 'ワークスペースに移動', + duplicateWorkspace: 'ワークスペースの複製', + duplicateWorkspacePrefix: '重複', goToWorkspaces: 'ワークスペースに移動', clearFilter: 'フィルターをクリア', workspaceName: 'ワークスペース名', @@ -4893,6 +4899,18 @@ const translations = { taxCode: '税コード', updateTaxCodeFailureMessage: '税コードの更新中にエラーが発生しました。もう一度お試しください。', }, + duplicateWorkspace: { + title: '新しいワークスペースに名前を付けてください', + selectFeatures: 'コピーする機能を選択してください', + whichFeatures: '新しいワークスペースにコピーする機能はどれですか?', + confirmDuplicate: '\n\n続行しますか?', + categories: 'カテゴリと自動分類ルール', + reimbursementAccount: '払い戻し口座', + delayedSubmission: '遅延送信', + welcomeNote: '新しいワークスペースの使用を開始してください', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `${newWorkspaceName ?? ''} を作成し、元のワークスペースの ${totalMembers ?? 0} 人のメンバーと共有しようとしています。`, + }, emptyWorkspace: { title: 'ワークスペースがありません', subtitle: '領収書の管理、経費精算、出張管理、請求書の送信などができます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3848078458175..aabcb2c71b101 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -320,6 +320,7 @@ const translations = { count: 'Aantal', cancel: 'Annuleren', dismiss: 'Verwijderen', + proceed: 'Proceed', yes: 'Ja', no: 'No', ok: 'OK', @@ -3480,12 +3481,14 @@ const translations = { customField1: 'Aangepast veld 1', customField2: 'Aangepast veld 2', customFieldHint: 'Voeg aangepaste codering toe die van toepassing is op alle uitgaven van dit lid.', + reports: 'Rapporten', reportFields: 'Rapportvelden', reportTitle: 'Rapporttitel', reportField: 'Rapportveld', taxes: 'Belastingen', bills: 'Rekeningen', invoices: 'Facturen', + perDiem: 'Per diem', travel: 'Reis', members: 'Leden', accounting: 'Boekhouding', @@ -3498,6 +3501,7 @@ const translations = { testTransactions: 'Testtransacties', issueAndManageCards: 'Kaarten uitgeven en beheren', reconcileCards: 'Reconcileer kaarten', + selectAll: 'Alles selecteren', selected: () => ({ one: '1 geselecteerd', other: (count: number) => `${count} geselecteerd`, @@ -3511,6 +3515,8 @@ const translations = { memberNotFound: 'Lid niet gevonden. Om een nieuw lid aan de werkruimte toe te voegen, gebruik de uitnodigingsknop hierboven.', notAuthorized: `Je hebt geen toegang tot deze pagina. Als je probeert lid te worden van deze werkruimte, vraag dan de eigenaar van de werkruimte om je als lid toe te voegen. Iets anders? Neem contact op met ${CONST.EMAIL.CONCIERGE}.`, goToWorkspace: 'Ga naar werkruimte', + duplicateWorkspace: 'Dubbele werkruimte', + duplicateWorkspacePrefix: 'Duplicaat', goToWorkspaces: 'Ga naar werkruimtes', clearFilter: 'Filter wissen', workspaceName: 'Werkruimte naam', @@ -4916,6 +4922,18 @@ const translations = { taxCode: 'Belastingcode', updateTaxCodeFailureMessage: 'Er is een fout opgetreden bij het bijwerken van de belastingcode, probeer het opnieuw.', }, + duplicateWorkspace: { + title: 'Geef je nieuwe werkruimte een naam', + selectFeatures: 'Selecteer te kopiëren functies', + whichFeatures: 'Welke functies wil je kopiëren naar je nieuwe werkruimte?', + confirmDuplicate: '\n\nWil je doorgaan?', + categories: 'categorieën en je regels voor automatische categorisatie', + reimbursementAccount: 'vergoedingsrekening', + delayedSubmission: 'vertraagde indiening', + welcomeNote: 'Ga aan de slag met mijn nieuwe werkruimte', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `Je staat op het punt om ${newWorkspaceName ?? ''} te maken en te delen met ${totalMembers ?? 0} leden uit de oorspronkelijke werkruimte.`, + }, emptyWorkspace: { title: 'Je hebt geen werkruimtes', subtitle: 'Beheer bonnetjes, vergoed uitgaven, regel reizen, verstuur facturen en meer.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 71413a9ef6235..6f708bdefc269 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -320,6 +320,7 @@ const translations = { count: 'Liczba', cancel: 'Anuluj', dismiss: 'Odrzuć', + proceed: 'Proceed', yes: 'Tak', no: 'Nie', ok: 'OK', @@ -3473,6 +3474,7 @@ const translations = { customField1: 'Pole niestandardowe 1', customField2: 'Pole niestandardowe 2', customFieldHint: 'Dodaj niestandardowe kodowanie, które dotyczy wszystkich wydatków tego członka.', + reports: 'Raporty', reportFields: 'Pola raportu', reportTitle: 'Tytuł raportu', reportField: 'Pole raportu', @@ -3480,6 +3482,7 @@ const translations = { bills: 'Rachunki', invoices: 'Faktury', travel: 'Podróżować', + perDiem: 'Per diem', members: 'Członkowie', accounting: 'Księgowość', receiptPartners: 'Partnerzy paragonów', @@ -3491,6 +3494,7 @@ const translations = { testTransactions: 'Przetestuj transakcje', issueAndManageCards: 'Wydawaj i zarządzaj kartami', reconcileCards: 'Uzgodnij karty', + selectAll: 'Wybierz wszystkie', selected: () => ({ one: '1 wybrano', other: (count: number) => `${count} wybrano`, @@ -3504,6 +3508,8 @@ const translations = { memberNotFound: 'Nie znaleziono członka. Aby zaprosić nowego członka do przestrzeni roboczej, użyj przycisku zaproszenia powyżej.', notAuthorized: `Nie masz dostępu do tej strony. Jeśli próbujesz dołączyć do tego miejsca pracy, poproś właściciela miejsca pracy o dodanie Cię jako członka. Coś innego? Skontaktuj się z ${CONST.EMAIL.CONCIERGE}.`, goToWorkspace: 'Przejdź do przestrzeni roboczej', + duplicateWorkspace: 'Duplikat obszaru roboczego', + duplicateWorkspacePrefix: 'Duplikat', goToWorkspaces: 'Przejdź do przestrzeni roboczych', clearFilter: 'Wyczyść filtr', workspaceName: 'Nazwa przestrzeni roboczej', @@ -4905,6 +4911,18 @@ const translations = { taxCode: 'Kod podatkowy', updateTaxCodeFailureMessage: 'Wystąpił błąd podczas aktualizacji kodu podatkowego, spróbuj ponownie.', }, + duplicateWorkspace: { + title: 'Nazwij swój nowy obszar roboczy', + selectFeatures: 'Wybierz funkcje do skopiowania', + whichFeatures: 'Które funkcje chcesz skopiować do nowego obszaru roboczego?', + confirmDuplicate: '\n\nCzy chcesz kontynuować?', + categories: 'kategorie i zasady automatycznej kategoryzacji', + reimbursementAccount: 'konto zwrotu', + delayedSubmission: 'opóźnione przesłanie', + welcomeNote: 'Proszę rozpocząć korzystanie z mojego nowego obszaru roboczego', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `Zamierzasz utworzyć i udostępnić ${newWorkspaceName ?? ''} członkom ${totalMembers ?? 0} z oryginalnej przestrzeni roboczej.`, + }, emptyWorkspace: { title: 'Nie masz żadnych przestrzeni roboczych', subtitle: 'Śledź paragony, zwracaj wydatki, zarządzaj podróżami, wysyłaj faktury i nie tylko.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 491de76466919..593093dcf978a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -320,6 +320,7 @@ const translations = { count: 'Contagem', cancel: 'Cancelar', dismiss: 'Dispensar', + proceed: 'Proceed', yes: 'Sim', no: 'Não', ok: 'OK', @@ -3478,12 +3479,14 @@ const translations = { customField1: 'Campo personalizado 1', customField2: 'Campo personalizado 2', customFieldHint: 'Adicione uma codificação personalizada que se aplique a todos os gastos deste membro.', + reports: 'Relatórios', reportFields: 'Campos do relatório', reportTitle: 'Título do relatório', reportField: 'Campo de relatório', taxes: 'Impostos', bills: 'Faturas', invoices: 'Faturas', + perDiem: 'Per diem', travel: 'Viagem', members: 'Membros', accounting: 'Contabilidade', @@ -3496,6 +3499,7 @@ const translations = { testTransactions: 'Testar transações', issueAndManageCards: 'Emitir e gerenciar cartões', reconcileCards: 'Conciliar cartões', + selectAll: 'Selecionar todos', selected: () => ({ one: '1 selecionado', other: (count: number) => `${count} selecionado(s)`, @@ -3509,6 +3513,8 @@ const translations = { memberNotFound: 'Membro não encontrado. Para convidar um novo membro para o espaço de trabalho, por favor, use o botão de convite acima.', notAuthorized: `Você não tem acesso a esta página. Se você está tentando entrar neste espaço de trabalho, basta pedir ao proprietário do espaço de trabalho para adicioná-lo como membro. Algo mais? Entre em contato com ${CONST.EMAIL.CONCIERGE}.`, goToWorkspace: 'Ir para o espaço de trabalho', + duplicateWorkspace: 'Espaço de trabalho duplicado', + duplicateWorkspacePrefix: 'Duplicado', goToWorkspaces: 'Ir para espaços de trabalho', clearFilter: 'Limpar filtro', workspaceName: 'Nome do espaço de trabalho', @@ -4911,6 +4917,18 @@ const translations = { taxCode: 'Código fiscal', updateTaxCodeFailureMessage: 'Ocorreu um erro ao atualizar o código de imposto, por favor, tente novamente.', }, + duplicateWorkspace: { + title: 'Nomeie seu novo espaço de trabalho', + selectFeatures: 'Selecione os recursos a serem copiados', + whichFeatures: 'Quais recursos você deseja copiar para o seu novo espaço de trabalho?', + confirmDuplicate: '\n\nVocê quer continuar?', + categories: 'categorias e suas regras de categorização automática', + reimbursementAccount: 'conta de reembolso', + delayedSubmission: 'envio atrasado', + welcomeNote: 'Comece a usar meu novo espaço de trabalho', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `Você está prestes a criar e compartilhar ${newWorkspaceName ?? ''} com ${totalMembers ?? 0} membros do espaço de trabalho original.`, + }, emptyWorkspace: { title: 'Você não tem espaços de trabalho', subtitle: 'Acompanhe recibos, reembolse despesas, gerencie viagens, envie faturas e muito mais.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3a1dd75d268fb..20f9408013821 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -320,6 +320,7 @@ const translations = { count: '计数', cancel: '取消', dismiss: '忽略', + proceed: 'Proceed', yes: '是的', no: '不', ok: '好的', @@ -3429,12 +3430,14 @@ const translations = { customField1: '自定义字段 1', customField2: '自定义字段2', customFieldHint: '添加适用于该成员所有支出的自定义编码。', + reports: '报告', reportFields: '报告字段', reportTitle: '报告标题', reportField: '报告字段', taxes: '税款', bills: '账单', invoices: '发票', + perDiem: 'Per diem', travel: '旅行', members: '成员', accounting: '会计', @@ -3447,6 +3450,7 @@ const translations = { testTransactions: '测试交易', issueAndManageCards: '发行和管理卡片', reconcileCards: '对账卡片', + selectAll: '全选', selected: () => ({ one: '1 已选择', other: (count: number) => `已选择${count}个`, @@ -3460,6 +3464,8 @@ const translations = { memberNotFound: '未找到成员。要邀请新成员加入工作区,请使用上面的邀请按钮。', notAuthorized: `您无权访问此页面。如果您正在尝试加入此工作区,请请求工作区所有者将您添加为成员。还有其他问题?请联系${CONST.EMAIL.CONCIERGE}。`, goToWorkspace: '前往工作区', + duplicateWorkspace: '重复工作区', + duplicateWorkspacePrefix: '复制', goToWorkspaces: '前往工作区', clearFilter: '清除筛选器', workspaceName: '工作区名称', @@ -4827,6 +4833,18 @@ const translations = { taxCode: '税码', updateTaxCodeFailureMessage: '更新税码时发生错误,请重试', }, + duplicateWorkspace: { + title: '命名您的新工作区', + selectFeatures: '选择要复制的功能', + whichFeatures: '您想要将哪些功能复制到您的新工作区?', + confirmDuplicate: '\n\n您想继续吗?', + categories: '类别和您的自动分类规则', + reimbursementAccount: '报销账户', + delayedSubmission: '延迟提交', + welcomeNote: '请开始使用我的新工作区', + confirmTitle: ({newWorkspaceName, totalMembers}: {newWorkspaceName?: string; totalMembers?: number}) => + `您即将创建并与原始工作区中的 ${totalMembers ?? 0} 名成员共享 ${newWorkspaceName ?? ''}。`, + }, emptyWorkspace: { title: '您没有任何工作区', subtitle: '跟踪收据、报销费用、管理差旅、发送发票等。', diff --git a/src/libs/API/parameters/DuplicateWorkspaceParams.ts b/src/libs/API/parameters/DuplicateWorkspaceParams.ts new file mode 100644 index 0000000000000..8dc8966550d5a --- /dev/null +++ b/src/libs/API/parameters/DuplicateWorkspaceParams.ts @@ -0,0 +1,20 @@ +import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; + +type DuplicateWorkspaceParams = { + policyID: string; + targetPolicyID: string; + policyName: string; + parts: string; + announceChatReportID: string; + adminsChatReportID: string; + welcomeNote: string; + expenseChatReportID: string; + adminsCreatedReportActionID: string; + expenseCreatedReportActionID: string; + announceChatReportActionID: string; + customUnitID: string; + customUnitRateID: string; + file?: FileObject; +}; + +export default DuplicateWorkspaceParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 484ce96f5d7be..335dedcbedf02 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -80,6 +80,7 @@ export type {default as SignInWithSupportAuthTokenParams} from './SignInWithSupp export type {default as UnlinkLoginParams} from './UnlinkLoginParams'; export type {default as UpdateAutomaticTimezoneParams} from './UpdateAutomaticTimezoneParams'; export type {default as UpdateChatPriorityModeParams} from './UpdateChatPriorityModeParams'; +export type {default as DuplicateWorkspaceParams} from './DuplicateWorkspaceParams'; export type {default as UpdateDateOfBirthParams} from './UpdateDateOfBirthParams'; export type {default as UpdateDisplayNameParams} from './UpdateDisplayNameParams'; export type {default as UpdateChatNameParams} from './UpdateChatNameParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 3e7631872854b..9f01ed916b9c5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -147,6 +147,7 @@ const WRITE_COMMANDS = { UPDATE_WORKSPACE_DESCRIPTION: 'UpdateWorkspaceDescription', UPDATE_WORKSPACE_MEMBERS_ROLE: 'UpdateWorkspaceMembersRole', CREATE_WORKSPACE: 'CreateWorkspace', + DUPLICATE_POLICY: 'DuplicatePolicy', CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment', UPDATE_POLICY_MEMBERS_CUSTOM_FIELDS: 'UpdatePolicyMembersCustomFields', SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', @@ -841,6 +842,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams; [WRITE_COMMANDS.PAY_INVOICE]: Parameters.PayInvoiceParams; [WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams; + [WRITE_COMMANDS.DUPLICATE_POLICY]: Parameters.DuplicateWorkspaceParams; [WRITE_COMMANDS.MERGE_DUPLICATES]: Parameters.MergeDuplicatesParams; [WRITE_COMMANDS.RESOLVE_DUPLICATES]: Parameters.ResolveDuplicatesParams; [WRITE_COMMANDS.MERGE_TRANSACTION]: Parameters.MergeTransactionParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 09d14e538af6b..529a488538250 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -41,6 +41,7 @@ import type { TravelNavigatorParamList, WalletStatementNavigatorParamList, WorkspaceConfirmationNavigatorParamList, + WorkspaceDuplicateNavigatorParamList, } from '@navigation/types'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; @@ -185,6 +186,11 @@ const WorkspaceConfirmationModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceConfirmationPage').default, }); +const WorkspaceDuplicateModalStackNavigator = createModalStackNavigator({ + [SCREENS.WORKSPACE_DUPLICATE.ROOT]: () => require('../../../../pages/workspace/duplicate/WorkspaceDuplicatePage').default, + [SCREENS.WORKSPACE_DUPLICATE.SELECT_FEATURES]: () => require('../../../../pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesPage').default, +}); + const TaskModalStackNavigator = createModalStackNavigator({ [SCREENS.TASK.TITLE]: () => require('../../../../pages/tasks/TaskTitlePage').default, [SCREENS.TASK.ASSIGNEE]: () => require('../../../../pages/tasks/TaskAssigneeSelectorModal').default, @@ -857,6 +863,7 @@ export { MissingPersonalDetailsModalStackNavigator, DebugModalStackNavigator, WorkspaceConfirmationModalStackNavigator, + WorkspaceDuplicateModalStackNavigator, ConsoleModalStackNavigator, AddUnreportedExpenseModalStackNavigator, ScheduleCallModalStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 0d2eaca5a5693..6dc900dd2fb07 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -148,6 +148,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.WORKSPACE_CONFIRMATION} component={ModalStackNavigators.WorkspaceConfirmationModalStackNavigator} /> + ['config'] = { [SCREENS.WORKSPACE_CONFIRMATION.ROOT]: ROUTES.WORKSPACE_CONFIRMATION.route, }, }, + [SCREENS.RIGHT_MODAL.WORKSPACE_DUPLICATE]: { + screens: { + [SCREENS.WORKSPACE_DUPLICATE.ROOT]: ROUTES.WORKSPACE_DUPLICATE.route, + [SCREENS.WORKSPACE_DUPLICATE.SELECT_FEATURES]: ROUTES.WORKSPACE_DUPLICATE_SELECT_FEATURES.route, + }, + }, [SCREENS.RIGHT_MODAL.NEW_TASK]: { screens: { [SCREENS.NEW_TASK.ROOT]: ROUTES.NEW_TASK.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 9d1cb460d5e5b..eaa7219e0f00a 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1571,6 +1571,15 @@ type WorkspaceConfirmationNavigatorParamList = { }; }; +type WorkspaceDuplicateNavigatorParamList = { + [SCREENS.WORKSPACE_DUPLICATE.ROOT]: { + policyID: string; + }; + [SCREENS.WORKSPACE_DUPLICATE.SELECT_FEATURES]: { + policyID: string; + }; +}; + type NewTaskNavigatorParamList = { [SCREENS.NEW_TASK.ROOT]: { backTo?: Routes; @@ -1765,6 +1774,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.WORKSPACE_CONFIRMATION]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.WORKSPACE_DUPLICATE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.NEW_TASK]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TASK_DETAILS]: NavigatorScreenParams; @@ -2370,6 +2380,7 @@ export type { WorkspaceSplitNavigatorParamList, MigratedUserModalNavigatorParamList, WorkspaceConfirmationNavigatorParamList, + WorkspaceDuplicateNavigatorParamList, TwoFactorAuthNavigatorParamList, ConsoleNavigatorParamList, ScheduleCallParamList, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 3174782a94243..da6ba8e79fca5 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -15,6 +15,7 @@ import type { DeleteWorkspaceParams, DisablePolicyBillableModeParams, DowngradeToTeamParams, + DuplicateWorkspaceParams, EnablePolicyAutoApprovalOptionsParams, EnablePolicyAutoReimbursementLimitParams, EnablePolicyCompanyCardsParams, @@ -68,6 +69,7 @@ import type SetPolicyCashExpenseModeParams from '@libs/API/parameters/SetPolicyC import type UpdatePolicyMembersCustomFieldsParams from '@libs/API/parameters/UpdatePolicyMembersCustomFieldsParams'; import type {ApiRequestCommandParameters} from '@libs/API/types'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -81,7 +83,7 @@ import * as NumberUtils from '@libs/NumberUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; -import {goBackWhenEnableFeature, isControlPolicy, navigateToExpensifyCardPage} from '@libs/PolicyUtils'; +import {getMemberAccountIDsForWorkspace, goBackWhenEnableFeature, isControlPolicy, navigateToExpensifyCardPage} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {PolicySelector} from '@pages/home/sidebar/FloatingActionButtonAndPopover'; import type {Feature} from '@pages/OnboardingInterestedFeatures/types'; @@ -95,6 +97,7 @@ import CONST from '@src/CONST'; import type {OnboardingAccounting} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { + DuplicateWorkspace, IntroSelected, InvitedEmailsToAccountIDs, LastPaymentMethod, @@ -113,7 +116,7 @@ import type { TransactionViolations, } from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {Attributes, CompanyAddress, CustomUnit, NetSuiteCustomList, NetSuiteCustomSegment, ProhibitedExpenses, Rate, TaxRate, UberReceiptPartner} from '@src/types/onyx/Policy'; +import type {Attributes, CompanyAddress, CustomUnit, NetSuiteCustomList, NetSuiteCustomSegment, ProhibitedExpenses, Rate, TaxRate} from '@src/types/onyx/Policy'; import type {CustomFieldType} from '@src/types/onyx/PolicyEmployee'; import type {NotificationPreference} from '@src/types/onyx/Report'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -167,6 +170,15 @@ type BuildPolicyDataOptions = { lastUsedPaymentMethod?: LastPaymentMethodType; }; +type DuplicatePolicyDataOptions = { + policyName: string; + policyID?: string; + targetPolicyID?: string; + welcomeNote: string; + parts: Record; + file?: File | CustomRNImageManipulatorResult; +}; + const allPolicies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -1747,6 +1759,14 @@ function removeWorkspace(policyID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); } +function setDuplicateWorkspaceData(data: Partial) { + Onyx.merge(ONYXKEYS.DUPLICATE_WORKSPACE, {...data}); +} + +function clearDuplicateWorkspace() { + Onyx.set(ONYXKEYS.DUPLICATE_WORKSPACE, {}); +} + /** * Generate a policy name based on an email and policy list. * @param [email] the email to base the workspace name on. If not passed, will use the logged-in user's email instead @@ -2468,6 +2488,286 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy return params; } +function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOptions) { + const {policyName = '', policyID = generatePolicyID(), file, welcomeNote, parts, targetPolicyID = generatePolicyID()} = options; + + const { + adminsChatReportID, + adminsChatData, + adminsReportActionData, + adminsCreatedReportActionID, + expenseChatReportID, + expenseChatData, + expenseReportActionData, + expenseCreatedReportActionID, + pendingChatMembers, + } = ReportUtils.buildOptimisticWorkspaceChats(targetPolicyID, policyName); + const isMemberOptionSelected = parts?.people; + const isReportsOptionSelected = parts?.reports; + const isConnectionsOptionSelected = parts?.connections; + const isCategoriesOptionSelected = parts?.categories; + const isTaxesOptionSelected = parts?.taxes; + const isTagsOptionSelected = parts?.tags; + const isInvoicesOptionSelected = parts?.invoices; + const isCustomUnitsOptionSelected = parts?.customUnits; + const isRulesOptionSelected = parts?.expenses; + const isWorkflowsOptionSelected = parts?.exportLayouts; + const policyMemberAccountIDs = isMemberOptionSelected ? Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)) : []; + const {customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(policy?.outputCurrency); + + const optimisticAnnounceChat = ReportUtils.buildOptimisticAnnounceChat(targetPolicyID, [...policyMemberAccountIDs]); + const announceRoomChat = optimisticAnnounceChat.announceChatData; + + const optimisticCategoriesData = buildOptimisticPolicyCategories(targetPolicyID, Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); + + // WARNING: The data below should be kept in sync with the API so we create the policy with the correct configuration. + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY}${targetPolicyID}`, + value: { + ...policy, + areCategoriesEnabled: isCategoriesOptionSelected, + areTagsEnabled: isTagsOptionSelected, + areDistanceRatesEnabled: isCustomUnitsOptionSelected, + areInvoicesEnabled: isInvoicesOptionSelected, + areRulesEnabled: isRulesOptionSelected, + areWorkflowsEnabled: isWorkflowsOptionSelected, + areReportFieldsEnabled: isReportsOptionSelected, + areConnectionsEnabled: isConnectionsOptionSelected, + tax: isTaxesOptionSelected ? policy?.tax : undefined, + employeeList: isMemberOptionSelected ? policy.employeeList : {[policy.owner]: policy?.employeeList?.[policy.owner]}, + id: targetPolicyID, + name: policyName, + fieldList: isReportsOptionSelected ? policy?.fieldList : undefined, + connections: isConnectionsOptionSelected ? policy?.connections : undefined, + customUnits: isCustomUnitsOptionSelected ? policy?.customUnits : undefined, + taxRates: isTaxesOptionSelected ? policy?.taxRates : undefined, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + pendingFields: { + autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + name: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + outputCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + address: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + type: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + areReportFieldsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + avatarURL: file?.uri, + originalFileName: file?.name, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseChatReportID}`, + value: { + isOptimisticReport: true, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...adminsChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${adminsChatReportID}`, + value: { + pendingChatMembers, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: adminsReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...expenseChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: expenseReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${targetPolicyID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${expenseChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${adminsChatReportID}`, + value: null, + }, + ...announceRoomChat.onyxOptimisticData, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${targetPolicyID}`, + value: { + pendingAction: null, + pendingFields: { + autoReporting: null, + approvalMode: null, + reimbursementChoice: null, + name: null, + outputCurrency: null, + address: null, + description: null, + type: null, + areReportFieldsEnabled: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${adminsChatReportID}`, + value: { + isOptimisticReport: false, + pendingChatMembers: [], + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: { + [adminsCreatedReportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseChatReportID}`, + value: { + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: { + [expenseCreatedReportActionID]: { + pendingAction: null, + }, + }, + }, + ...announceRoomChat.onyxSuccessData, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${targetPolicyID}`, + value: {employeeList: null}, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: null, + }, + ...announceRoomChat.onyxFailureData, + ]; + + if (optimisticCategoriesData.optimisticData) { + optimisticData.push(...optimisticCategoriesData.optimisticData); + } + + if (optimisticCategoriesData.failureData) { + failureData.push(...optimisticCategoriesData.failureData); + } + + if (optimisticCategoriesData.successData) { + successData.push(...optimisticCategoriesData.successData); + } + + // We need to clone the file to prevent non-indexable errors. + const clonedFile = file ? createFile(file as File) : undefined; + + const params: DuplicateWorkspaceParams = { + policyID, + targetPolicyID, + adminsChatReportID, + expenseChatReportID, + policyName, + adminsCreatedReportActionID, + expenseCreatedReportActionID, + announceChatReportID: optimisticAnnounceChat.announceChatReportID, + announceChatReportActionID: optimisticAnnounceChat.announceChatReportActionID, + customUnitID, + parts: JSON.stringify(parts), + welcomeNote, + customUnitRateID, + file: clonedFile, + }; + + return {successData, optimisticData, failureData, params}; +} + +function duplicateWorkspace(policy: Policy, options: DuplicatePolicyDataOptions): DuplicateWorkspaceParams { + const {optimisticData, failureData, successData, params} = buildDuplicatePolicyData(policy, options); + + API.write(WRITE_COMMANDS.DUPLICATE_POLICY, params, {optimisticData, successData, failureData}); + + return params; +} + function openPolicyWorkflowsPage(policyID: string) { if (!policyID) { Log.warn('openPolicyWorkflowsPage invalid params', {policyID}); @@ -5950,6 +6250,8 @@ export { setPolicyMaxExpenseAge, updateCustomRules, setPolicyProhibitedExpense, + setDuplicateWorkspaceData, + clearDuplicateWorkspace, setPolicyBillableMode, disableWorkspaceBillableExpenses, setWorkspaceEReceiptsEnabled, @@ -5967,6 +6269,7 @@ export { clearBillingReceiptDetailsErrors, clearQuickbooksOnlineAutoSyncErrorField, setIsForcedToChangeCurrency, + duplicateWorkspace, removePolicyReceiptPartnersConnection, openPolicyReceiptPartnersPage, setIsComingFromGlobalReimbursementsFlow, diff --git a/src/libs/getFirstAlphaNumericCharacter.ts b/src/libs/getFirstAlphaNumericCharacter.ts new file mode 100644 index 0000000000000..94d7f5e43e1fc --- /dev/null +++ b/src/libs/getFirstAlphaNumericCharacter.ts @@ -0,0 +1,8 @@ +function getFirstAlphaNumericCharacter(str = '') { + return str + .normalize('NFD') + .replace(/[^0-9a-z]/gi, '') + .toUpperCase()[0]; +} + +export default getFirstAlphaNumericCharacter; diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 30ba4462c6f45..4cf5ca1e24e86 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,6 +1,6 @@ -import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; -import {FlatList, View} from 'react-native'; +import {useIsFocused, useRoute} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FlatList, InteractionManager, View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; @@ -35,7 +35,16 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import {calculateBillNewDot, clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace, updateDefaultPolicy} from '@libs/actions/Policy/Policy'; +import { + calculateBillNewDot, + clearDeleteWorkspaceError, + clearDuplicateWorkspace, + clearErrors, + deleteWorkspace, + leaveWorkspace, + removeWorkspace, + updateDefaultPolicy, +} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; @@ -97,6 +106,7 @@ function WorkspacesListPage() { const StyleUtils = useStyleUtils(); const {translate, localeCompare} = useLocalize(); const {isOffline} = useNetwork(); + const isFocused = useIsFocused(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS, {canBeMissing: true}); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); @@ -107,6 +117,7 @@ function WorkspacesListPage() { const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); + const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE, {canBeMissing: false}); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [policyIDToDelete, setPolicyIDToDelete] = useState(); @@ -126,6 +137,7 @@ function WorkspacesListPage() { selector: filterInactiveCards, canBeMissing: true, }); + const flatlistRef = useRef(null); const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, {canBeMissing: true}); // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 @@ -166,6 +178,8 @@ function WorkspacesListPage() { const isAdmin = isPolicyAdmin(item as unknown as PolicyType, session?.email); const isOwner = item.ownerAccountID === session?.accountID; const isDefault = activePolicyID === item.policyID; + const shouldAnimateInHighlight = duplicateWorkspace?.policyID === item.policyID; + const threeDotsMenuItems: PopoverMenuItem[] = [ { icon: Expensicons.Building, @@ -214,6 +228,14 @@ function WorkspacesListPage() { }); } + if (isAdmin) { + threeDotsMenuItems.push({ + icon: Expensicons.Copy, + text: translate('workspace.common.duplicateWorkspace'), + onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE.getRoute(item.policyID, ROUTES.WORKSPACES_LIST.route)) : undefined), + }); + } + if (!isDefault && !item?.isJoinRequestPending) { threeDotsMenuItems.push({ icon: Expensicons.Star, @@ -246,6 +268,7 @@ function WorkspacesListPage() { workspaceIcon={item.icon} ownerAccountID={item.ownerAccountID} workspaceType={item.type} + shouldAnimateInHighlight={shouldAnimateInHighlight} isJoinRequestPending={item?.isJoinRequestPending} rowStyles={hovered && styles.hoveredComponentBG} layoutWidth={isLessThanMediumScreen ? CONST.LAYOUT_WIDTH.NARROW : CONST.LAYOUT_WIDTH.WIDE} @@ -266,6 +289,7 @@ function WorkspacesListPage() { styles.mb2, styles.mh5, styles.ph5, + duplicateWorkspace?.policyID, styles.hoveredComponentBG, translate, styles.offlineFeedback.deleted, @@ -357,6 +381,19 @@ function WorkspacesListPage() { const sortWorkspace = useCallback((workspaceItems: WorkspaceItem[]) => workspaceItems.sort((a, b) => localeCompare(a.title, b.title)), [localeCompare]); const [inputValue, setInputValue, filteredWorkspaces] = useSearchResults(workspaces, filterWorkspace, sortWorkspace); + useEffect(() => { + if (isEmptyObject(duplicateWorkspace) || !filteredWorkspaces.length || !isFocused) { + return; + } + const duplicateWorkspaceIndex = filteredWorkspaces.findIndex((workspace) => workspace.policyID === duplicateWorkspace.policyID); + if (duplicateWorkspaceIndex > 0) { + flatlistRef.current?.scrollToIndex({index: duplicateWorkspaceIndex, animated: false}); + InteractionManager.runAfterInteractions(() => { + clearDuplicateWorkspace(); + }); + } + }, [duplicateWorkspace, isFocused, filteredWorkspaces]); + const listHeaderComponent = ( <> {isLessThanMediumScreen && } @@ -476,7 +513,14 @@ function WorkspacesListPage() { {!shouldUseNarrowLayout && {getHeaderButton()}} {shouldUseNarrowLayout && {getHeaderButton()}} { + flatlistRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true, + }); + }} renderItem={getMenuItem} ListHeaderComponent={listHeaderComponent} keyboardShouldPersistTaps="handled" diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx index 80a1762dba96e..201eb01f6cce9 100644 --- a/src/pages/workspace/WorkspacesListRow.tsx +++ b/src/pages/workspace/WorkspacesListRow.tsx @@ -2,6 +2,7 @@ import {Str} from 'expensify-common'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; +import Animated from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; import Avatar from '@components/Avatar'; import Badge from '@components/Badge'; @@ -16,6 +17,7 @@ import Tooltip from '@components/Tooltip'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import WorkspacesListRowDisplayName from '@components/WorkspacesListRowDisplayName'; +import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -74,6 +76,9 @@ type WorkspacesListRowProps = WithCurrentUserPersonalDetailsProps & { /** Whether the bill is loading */ isLoadingBill?: boolean; + /** Whether the list item is highlighted */ + shouldAnimateInHighlight?: boolean; + /** Function to reset loading spinner icon index */ resetLoadingSpinnerIconIndex?: () => void; }; @@ -116,6 +121,7 @@ function WorkspacesListRow({ rowStyles, style, brickRoadIndicator, + shouldAnimateInHighlight, shouldDisableThreeDotsMenu, isJoinRequestPending, policyID, @@ -126,10 +132,17 @@ function WorkspacesListRow({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const theme = useTheme(); const isNarrow = layoutWidth === CONST.LAYOUT_WIDTH.NARROW; const ownerDetails = ownerAccountID && getPersonalDetailsByIDs({accountIDs: [ownerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); const threeDotsMenuRef = useRef<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>(null); + const animatedHighlightStyle = useAnimatedHighlightStyle({ + borderRadius: variables.componentBorderRadius, + shouldHighlight: !!shouldAnimateInHighlight, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + }); useEffect(() => { if (isLoadingBill) { @@ -200,7 +213,7 @@ function WorkspacesListRow({ ); return ( - + @@ -273,7 +286,7 @@ function WorkspacesListRow({ {!isNarrow && ThreeDotMenuOrPendingIcon} - + ); } diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx new file mode 100644 index 0000000000000..3b19e8101c1d1 --- /dev/null +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx @@ -0,0 +1,162 @@ +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; +import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; +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 * as Expensicons from '@components/Icon/Expensicons'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceConfirmationAvatar from '@hooks/useWorkspaceConfirmationAvatar'; +import {generatePolicyID, setDuplicateWorkspaceData} from '@libs/actions/Policy/Policy'; +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; +import {addErrorMessage} from '@libs/ErrorUtils'; +import getFirstAlphaNumericCharacter from '@libs/getFirstAlphaNumericCharacter'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import {isRequiredFulfilled} from '@libs/ValidationUtils'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/WorkspaceDuplicateForm'; + +type WorkspaceDuplicateFormProps = { + policyID?: string; +}; + +function WorkspaceDuplicateForm({policyID}: WorkspaceDuplicateFormProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const policy = usePolicy(policyID); + const defaultWorkspaceName = `${policy?.name} (${translate('workspace.common.duplicateWorkspacePrefix')})`; + + const validate = useCallback( + (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const name = values.name.trim(); + + if (!isRequiredFulfilled(name)) { + errors.name = translate('workspace.editor.nameIsRequiredError'); + } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 + // code units. + addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', {length: [...name].length, limit: CONST.TITLE_CHARACTER_LIMIT})); + } + + return errors; + }, + [translate], + ); + + const onSubmit = useCallback( + ({name, avatarFile}: {name?: string; avatarFile?: File | CustomRNImageManipulatorResult}) => { + if (!policyID) { + return; + } + const newPolicyID = generatePolicyID(); + setDuplicateWorkspaceData({policyID: newPolicyID, name, file: avatarFile}); + Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE_SELECT_FEATURES.getRoute(policyID, ROUTES.WORKSPACES_LIST.route)); + }, + [policyID], + ); + + const [workspaceNameFirstCharacter, setWorkspaceNameFirstCharacter] = useState(defaultWorkspaceName ?? ''); + + const [workspaceAvatar, setWorkspaceAvatar] = useState<{avatarUri: string | null; avatarFileName?: string | null; avatarFileType?: string | null}>({ + avatarUri: null, + avatarFileName: null, + avatarFileType: null, + }); + const [avatarFile, setAvatarFile] = useState(); + + const stashedLocalAvatarImage = workspaceAvatar?.avatarUri ?? undefined; + + const DefaultAvatar = useWorkspaceConfirmationAvatar({ + policyID, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if left side can be empty string + source: stashedLocalAvatarImage || getDefaultWorkspaceAvatar(workspaceNameFirstCharacter), + name: workspaceNameFirstCharacter, + }); + + return ( + <> + + + + {translate('workspace.duplicateWorkspace.title')} + + { + setAvatarFile(image); + setWorkspaceAvatar({avatarUri: image.uri ?? '', avatarFileName: image.name ?? '', avatarFileType: image.type}); + }} + onImageRemoved={() => { + setAvatarFile(undefined); + setWorkspaceAvatar({avatarUri: null, avatarFileName: null, avatarFileType: null}); + }} + size={CONST.AVATAR_SIZE.X_LARGE} + avatarStyle={[styles.avatarXLarge, styles.alignSelfCenter]} + shouldDisableViewPhoto + editIcon={Expensicons.Camera} + editIconStyle={styles.smallEditIconAccount} + type={CONST.ICON_TYPE_WORKSPACE} + style={[styles.w100, styles.alignItemsCenter, styles.mv4, styles.mb6, styles.alignSelfCenter, styles.ph5]} + DefaultAvatar={DefaultAvatar} + editorMaskImage={Expensicons.ImageCropSquareMask} + /> + + onSubmit({ + name: val[INPUT_IDS.NAME], + avatarFile, + }) + } + enabledWhenOffline + addBottomSafeAreaPadding + > + + { + if (getFirstAlphaNumericCharacter(str) === getFirstAlphaNumericCharacter(workspaceNameFirstCharacter)) { + return; + } + setWorkspaceNameFirstCharacter(str); + }} + ref={inputCallbackRef} + /> + + + + + ); +} + +WorkspaceDuplicateForm.displayName = 'WorkspaceDuplicateForm'; + +export default WorkspaceDuplicateForm; diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx new file mode 100644 index 0000000000000..e9485b5000497 --- /dev/null +++ b/src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx @@ -0,0 +1,36 @@ +import {useRoute} from '@react-navigation/native'; +import React, {useEffect} from 'react'; +import ScreenWrapper from '@components/ScreenWrapper'; +import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; +import type {WorkspaceDuplicateNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {clearDuplicateWorkspace} from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import WorkspaceDuplicateForm from './WorkspaceDuplicateForm'; + +function WorkspaceDuplicatePage() { + const route = useRoute>(); + const policyID = route?.params?.policyID; + + useEffect(clearDuplicateWorkspace, []); + + return ( + + + + + + ); +} + +WorkspaceDuplicatePage.displayName = 'WorkspaceDuplicatePage'; + +export default WorkspaceDuplicatePage; diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx new file mode 100644 index 0000000000000..f3d548dce060a --- /dev/null +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -0,0 +1,351 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import SelectionList from '@components/SelectionList'; +import MultiSelectListItem from '@components/SelectionList/MultiSelectListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getDistanceRateCustomUnit, getMemberAccountIDsForWorkspace, getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import {getReportFieldsByPolicyID} from '@libs/ReportUtils'; +import Navigation from '@navigation/Navigation'; +import {openPolicyCategoriesPage} from '@userActions/Policy/Category'; +import {openPolicyDistanceRatesPage} from '@userActions/Policy/DistanceRate'; +import {openWorkspaceMembersPage} from '@userActions/Policy/Member'; +import {openPolicyPerDiemPage} from '@userActions/Policy/PerDiem'; +import {duplicateWorkspace as duplicateWorkspaceAction, openPolicyTaxesPage, openPolicyWorkflowsPage} from '@userActions/Policy/Policy'; +import {openPolicyReportFieldsPage} from '@userActions/Policy/ReportField'; +import {openPolicyTagsPage} from '@userActions/Policy/Tag'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Rate} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getAllValidConnectedIntegration, getWorkflowRules, getWorkspaceRules} from './utils'; + +type WorkspaceDuplicateFormProps = { + policyID?: string; +}; +const DEFAULT_SELECT_ALL = 'selectAll'; + +function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateFormProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE, {canBeMissing: false}); + const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); + const allIds = getMemberAccountIDsForWorkspace(policy?.employeeList); + const totalMembers = Object.keys(allIds).length; + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: false}); + const totalTags = Object.keys(policyTags ?? {}).length ?? 0; + const taxesLength = Object.keys(policy?.taxRates?.taxes ?? {}).length ?? 0; + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const categoriesCount = Object.keys(policyCategories ?? {}).length; + const [selectedItems, setSelectedItems] = useState([]); + const reportFields = Object.keys(getReportFieldsByPolicyID(policyID)).length ?? 0; + const customUnits = getPerDiemCustomUnit(policy); + const customUnitRates: Record = customUnits?.rates ?? {}; + const allRates = Object.values(customUnitRates)?.length; + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); + + const accountingIntegrations = Object.values(CONST.POLICY.CONNECTIONS.NAME); + const connectedIntegration = getAllValidConnectedIntegration(policy, accountingIntegrations); + + const customUnit = getDistanceRateCustomUnit(policy); + const ratesCount = Object.keys(customUnit?.rates ?? {}).length; + const invoiceCompany = + policy?.invoice?.companyName && policy?.invoice?.companyWebsite + ? `${policy?.invoice?.companyName}, ${policy?.invoice?.companyWebsite}` + : (policy?.invoice?.companyName ?? policy?.invoice?.companyWebsite ?? ''); + + const [street1, street2] = (policy?.address?.addressStreet ?? '').split('\n'); + const formattedAddress = + !isEmptyObject(policy) && !isEmptyObject(policy.address) + ? `, ${street1?.trim()}, ${street2 ? `${street2.trim()}, ` : ''}${policy.address.city}, ${policy.address.state} ${policy.address.zipCode ?? ''}` + : ''; + + const items = useMemo(() => { + const rules = getWorkspaceRules(policy, translate); + const workflows = getWorkflowRules(policy, translate); + + const result = [ + { + translation: translate('workspace.common.selectAll'), + value: DEFAULT_SELECT_ALL, + }, + { + translation: translate('workspace.common.profile'), + value: 'overview', + alternateText: `${policy?.outputCurrency} ${translate('common.currency')}, ${formattedAddress}`, + }, + totalMembers > 1 + ? { + translation: translate('workspace.common.members'), + value: 'members', + alternateText: totalMembers ? `${totalMembers} ${translate('workspace.common.members').toLowerCase()}` : undefined, + } + : undefined, + reportFields > 0 + ? { + translation: translate('workspace.common.reports'), + value: 'reports', + alternateText: reportFields ? `${reportFields} ${translate('workspace.common.reportFields').toLowerCase()}` : undefined, + } + : undefined, + connectedIntegration && connectedIntegration?.length > 0 + ? { + translation: translate('workspace.common.accounting'), + value: 'accounting', + alternateText: connectedIntegration.map((connectionName) => CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]).join(', '), + } + : undefined, + totalTags > 0 + ? { + translation: translate('workspace.common.tags'), + value: 'tags', + alternateText: totalTags ? `${totalTags} ${translate('workspace.common.tags').toLowerCase()}` : undefined, + } + : undefined, + categoriesCount > 0 + ? { + translation: translate('workspace.common.categories'), + value: 'categories', + alternateText: categoriesCount ? `${categoriesCount} ${translate('workspace.duplicateWorkspace.categories').toLowerCase()}` : undefined, + } + : undefined, + taxesLength > 0 + ? { + translation: translate('workspace.common.taxes'), + value: 'taxes', + alternateText: taxesLength ? `${taxesLength} ${translate('workspace.common.taxes').toLowerCase()}` : undefined, + } + : undefined, + workflows && workflows?.length > 0 + ? { + translation: translate('workspace.common.workflows'), + value: 'workflows', + alternateText: workflows?.join(', '), + } + : undefined, + rules && rules.length > 0 + ? { + translation: translate('workspace.common.rules'), + value: 'rules', + alternateText: rules.length + ? `${rules.length} ${translate('workspace.common.workspace').toLowerCase()} ${translate('workspace.common.rules').toLowerCase()}: ${rules.join(', ')}` + : undefined, + } + : undefined, + ratesCount > 0 + ? { + translation: translate('workspace.common.distanceRates'), + value: 'distanceRates', + alternateText: ratesCount ? `${ratesCount} ${translate('iou.rates').toLowerCase()}` : undefined, + } + : undefined, + allRates > 0 + ? { + translation: translate('workspace.common.perDiem'), + value: 'perDiem', + alternateText: allRates ? `${allRates} ${translate('workspace.common.perDiem').toLowerCase()}` : undefined, + } + : undefined, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (bankAccountList && Object.keys(bankAccountList).length) || !!invoiceCompany + ? { + translation: translate('workspace.common.invoices'), + value: 'invoices', + alternateText: bankAccountList ? `${Object.keys(bankAccountList).length} ${translate('common.bankAccounts').toLowerCase()}, ${invoiceCompany}` : invoiceCompany, + } + : undefined, + ]; + + return result.filter((item): item is NonNullable => !!item); + }, [ + policy, + translate, + formattedAddress, + totalMembers, + reportFields, + connectedIntegration, + totalTags, + categoriesCount, + taxesLength, + ratesCount, + allRates, + bankAccountList, + invoiceCompany, + ]); + + const listData: ListItem[] = useMemo(() => { + return items.map((option) => { + const alternateText = option?.alternateText ? option.alternateText.trim().replace(/,$/, '') : undefined; + return { + text: option.translation, + keyForList: option.value, + isSelected: selectedItems.includes(option.value), + alternateText, + }; + }); + }, [items, selectedItems]); + + const fetchWorkspaceRelatedData = useCallback(() => { + if (!policyID) { + return; + } + openWorkspaceMembersPage(policyID, Object.keys(allIds ?? {})); + openPolicyCategoriesPage(policyID); + openPolicyDistanceRatesPage(policyID); + openPolicyPerDiemPage(policyID); + openPolicyReportFieldsPage(policyID); + openPolicyTagsPage(policyID); + openPolicyTaxesPage(policyID); + openPolicyWorkflowsPage(policyID); + }, [policyID, allIds]); + + const confirmDuplicate = useCallback(() => { + if (!policy || !duplicateWorkspace?.name || !duplicateWorkspace?.policyID) { + return; + } + duplicateWorkspaceAction(policy, { + policyName: duplicateWorkspace.name, + policyID: policy.id, + targetPolicyID: duplicateWorkspace.policyID, + welcomeNote: `${translate('workspace.duplicateWorkspace.welcomeNote')} ${duplicateWorkspace.name}`, + parts: { + people: selectedItems.includes('members'), + reports: selectedItems.includes('reports'), + connections: selectedItems.includes('accounting'), + categories: selectedItems.includes('categories'), + tags: selectedItems.includes('tags'), + taxes: selectedItems.includes('taxes'), + reimbursements: selectedItems.includes('invoices'), + expenses: selectedItems.includes('rules'), + customUnits: selectedItems.includes('distanceRates'), + invoices: selectedItems.includes('invoices'), + exportLayouts: selectedItems.includes('workflows'), + }, + file: duplicateWorkspace?.file, + }); + Navigation.closeRHPFlow(); + }, [duplicateWorkspace?.file, duplicateWorkspace?.name, duplicateWorkspace?.policyID, policy, selectedItems, translate]); + + const confirmDuplicateAndHideModal = useCallback(() => { + setIsDuplicateModalOpen(false); + if (!policy || !duplicateWorkspace?.name || !duplicateWorkspace?.policyID) { + return; + } + confirmDuplicate(); + }, [confirmDuplicate, duplicateWorkspace?.name, duplicateWorkspace?.policyID, policy]); + + const onConfirmSelectList = useCallback(() => { + if (!totalMembers || totalMembers < 2 || !selectedItems.includes('members')) { + confirmDuplicate(); + return; + } + setIsDuplicateModalOpen(true); + }, [confirmDuplicate, selectedItems, totalMembers]); + + const updateSelectedItems = useCallback( + (listItem: ListItem) => { + if (listItem.isSelected) { + if (listItem.keyForList === DEFAULT_SELECT_ALL) { + setSelectedItems([]); + return; + } + setSelectedItems(selectedItems.filter((i) => i !== listItem.keyForList && i !== DEFAULT_SELECT_ALL)); + return; + } + if (listItem.keyForList === DEFAULT_SELECT_ALL) { + setSelectedItems(items.map((i) => i.value)); + return; + } + + const newItem = items.find((i) => i.value === listItem.keyForList)?.value; + + if (newItem) { + const newSelectedItems = [...selectedItems, newItem]; + const featuresOptions = items.filter((i) => i.value !== DEFAULT_SELECT_ALL); + const allItemsSelected = featuresOptions.length === newSelectedItems.length; + + if (allItemsSelected) { + setSelectedItems([...newSelectedItems, DEFAULT_SELECT_ALL]); + } else { + setSelectedItems(newSelectedItems); + } + } + }, + [items, selectedItems], + ); + + useEffect(() => { + if (!items.length) { + return; + } + setSelectedItems(items.map((i) => i.value)); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items.length]); + + useEffect(() => { + fetchWorkspaceRelatedData(); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + Navigation.goBack(ROUTES.WORKSPACE_DUPLICATE.getRoute(policyID, ROUTES.WORKSPACES_LIST.route)) : undefined} + title={translate('workspace.common.duplicateWorkspace')} + /> + <> + + {translate('workspace.duplicateWorkspace.selectFeatures')} + {translate('workspace.duplicateWorkspace.whichFeatures')} + + + + + + setIsDuplicateModalOpen(false)} + prompt={ + + + {translate('workspace.duplicateWorkspace.confirmTitle', { + newWorkspaceName: duplicateWorkspace?.name, + totalMembers, + })} + + {translate('workspace.duplicateWorkspace.confirmDuplicate')} + + } + confirmText={translate('common.proceed')} + cancelText={translate('common.cancel')} + success + /> + + ); +} + +WorkspaceDuplicateSelectFeaturesForm.displayName = 'WorkspaceDuplicateSelectFeaturesForm'; + +export default WorkspaceDuplicateSelectFeaturesForm; diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesPage.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesPage.tsx new file mode 100644 index 0000000000000..0359e4ef47dd4 --- /dev/null +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesPage.tsx @@ -0,0 +1,33 @@ +import {useRoute} from '@react-navigation/native'; +import React from 'react'; +import ScreenWrapper from '@components/ScreenWrapper'; +import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; +import type {WorkspaceDuplicateNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import WorkspaceDuplicateSelectFeaturesForm from './WorkspaceDuplicateSelectFeaturesForm'; + +function WorkspaceDuplicateSelectFeaturesPage() { + const route = useRoute>(); + const policyID = route?.params?.policyID; + + return ( + + + + + + ); +} + +WorkspaceDuplicateSelectFeaturesPage.displayName = 'WorkspaceDuplicateSelectFeaturesPage'; + +export default WorkspaceDuplicateSelectFeaturesPage; diff --git a/src/pages/workspace/duplicate/utils.ts b/src/pages/workspace/duplicate/utils.ts new file mode 100644 index 0000000000000..aa4a78b79e705 --- /dev/null +++ b/src/pages/workspace/duplicate/utils.ts @@ -0,0 +1,79 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {getCorrectedAutoReportingFrequency, getWorkflowApprovalsUnavailable, hasVBBA} from '@libs/PolicyUtils'; +import {getAutoReportingFrequencyDisplayNames} from '@pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage'; +import type {AutoReportingFrequencyKey} from '@pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage'; +import {isAuthenticationError} from '@userActions/connections'; +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +function getWorkspaceRules(policy: Policy | undefined, translate: LocaleContextProps['translate']) { + const workflowApprovalsUnavailable = getWorkflowApprovalsUnavailable(policy); + const autoPayApprovedReportsUnavailable = !policy?.areWorkflowsEnabled || policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES || !hasVBBA(policy?.id); + const total: string[] = []; + if (policy?.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE) { + total.push(translate('workspace.rules.individualExpenseRules.receiptRequiredAmount')); + } + if (policy?.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE) { + total.push(translate('workspace.rules.individualExpenseRules.maxExpenseAmount')); + } + if (policy?.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE) { + total.push(translate('workspace.rules.individualExpenseRules.maxExpenseAge')); + } + if (policy?.defaultBillable) { + total.push(translate('workspace.rules.individualExpenseRules.billable')); + } + if (policy?.prohibitedExpenses && Object.values(policy?.prohibitedExpenses).find((value) => value)) { + total.push(translate('workspace.rules.individualExpenseRules.prohibitedExpenses')); + } + if (policy?.eReceipts) { + total.push(translate('workspace.rules.individualExpenseRules.eReceipts')); + } + if (policy?.isAttendeeTrackingEnabled) { + total.push(translate('workspace.rules.individualExpenseRules.attendeeTracking')); + } + if (policy?.preventSelfApproval && !workflowApprovalsUnavailable) { + total.push(translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle')); + } + if (policy?.shouldShowAutoApprovalOptions && !workflowApprovalsUnavailable) { + total.push(translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsTitle')); + } + if (policy?.shouldShowAutoReimbursementLimitOption && !autoPayApprovedReportsUnavailable) { + total.push(translate('workspace.rules.expenseReportRules.autoPayApprovedReportsTitle')); + } + + return total.length > 0 ? total : null; +} + +function getWorkflowRules(policy: Policy | undefined, translate: LocaleContextProps['translate']) { + const total: string[] = []; + const {bankAccountID} = policy?.achAccount ?? {}; + const hasDelayedSubmissionError = !!(policy?.errorFields?.autoReporting ?? policy?.errorFields?.autoReportingFrequency); + const hasApprovalError = !!policy?.errorFields?.approvalMode; + const shouldShowBankAccount = !!bankAccountID && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + + if (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT && !hasDelayedSubmissionError) { + const title = + getAutoReportingFrequencyDisplayNames(translate)[(getCorrectedAutoReportingFrequency(policy) as AutoReportingFrequencyKey) ?? CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY]; + total.push(`${title} ${translate('workspace.duplicateWorkspace.delayedSubmission')}`); + } + if ([CONST.POLICY.APPROVAL_MODE.BASIC, CONST.POLICY.APPROVAL_MODE.ADVANCED].some((approvalMode) => approvalMode === policy?.approvalMode) && !hasApprovalError) { + total.push(translate('common.approvals')); + } + if (policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO) { + if (shouldShowBankAccount) { + total.push(`1 ${translate('workspace.duplicateWorkspace.reimbursementAccount')}`); + } else { + total.push(translate('common.payments')); + } + } + return total.length > 0 ? total : null; +} + +function getAllValidConnectedIntegration(policy: Policy | undefined, accountingIntegrations?: ConnectionName[]) { + return (accountingIntegrations ?? Object.values(CONST.POLICY.CONNECTIONS.NAME)).filter( + (integration) => !!policy?.connections?.[integration] && !isAuthenticationError(policy, integration), + ); +} + +export {getWorkspaceRules, getWorkflowRules, getAllValidConnectedIntegration}; diff --git a/src/types/form/WorkspaceDuplicateForm.tsx b/src/types/form/WorkspaceDuplicateForm.tsx new file mode 100644 index 0000000000000..348e2e3500e84 --- /dev/null +++ b/src/types/form/WorkspaceDuplicateForm.tsx @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + NAME: 'name', +} as const; + +type InputID = ValueOf; + +type WorkspaceDuplicateForm = Form< + InputID, + { + [INPUT_IDS.NAME]: string; + } +>; + +export type {WorkspaceDuplicateForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index feffe56baea8f..c96d8a41918d9 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -34,6 +34,7 @@ export type {ReportVirtualCardFraudForm} from './ReportVirtualCardFraudForm'; export type {DebugReportForm} from './DebugReportForm'; export type {DebugReportActionForm} from './DebugReportActionForm'; export type {DebugTransactionForm} from './DebugTransactionForm'; +export type {WorkspaceDuplicateForm} from './WorkspaceDuplicateForm'; export type {DebugTransactionViolationForm} from './DebugTransactionViolationForm'; export type {RequestPhysicalCardForm} from './RequestPhysicalCardForm'; export type {RoomNameForm} from './RoomNameForm'; diff --git a/src/types/onyx/DuplicateWorkspace.ts b/src/types/onyx/DuplicateWorkspace.ts new file mode 100644 index 0000000000000..453cdfb441a59 --- /dev/null +++ b/src/types/onyx/DuplicateWorkspace.ts @@ -0,0 +1,22 @@ +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; +import type * as OnyxCommon from './OnyxCommon'; + +/** Model of plaid data */ +type DuplicateWorkspace = { + /** New policy ID */ + policyID?: string; + + /** New workspace name */ + name?: string; + + /** Workspace avatar */ + file?: File | CustomRNImageManipulatorResult; + + /** Whether the data is being fetched from server */ + isLoading?: boolean; + + /** Error messages to show in UI */ + errors?: OnyxCommon.Errors; +}; + +export default DuplicateWorkspace; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 9d13fb18e1f9f..9e189f01d7445 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -28,6 +28,7 @@ import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; import type Download from './Download'; import type DraftReportComments from './DraftReportComments'; +import type DuplicateWorkspace from './DuplicateWorkspace'; import type ExpensifyCardBankAccountMetadata from './ExpensifyCardBankAccountMetadata'; import type ExpensifyCardSettings from './ExpensifyCardSettings'; import type ExportTemplate from './ExportTemplate'; @@ -145,6 +146,7 @@ export type { CurrencyList, CustomStatusDraft, DismissedReferralBanners, + DuplicateWorkspace, Download, WorkspaceCardsList, ExpensifyCardSettings, From 42621c663c93ae9a2a10401f1164d5065a951f42 Mon Sep 17 00:00:00 2001 From: Nicolay Arefyeu Date: Mon, 25 Aug 2025 13:26:48 +0300 Subject: [PATCH 2/8] fix eslint --- src/libs/actions/Policy/Policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index da6ba8e79fca5..1d1714796f5ea 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -116,7 +116,7 @@ import type { TransactionViolations, } from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {Attributes, CompanyAddress, CustomUnit, NetSuiteCustomList, NetSuiteCustomSegment, ProhibitedExpenses, Rate, TaxRate} from '@src/types/onyx/Policy'; +import type {Attributes, CompanyAddress, CustomUnit, NetSuiteCustomList, NetSuiteCustomSegment, ProhibitedExpenses, Rate, TaxRate, UberReceiptPartner} from '@src/types/onyx/Policy'; import type {CustomFieldType} from '@src/types/onyx/PolicyEmployee'; import type {NotificationPreference} from '@src/types/onyx/Report'; import type {OnyxData} from '@src/types/onyx/Request'; From 92a6a242afa796286957c7e4ecb37a3aab21fbb3 Mon Sep 17 00:00:00 2001 From: Nicolay Arefyeu Date: Tue, 26 Aug 2025 17:54:34 +0300 Subject: [PATCH 3/8] fix outstanding issues --- src/libs/actions/Policy/Category.ts | 59 +++++++++++++++++++ src/libs/actions/Policy/Policy.ts | 11 +++- src/pages/workspace/WorkspacesListPage.tsx | 10 +--- .../WorkspaceDuplicateSelectFeaturesForm.tsx | 19 ++++-- 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index e8954756c7e81..2a23deae43041 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -60,6 +60,64 @@ function appendSetupCategoriesOnboardingData(onyxData: OnyxData) { return onyxData; } +function buildOptimisticPolicyWithExistingCategories(policyID: string, categories: PolicyCategories) { + const categoriesValues = Object.values(categories); + const optimisticCategoryMap = categoriesValues.reduce>>((acc, category) => { + acc[category.name] = { + ...category, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + return acc; + }, {}); + + const successCategoryMap = categoriesValues.reduce>>((acc, category) => { + acc[category.name] = { + errors: null, + pendingAction: null, + }; + return acc; + }, {}); + + const failureCategoryMap = categoriesValues.reduce>>((acc, category) => { + acc[category.name] = { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.createFailureMessage'), + }; + return acc; + }, {}); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: optimisticCategoryMap, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`, + value: null, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: successCategoryMap, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: failureCategoryMap, + }, + ], + }; + + return onyxData; +} + function buildOptimisticPolicyCategories(policyID: string, categories: readonly string[]) { const optimisticCategoryMap = categories.reduce>>((acc, category) => { acc[category] = { @@ -1430,6 +1488,7 @@ export { renamePolicyCategory, setPolicyCategoryApprover, setPolicyCategoryDescriptionRequired, + buildOptimisticPolicyWithExistingCategories, setPolicyCategoryGLCode, setPolicyCategoryMaxAmount, setPolicyCategoryPayrollCode, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 141574fd7cf6a..da0c85c1f3f6e 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -103,6 +103,7 @@ import type { LastPaymentMethodType, PersonalDetailsList, Policy, + PolicyCategories, PolicyCategory, PolicyEmployee, ReimbursementAccount, @@ -120,7 +121,7 @@ import type {CustomFieldType} from '@src/types/onyx/PolicyEmployee'; import type {NotificationPreference} from '@src/types/onyx/Report'; import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {buildOptimisticMccGroup, buildOptimisticPolicyCategories} from './Category'; +import {buildOptimisticMccGroup, buildOptimisticPolicyCategories, buildOptimisticPolicyWithExistingCategories} from './Category'; type ReportCreationData = Record< string, @@ -176,6 +177,7 @@ type DuplicatePolicyDataOptions = { welcomeNote: string; parts: Record; file?: File | CustomRNImageManipulatorResult; + policyCategories?: PolicyCategories; }; const allPolicies: OnyxCollection = {}; @@ -2455,7 +2457,7 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy } function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOptions) { - const {policyName = '', policyID = generatePolicyID(), file, welcomeNote, parts, targetPolicyID = generatePolicyID()} = options; + const {policyName = '', policyID = generatePolicyID(), file, welcomeNote, parts, targetPolicyID = generatePolicyID(), policyCategories} = options; const { adminsChatReportID, @@ -2484,7 +2486,9 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp const optimisticAnnounceChat = ReportUtils.buildOptimisticAnnounceChat(targetPolicyID, [...policyMemberAccountIDs]); const announceRoomChat = optimisticAnnounceChat.announceChatData; - const optimisticCategoriesData = buildOptimisticPolicyCategories(targetPolicyID, Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); + const optimisticCategoriesData = policyCategories + ? buildOptimisticPolicyWithExistingCategories(targetPolicyID, policyCategories) + : buildOptimisticPolicyCategories(targetPolicyID, Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); // WARNING: The data below should be kept in sync with the API so we create the policy with the correct configuration. const optimisticData: OnyxUpdate[] = [ @@ -2501,6 +2505,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp areWorkflowsEnabled: isWorkflowsOptionSelected, areReportFieldsEnabled: isReportsOptionSelected, areConnectionsEnabled: isConnectionsOptionSelected, + workspaceAccountID: undefined, tax: isTaxesOptionSelected ? policy?.tax : undefined, employeeList: isMemberOptionSelected ? policy.employeeList : {[policy.owner]: policy?.employeeList?.[policy.owner]}, id: targetPolicyID, diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 4e527c72f10ff..c8b6015e1626d 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -35,15 +35,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import { - calculateBillNewDot, - clearDeleteWorkspaceError, - clearDuplicateWorkspace, - clearErrors, - deleteWorkspace, - leaveWorkspace, - removeWorkspace, -} from '@libs/actions/Policy/Policy'; +import {calculateBillNewDot, clearDeleteWorkspaceError, clearDuplicateWorkspace, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index f3d548dce060a..25a7764e2bf9c 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -10,7 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDistanceRateCustomUnit, getMemberAccountIDsForWorkspace, getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import {getDistanceRateCustomUnit, getMemberAccountIDsForWorkspace, getPerDiemCustomUnit, isCollectPolicy} from '@libs/PolicyUtils'; import {getReportFieldsByPolicyID} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import {openPolicyCategoriesPage} from '@userActions/Policy/Category'; @@ -36,12 +36,12 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const styles = useThemeStyles(); const {translate} = useLocalize(); const policy = usePolicy(policyID); + const isCollect = isCollectPolicy(policy); const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE, {canBeMissing: false}); const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); const allIds = getMemberAccountIDsForWorkspace(policy?.employeeList); const totalMembers = Object.keys(allIds).length; const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: false}); - const totalTags = Object.keys(policyTags ?? {}).length ?? 0; const taxesLength = Object.keys(policy?.taxRates?.taxes ?? {}).length ?? 0; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); const categoriesCount = Object.keys(policyCategories ?? {}).length; @@ -62,10 +62,17 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm ? `${policy?.invoice?.companyName}, ${policy?.invoice?.companyWebsite}` : (policy?.invoice?.companyName ?? policy?.invoice?.companyWebsite ?? ''); + const totalTags = useMemo(() => { + if (!policyTags) { + return 0; + } + return Object.values(policyTags).reduce((sum, tagGroup) => sum + Number(Object.values(tagGroup.tags)?.length ?? 0), 0); + }, [policyTags]); + const [street1, street2] = (policy?.address?.addressStreet ?? '').split('\n'); const formattedAddress = !isEmptyObject(policy) && !isEmptyObject(policy.address) - ? `, ${street1?.trim()}, ${street2 ? `${street2.trim()}, ` : ''}${policy.address.city}, ${policy.address.state} ${policy.address.zipCode ?? ''}` + ? `${street1?.trim()}, ${street2 ? `${street2.trim()}, ` : ''}${policy.address.city}, ${policy.address.state} ${policy.address.zipCode ?? ''}` : ''; const items = useMemo(() => { @@ -131,7 +138,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm alternateText: workflows?.join(', '), } : undefined, - rules && rules.length > 0 + rules && rules.length > 0 && !isCollect ? { translation: translate('workspace.common.rules'), value: 'rules', @@ -176,6 +183,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm categoriesCount, taxesLength, ratesCount, + isCollect, allRates, bankAccountList, invoiceCompany, @@ -216,6 +224,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm policyID: policy.id, targetPolicyID: duplicateWorkspace.policyID, welcomeNote: `${translate('workspace.duplicateWorkspace.welcomeNote')} ${duplicateWorkspace.name}`, + policyCategories: selectedItems.includes('categories') ? policyCategories : undefined, parts: { people: selectedItems.includes('members'), reports: selectedItems.includes('reports'), @@ -232,7 +241,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm file: duplicateWorkspace?.file, }); Navigation.closeRHPFlow(); - }, [duplicateWorkspace?.file, duplicateWorkspace?.name, duplicateWorkspace?.policyID, policy, selectedItems, translate]); + }, [duplicateWorkspace?.file, duplicateWorkspace?.name, duplicateWorkspace?.policyID, policy, policyCategories, selectedItems, translate]); const confirmDuplicateAndHideModal = useCallback(() => { setIsDuplicateModalOpen(false); From ed5edcc9599751e769d004de54defafc2fda8ecb Mon Sep 17 00:00:00 2001 From: Nicolay Arefyeu Date: Thu, 28 Aug 2025 12:44:32 +0300 Subject: [PATCH 4/8] add beta flag --- src/CONST/index.ts | 1 + src/pages/workspace/WorkspacesListPage.tsx | 6 +++++- src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx | 4 ++++ .../duplicate/WorkspaceDuplicateSelectFeaturesPage.tsx | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 415f049dd6062..336e985bb8258 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -693,6 +693,7 @@ const CONST = { NO_OPTIMISTIC_TRANSACTION_THREADS: 'noOptimisticTransactionThreads', VACATION_DELEGATE: 'vacationDelegate', UBER_FOR_BUSINESS: 'uberForBusiness', + DUPLICATE_WORKSPACE: 'duplicateWorkspace', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index c8b6015e1626d..374864e65a345 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -29,6 +29,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -100,6 +101,7 @@ function WorkspacesListPage() { const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS, {canBeMissing: true}); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true}); @@ -110,6 +112,7 @@ function WorkspacesListPage() { const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE, {canBeMissing: false}); + const isDuplicatedWorkspaceEnabled = isBetaEnabled(CONST.BETAS.DUPLICATE_WORKSPACE); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [policyIDToDelete, setPolicyIDToDelete] = useState(); @@ -220,7 +223,7 @@ function WorkspacesListPage() { }); } - if (isAdmin) { + if (isAdmin && isDuplicatedWorkspaceEnabled) { threeDotsMenuItems.push({ icon: Expensicons.Copy, text: translate('workspace.common.duplicateWorkspace'), @@ -294,6 +297,7 @@ function WorkspacesListPage() { session?.email, activePolicyID, isSupportalAction, + isDuplicatedWorkspaceEnabled, setIsDeletingPaidWorkspace, isLoadingBill, shouldCalculateBillNewDot, diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx index e9485b5000497..c2251dd50c453 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicatePage.tsx @@ -1,6 +1,7 @@ import {useRoute} from '@react-navigation/native'; import React, {useEffect} from 'react'; import ScreenWrapper from '@components/ScreenWrapper'; +import usePermissions from '@hooks/usePermissions'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; import type {WorkspaceDuplicateNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -12,6 +13,8 @@ import WorkspaceDuplicateForm from './WorkspaceDuplicateForm'; function WorkspaceDuplicatePage() { const route = useRoute>(); const policyID = route?.params?.policyID; + const {isBetaEnabled} = usePermissions(); + const isDuplicatedWorkspaceEnabled = isBetaEnabled(CONST.BETAS.DUPLICATE_WORKSPACE); useEffect(clearDuplicateWorkspace, []); @@ -19,6 +22,7 @@ function WorkspaceDuplicatePage() { >(); const policyID = route?.params?.policyID; + const {isBetaEnabled} = usePermissions(); + const isDuplicatedWorkspaceEnabled = isBetaEnabled(CONST.BETAS.DUPLICATE_WORKSPACE); return ( Date: Thu, 28 Aug 2025 13:58:37 +0300 Subject: [PATCH 5/8] add beta flag --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 336e985bb8258..e68bcdf2bcf84 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -693,7 +693,7 @@ const CONST = { NO_OPTIMISTIC_TRANSACTION_THREADS: 'noOptimisticTransactionThreads', VACATION_DELEGATE: 'vacationDelegate', UBER_FOR_BUSINESS: 'uberForBusiness', - DUPLICATE_WORKSPACE: 'duplicateWorkspace', + DUPLICATE_WORKSPACE: 'duplicatePolicyNewDot', }, BUTTON_STATES: { DEFAULT: 'default', From de41785edd771b39c3b6edfcb0b447bf31bc2595 Mon Sep 17 00:00:00 2001 From: Nicolay Arefyeu Date: Fri, 29 Aug 2025 13:39:45 +0300 Subject: [PATCH 6/8] add perDiem --- src/libs/actions/Policy/Policy.ts | 2 ++ .../duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 373ef042be347..b76297676dc5b 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2481,6 +2481,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp const isCustomUnitsOptionSelected = parts?.customUnits; const isRulesOptionSelected = parts?.expenses; const isWorkflowsOptionSelected = parts?.exportLayouts; + const isPerDiemOptionSelected = parts?.perDiem; const policyMemberAccountIDs = isMemberOptionSelected ? Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)) : []; const {customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(policy?.outputCurrency); @@ -2506,6 +2507,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp areWorkflowsEnabled: isWorkflowsOptionSelected, areReportFieldsEnabled: isReportsOptionSelected, areConnectionsEnabled: isConnectionsOptionSelected, + arePerDiemRatesEnabled: isPerDiemOptionSelected, workspaceAccountID: undefined, tax: isTaxesOptionSelected ? policy?.tax : undefined, employeeList: isMemberOptionSelected ? policy.employeeList : {[policy.owner]: policy?.employeeList?.[policy.owner]}, diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index 25a7764e2bf9c..8c5e34df83aa4 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -232,6 +232,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm categories: selectedItems.includes('categories'), tags: selectedItems.includes('tags'), taxes: selectedItems.includes('taxes'), + perDiem: selectedItems.includes('perDiem'), reimbursements: selectedItems.includes('invoices'), expenses: selectedItems.includes('rules'), customUnits: selectedItems.includes('distanceRates'), From 5bd4153fbf130b6d176b0f67cdd4120574738d77 Mon Sep 17 00:00:00 2001 From: Nicolay Arefyeu Date: Fri, 29 Aug 2025 14:16:18 +0300 Subject: [PATCH 7/8] add unit test --- tests/actions/PolicyTest.ts | 197 ++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 53e0ccb27202c..68341f9b98118 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -249,6 +249,203 @@ describe('actions/Policy', () => { }); }); + it('duplicate workspace', async () => { + (fetch as MockFetch)?.pause?.(); + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + const fakePolicy = createRandomPolicy(10, CONST.POLICY.TYPE.PERSONAL); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.NVP_ACTIVE_POLICY_ID}`, fakePolicy.id); + await Onyx.set(`${ONYXKEYS.NVP_INTRO_SELECTED}`, {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}); + await waitForBatchedUpdates(); + + let adminReportID; + let expenseReportID; + const POLICY_NAME = 'Duplicate Workspace'; + const policyID = Policy.generatePolicyID(); + + const options = { + policyName: POLICY_NAME, + policyID: fakePolicy.id, + targetPolicyID: policyID, + welcomeNote: 'Join my policy', + parts: { + people: true, + reports: true, + connections: true, + categories: true, + tags: true, + taxes: true, + perDiem: true, + reimbursements: true, + expenses: true, + customUnits: true, + invoices: true, + exportLayouts: true, + }, + }; + + Policy.duplicateWorkspace(fakePolicy, options); + await waitForBatchedUpdates(); + + let policy: OnyxEntry | OnyxCollection = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + callback: (workspace) => { + Onyx.disconnect(connection); + resolve(workspace); + }, + }); + }); + + expect(policy?.id).toBe(policyID); + + // check if policy was created with correct values + expect(policy?.id).toBe(policyID); + expect(policy?.name).toBe(POLICY_NAME); + expect(policy?.type).toBe(fakePolicy.type); + expect(policy?.role).toBe(fakePolicy.role); + expect(policy?.owner).toBe(fakePolicy.owner); + expect(policy?.areWorkflowsEnabled).toBe(true); + expect(policy?.areDistanceRatesEnabled).toBe(true); + expect(policy?.areInvoicesEnabled).toBe(true); + expect(policy?.arePerDiemRatesEnabled).toBe(true); + expect(policy?.approvalMode).toBe(fakePolicy.approvalMode); + expect(policy?.approver).toBe(fakePolicy.approver); + expect(policy?.isPolicyExpenseChatEnabled).toBe(fakePolicy.isPolicyExpenseChatEnabled); + expect(policy?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(policy?.employeeList).toEqual(fakePolicy.employeeList); + expect(policy?.mccGroup).toBe(fakePolicy.mccGroup); + expect(policy?.requiresCategory).toBe(fakePolicy.requiresCategory); + + let allReports: OnyxCollection = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { + Onyx.disconnect(connection); + resolve(reports); + }, + }); + }); + + // These reports should be created: #admins and expense report + task reports of manage team (default) intent + const workspaceReports = Object.values(allReports ?? {}) + .filter((report) => report?.policyID === policyID) + .filter((report) => report?.type !== 'task'); + expect(workspaceReports.length).toBe(2); + workspaceReports.forEach((report) => { + expect(report?.pendingFields?.addWorkspaceRoom).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + switch (report?.chatType) { + case CONST.REPORT.CHAT_TYPE.POLICY_ADMINS: { + expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT_ADMINS_ROOM}); + adminReportID = report.reportID; + break; + } + case CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT: { + expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT_EXPENSE_CHAT}); + expenseReportID = report.reportID; + break; + } + default: + break; + } + }); + + let reportActions: OnyxCollection = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions); + }, + }); + }); + + // Each of the three reports should have a `CREATED` action. + let adminReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {}); + let expenseReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {}); + let workspaceReportActions: ReportAction[] = adminReportActions.concat(expenseReportActions); + expect(expenseReportActions.length).toBe(1); + [...expenseReportActions].forEach((reportAction) => { + expect(reportAction.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.CREATED); + expect(reportAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(reportAction.actorAccountID).toBe(ESH_ACCOUNT_ID); + }); + + // After filtering, two actions are added to the list =- signoff message (+1) and default create action (+1) + const expectedReportActionsOfTypeCreatedCount = 1; + expect(adminReportActions.length).toBe(1); + + let reportActionsOfTypeCreatedCount = 0; + adminReportActions.forEach((reportAction) => { + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + reportActionsOfTypeCreatedCount++; + expect(reportAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(reportAction.actorAccountID).toBe(ESH_ACCOUNT_ID); + return; + } + if (reportAction.childType === CONST.REPORT.TYPE.TASK) { + expect(reportAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + } + }); + expect(reportActionsOfTypeCreatedCount).toBe(expectedReportActionsOfTypeCreatedCount); + + // Check for success data + (fetch as MockFetch)?.resume?.(); + await waitForBatchedUpdates(); + + policy = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (workspace) => { + Onyx.disconnect(connection); + resolve(workspace); + }, + }); + }); + + // Check if the policy pending action was cleared + expect(policy?.pendingAction).toBeFalsy(); + + allReports = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { + Onyx.disconnect(connection); + resolve(reports); + }, + }); + }); + + // Check if the report pending action and fields were cleared + Object.values(allReports ?? {}).forEach((report) => { + expect(report?.pendingAction).toBeFalsy(); + expect(report?.pendingFields?.addWorkspaceRoom).toBeFalsy(); + }); + + reportActions = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions); + }, + }); + }); + + // Check if the report action pending action was cleared + adminReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {}); + expenseReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {}); + workspaceReportActions = adminReportActions.concat(expenseReportActions); + workspaceReportActions.forEach((reportAction) => { + expect(reportAction.pendingAction).toBeFalsy(); + }); + }); + it('creates a new workspace with BASIC approval mode if the introSelected is MANAGE_TEAM', async () => { const policyID = Policy.generatePolicyID(); // When a new workspace is created with introSelected set to MANAGE_TEAM From 8b33b70bf529e1d7a0787bc2e011a40787690920 Mon Sep 17 00:00:00 2001 From: Nicolay Arefyeu Date: Fri, 29 Aug 2025 15:18:22 +0300 Subject: [PATCH 8/8] change order --- src/pages/workspace/WorkspacesListPage.tsx | 58 +++++++++++----------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 374864e65a345..956c8a24355ab 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -183,6 +183,35 @@ function WorkspacesListPage() { }, ]; + const defaultApprover = getDefaultApprover(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]); + if (!(isAdmin || isOwner) && defaultApprover !== session?.email) { + threeDotsMenuItems.push({ + icon: Expensicons.Exit, + text: translate('common.leave'), + onSelected: callFunctionIfActionIsAllowed(() => leaveWorkspace(item.policyID)), + }); + } + + if (isAdmin && isDuplicatedWorkspaceEnabled) { + threeDotsMenuItems.push({ + icon: Expensicons.Copy, + text: translate('workspace.common.duplicateWorkspace'), + onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE.getRoute(item.policyID, ROUTES.WORKSPACES_LIST.route)) : undefined), + }); + } + + if (!isDefault && !item?.isJoinRequestPending) { + threeDotsMenuItems.push({ + icon: Expensicons.Star, + text: translate('workspace.common.setAsDefault'), + onSelected: () => { + if (!item.policyID || !activePolicyID) { + return; + } + setNameValuePair(ONYXKEYS.NVP_ACTIVE_POLICY_ID, item.policyID, activePolicyID); + }, + }); + } if (isOwner) { threeDotsMenuItems.push({ icon: Expensicons.Trashcan, @@ -214,35 +243,6 @@ function WorkspacesListPage() { shouldCallAfterModalHide: !shouldCalculateBillNewDot, }); } - const defaultApprover = getDefaultApprover(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]); - if (!(isAdmin || isOwner) && defaultApprover !== session?.email) { - threeDotsMenuItems.push({ - icon: Expensicons.Exit, - text: translate('common.leave'), - onSelected: callFunctionIfActionIsAllowed(() => leaveWorkspace(item.policyID)), - }); - } - - if (isAdmin && isDuplicatedWorkspaceEnabled) { - threeDotsMenuItems.push({ - icon: Expensicons.Copy, - text: translate('workspace.common.duplicateWorkspace'), - onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE.getRoute(item.policyID, ROUTES.WORKSPACES_LIST.route)) : undefined), - }); - } - - if (!isDefault && !item?.isJoinRequestPending) { - threeDotsMenuItems.push({ - icon: Expensicons.Star, - text: translate('workspace.common.setAsDefault'), - onSelected: () => { - if (!item.policyID || !activePolicyID) { - return; - } - setNameValuePair(ONYXKEYS.NVP_ACTIVE_POLICY_ID, item.policyID, activePolicyID); - }, - }); - } return (