diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 68b53dc540839..055bf514209ec 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',
@@ -682,6 +685,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',
@@ -888,6 +893,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 7e8b4b1c2af47..83f6b1a14a1a1 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -1875,6 +1875,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 e283c1cd98deb..f727523b16dfa 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',
@@ -387,6 +388,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 24075db4e3044..3afcc09b8a9d5 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -318,6 +318,7 @@ const translations = {
count: 'Zählen',
cancel: 'Abbrechen',
dismiss: 'Verwerfen',
+ proceed: 'Fortfahren',
yes: 'Ja',
no: 'No',
ok: 'OK',
@@ -3442,12 +3443,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',
@@ -3460,6 +3463,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`,
@@ -3473,6 +3477,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',
@@ -4881,6 +4887,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: 'Erstellen Sie einen Arbeitsbereich',
subtitle:
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 6b3aa34a76f35..54080f03c65b2 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -310,6 +310,7 @@ const translations = {
count: 'Count',
cancel: 'Cancel',
dismiss: 'Dismiss',
+ proceed: 'Proceed',
yes: 'Yes',
no: 'No',
ok: 'OK',
@@ -3439,12 +3440,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',
@@ -3457,6 +3460,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`,
@@ -3470,6 +3474,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',
@@ -4864,6 +4870,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: 'Create a workspace',
subtitle: 'Create a workspace to track receipts, reimburse expenses, manage travel, send invoices, and more — all at the speed of chat.',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 2d5402374455e..20fe86959ff46 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -300,6 +300,7 @@ const translations = {
count: 'Contar',
cancel: 'Cancelar',
dismiss: 'Descartar',
+ proceed: 'Proceed',
yes: 'Sí',
no: 'No',
ok: 'OK',
@@ -3427,11 +3428,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',
@@ -3444,6 +3447,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`,
@@ -3457,6 +3461,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',
@@ -4874,6 +4880,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: 'Crea un espacio de trabajo',
subtitle: 'Crea un espacio de trabajo para organizar recibos, reembolsar gastos, gestionar viajes, enviar facturas y mucho más, todo a la velocidad del chat.',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 44204f4c8b122..c4614f408c1e7 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -318,6 +318,7 @@ const translations = {
count: 'Compter',
cancel: 'Annuler',
dismiss: 'Ignorer',
+ proceed: 'Procéder',
yes: 'Oui',
no: 'No',
ok: "D'accord",
@@ -3450,12 +3451,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é',
@@ -3468,6 +3471,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)`,
@@ -3481,6 +3485,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",
@@ -4897,6 +4903,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: 'Créer un espace de travail',
subtitle: 'Créez un espace de travail pour suivre les reçus, rembourser les dépenses, gérer les voyages, envoyer des factures, et plus encore — le tout à la vitesse du chat.',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index da0c3fd5da1a7..fce6a3ac3d5ad 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -318,6 +318,7 @@ const translations = {
count: 'Contare',
cancel: 'Annulla',
dismiss: 'Ignora',
+ proceed: 'Proceed',
yes: 'Sì',
no: 'No',
ok: 'OK',
@@ -3455,12 +3456,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',
+ reportField: 'Campo del rapporto',
taxes: 'Tasse',
bills: 'Fatture',
invoices: 'Fatture',
+ perDiem: 'Per diem',
travel: 'Viaggio',
members: 'Membri',
accounting: 'Contabilità',
@@ -3473,6 +3476,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`,
@@ -3486,7 +3490,9 @@ const translations = {
memberNotFound: 'Membro non trovato. Per invitare un nuovo membro al workspace, utilizza il pulsante di invito sopra.',
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',
+ goToWorkspaces: 'Duplicato',
clearFilter: 'Cancella filtro',
workspaceName: 'Nome del workspace',
workspaceOwner: 'Proprietario',
@@ -4896,6 +4902,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: "Crea un'area di lavoro",
subtitle: 'Crea uno spazio di lavoro per tracciare le ricevute, rimborsare le spese, gestire i viaggi, inviare fatture e altro ancora, tutto alla velocità della chat.',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 3a839d07010cd..1ef2a08c5089f 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -318,6 +318,7 @@ const translations = {
count: 'カウント',
cancel: 'キャンセル',
dismiss: '却下する',
+ proceed: 'Proceed',
yes: 'はい',
no: 'いいえ',
ok: 'OK',
@@ -3455,12 +3456,14 @@ const translations = {
customField1: 'カスタムフィールド1',
customField2: 'カスタムフィールド2',
customFieldHint: 'このメンバーのすべての支出に適用されるカスタムコーディングを追加します。',
+ reports: 'レポート',
reportFields: 'レポートフィールド',
reportTitle: 'レポートタイトル',
reportField: 'レポートフィールド',
taxes: '税金',
bills: '請求書',
invoices: '請求書',
+ perDiem: 'Per diem',
travel: '旅行',
members: 'メンバー',
accounting: '会計',
@@ -3473,6 +3476,7 @@ const translations = {
testTransactions: 'トランザクションをテストする',
issueAndManageCards: 'カードの発行と管理',
reconcileCards: 'カードを照合する',
+ selectAll: 'すべて選択',
selected: () => ({
one: '1 件選択済み',
other: (count: number) => `${count} 件選択済み`,
@@ -3486,6 +3490,8 @@ const translations = {
memberNotFound: 'メンバーが見つかりません。新しいメンバーをワークスペースに招待するには、上の招待ボタンを使用してください。',
notAuthorized: `このページにアクセスする権限がありません。このワークスペースに参加しようとしている場合は、ワークスペースのオーナーにメンバーとして追加してもらってください。他に何かお困りですか?${CONST.EMAIL.CONCIERGE}にお問い合わせください。`,
goToWorkspace: 'ワークスペースに移動',
+ duplicateWorkspace: 'ワークスペースの複製',
+ duplicateWorkspacePrefix: '重複',
goToWorkspaces: 'ワークスペースに移動',
clearFilter: 'フィルターをクリア',
workspaceName: 'ワークスペース名',
@@ -4875,6 +4881,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 9f9dc1f9b1ac9..1a183060a7daf 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -318,6 +318,7 @@ const translations = {
count: 'Aantal',
cancel: 'Annuleren',
dismiss: 'Verwijderen',
+ proceed: 'Proceed',
yes: 'Ja',
no: 'No',
ok: 'OK',
@@ -3462,12 +3463,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',
@@ -3480,6 +3483,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`,
@@ -3493,6 +3497,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',
@@ -4898,6 +4904,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: 'Maak een werkruimte aan',
subtitle: 'Maak een werkruimte om bonnetjes bij te houden, uitgaven te vergoeden, reizen te beheren, facturen te versturen en meer — allemaal op de snelheid van chat.',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 7bbd38eb0e91c..3722c84ff6832 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -318,6 +318,7 @@ const translations = {
count: 'Liczba',
cancel: 'Anuluj',
dismiss: 'Odrzuć',
+ proceed: 'Proceed',
yes: 'Tak',
no: 'Nie',
ok: 'OK',
@@ -3455,13 +3456,15 @@ 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',
taxes: 'Podatki',
bills: 'Rachunki',
invoices: 'Faktury',
- travel: 'Podróżować',
+ perDiem: 'Per diem',
+ travel: 'Podróże',
members: 'Członkowie',
accounting: 'Księgowość',
receiptPartners: 'Partnerzy paragonów',
@@ -3473,6 +3476,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`,
@@ -3486,6 +3490,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',
@@ -4887,6 +4893,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: 'Utwórz przestrzeń roboczą',
subtitle: 'Utwórz przestrzeń roboczą do śledzenia paragonów, zwracania wydatków, zarządzania podróżami, wysyłania faktur i nie tylko — wszystko z prędkością czatu.',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 344b3bd485f22..bd671ea930d5c 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -318,6 +318,7 @@ const translations = {
count: 'Contagem',
cancel: 'Cancelar',
dismiss: 'Dispensar',
+ proceed: 'Proceed',
yes: 'Sim',
no: 'Não',
ok: 'OK',
@@ -3460,12 +3461,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',
@@ -3478,6 +3481,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)`,
@@ -3491,6 +3495,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',
@@ -4893,6 +4899,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: 'Criar um espaço de trabalho',
subtitle: 'Crie um espaço de trabalho para rastrear recibos, reembolsar despesas, gerenciar viagens, enviar faturas e muito mais — tudo na velocidade do chat.',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 5e0f238759f2c..80e175aa7c80e 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -318,6 +318,7 @@ const translations = {
count: '计数',
cancel: '取消',
dismiss: '忽略',
+ proceed: 'Proceed',
yes: '是的',
no: '不',
ok: '好的',
@@ -3412,12 +3413,14 @@ const translations = {
customField1: '自定义字段 1',
customField2: '自定义字段2',
customFieldHint: '添加适用于该成员所有支出的自定义编码。',
+ reports: '报告',
reportFields: '报告字段',
reportTitle: '报告标题',
reportField: '报告字段',
taxes: '税款',
bills: '账单',
invoices: '发票',
+ perDiem: 'Per diem',
travel: '旅行',
members: '成员',
accounting: '会计',
@@ -3430,6 +3433,7 @@ const translations = {
testTransactions: '测试交易',
issueAndManageCards: '发行和管理卡片',
reconcileCards: '对账卡片',
+ selectAll: '全选',
selected: () => ({
one: '1 已选择',
other: (count: number) => `已选择${count}个`,
@@ -3443,6 +3447,8 @@ const translations = {
memberNotFound: '未找到成员。要邀请新成员加入工作区,请使用上面的邀请按钮。',
notAuthorized: `您无权访问此页面。如果您正在尝试加入此工作区,请请求工作区所有者将您添加为成员。还有其他问题?请联系${CONST.EMAIL.CONCIERGE}。`,
goToWorkspace: '前往工作区',
+ duplicateWorkspace: '重复工作区',
+ duplicateWorkspacePrefix: '复制',
goToWorkspaces: '前往工作区',
clearFilter: '清除筛选器',
workspaceName: '工作区名称',
@@ -4810,6 +4816,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 2f8400a96ac03..348bc86a6320e 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 3ac4cd240de29..5197679c3d87e 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',
@@ -840,6 +841,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 b9c3630e2352e..e74694e81e91a 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -40,6 +40,7 @@ import type {
TravelNavigatorParamList,
WalletStatementNavigatorParamList,
WorkspaceConfirmationNavigatorParamList,
+ WorkspaceDuplicateNavigatorParamList,
} from '@navigation/types';
import type {Screen} from '@src/SCREENS';
import SCREENS from '@src/SCREENS';
@@ -180,6 +181,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,
@@ -851,6 +857,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 8267dd03385f9..df2f877090925 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -144,6 +144,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 dbd40fac1889d..77b388273e60a 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;
@@ -2363,6 +2373,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..43753df64464c 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,
@@ -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,
@@ -5968,6 +6270,7 @@ export {
clearQuickbooksOnlineAutoSyncErrorField,
setIsForcedToChangeCurrency,
removePolicyReceiptPartnersConnection,
+ duplicateWorkspace,
openPolicyReceiptPartnersPage,
setIsComingFromGlobalReimbursementsFlow,
setPolicyAttendeeTrackingEnabled,
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 f901338592df5..86f01b1823338 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 useSearchResults from '@hooks/useSearchResults';
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';
@@ -109,6 +118,7 @@ function WorkspacesListPage() {
const styles = useThemeStyles();
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});
@@ -119,6 +129,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();
@@ -138,6 +149,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
@@ -178,6 +190,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,
@@ -226,6 +240,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,
@@ -258,6 +280,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}
@@ -278,6 +301,7 @@ function WorkspacesListPage() {
styles.mb2,
styles.mh5,
styles.ph5,
+ duplicateWorkspace?.policyID,
styles.hoveredComponentBG,
translate,
styles.offlineFeedback.deleted,
@@ -369,6 +393,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 && }
@@ -484,7 +521,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 4aa8b7f0951df..9917e3a31a062 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';
@@ -144,6 +145,7 @@ export type {
CurrencyList,
CustomStatusDraft,
DismissedReferralBanners,
+ DuplicateWorkspace,
Download,
WorkspaceCardsList,
ExpensifyCardSettings,