Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5964,6 +5964,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU
`Sie sind dabei, ${newWorkspaceName ?? ''} mit ${totalMembers ?? 0} Mitgliedern aus dem ursprünglichen Workspace zu erstellen und zu teilen.`,
error: 'Beim Duplizieren deines neuen Workspace ist ein Fehler aufgetreten. Bitte versuche es erneut.',
},
copyPolicySettings: {error: 'Beim Kopieren der Arbeitsbereichseinstellungen ist ein Fehler aufgetreten. Bitte versuche es erneut.'},
emptyWorkspace: {
title: 'Du hast keine Arbeitsbereiche',
subtitle: 'Belege erfassen, Auslagen erstatten, Reisen verwalten, Rechnungen versenden und mehr.',
Expand Down
3 changes: 3 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5977,6 +5977,9 @@ const translations = {
`You’re about to create and share ${newWorkspaceName ?? ''} with ${totalMembers ?? 0} members from the original workspace.`,
error: 'An error occurred while duplicating your new workspace. Please try again.',
},
copyPolicySettings: {
error: 'An error occurred while copying workspace settings. Please try again.',
},
emptyWorkspace: {
title: 'No workspaces yet',
subtitle: 'Create a workspace to manage your expenses, reimbursements, and company cards.',
Expand Down
3 changes: 3 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5841,6 +5841,9 @@ ${amount} para ${merchant} - ${date}`,
`Estás a punto de crear y compartir ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} miembros del espacio de trabajo original.`,
error: 'Se produjo un error al duplicar tu nuevo espacio de trabajo. Inténtalo de nuevo.',
},
copyPolicySettings: {
error: 'Se produjo un error al copiar la configuración del espacio de trabajo. Inténtalo de nuevo.',
},
emptyWorkspace: {
title: 'Aún no hay espacios de trabajo',
subtitle: 'Crea un espacio de trabajo para gestionar tus gastos, reembolsos y tarjetas de empresa.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5984,6 +5984,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST.
`Vous êtes sur le point de créer et de partager ${newWorkspaceName ?? ''} avec ${totalMembers ?? 0} membres de l’espace de travail d’origine.`,
error: 'Une erreur s’est produite lors de la duplication de votre nouvel espace de travail. Veuillez réessayer.',
},
copyPolicySettings: {error: 'Une erreur s’est produite lors de la copie des paramètres de l’espace de travail. Veuillez réessayer.'},
emptyWorkspace: {
title: 'Vous n’avez aucun espace de travail',
subtitle: 'Suivez les reçus, remboursez les dépenses, gérez les voyages, envoyez des factures, et plus encore.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5954,6 +5954,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST.
`Stai per creare e condividere ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} membri dello spazio di lavoro originale.`,
error: 'Si è verificato un errore durante la duplicazione del tuo nuovo workspace. Riprova.',
},
copyPolicySettings: {error: 'Si è verificato un errore durante la copia delle impostazioni dello spazio di lavoro. Riprova.'},
emptyWorkspace: {
title: 'Non hai nessuna area di lavoro',
subtitle: 'Tieni traccia delle ricevute, rimborsa le spese, gestisci i viaggi, invia le fatture e altro ancora.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5887,6 +5887,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO
`元のワークスペースから ${totalMembers ?? 0} 人のメンバーと一緒に、${newWorkspaceName ?? ''} を作成して共有しようとしています。`,
error: '新しいワークスペースの複製中にエラーが発生しました。もう一度お試しください。',
},
copyPolicySettings: {error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。'},
emptyWorkspace: {
title: 'ワークスペースがありません',
subtitle: '領収書を管理し、経費を精算し、出張を管理し、請求書を送信するなど、さまざまなことができます。',
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5934,6 +5934,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_
`Je staat op het punt ${newWorkspaceName ?? ''} te maken en te delen met ${totalMembers ?? 0} leden van de oorspronkelijke werkruimte.`,
error: 'Er is een fout opgetreden bij het dupliceren van je nieuwe werkruimte. Probeer het opnieuw.',
},
copyPolicySettings: {error: 'Er is een fout opgetreden bij het kopiëren van de werkruimtainstellingen. Probeer het opnieuw.'},
emptyWorkspace: {
title: 'Je hebt geen werkruimtes',
subtitle: 'Volg bonnen, vergoed uitgaven, beheer reizen, verstuur facturen en meer.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5928,6 +5928,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy
`Za chwilę utworzysz i udostępnisz ${newWorkspaceName ?? ''} ${totalMembers ?? 0} członkom oryginalnego obszaru roboczego.`,
error: 'Wystąpił błąd podczas duplikowania Twojego nowego obszaru roboczego. Spróbuj ponownie.',
},
copyPolicySettings: {error: 'Wystąpił błąd podczas kopiowania ustawień przestrzeni roboczej. Spróbuj ponownie.'},
emptyWorkspace: {
title: 'Nie masz żadnych przestrzeni roboczych',
subtitle: 'Śledź paragony, rozliczaj wydatki, zarządzaj podróżami, wysyłaj faktury i wiele więcej.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5934,6 +5934,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS
`Você está prestes to criar e compartilhar ${newWorkspaceName ?? ''} com ${totalMembers ?? 0} membros do workspace original.`,
error: 'Ocorreu um erro ao duplicar seu novo espaço de trabalho. Tente novamente.',
},
copyPolicySettings: {error: 'Ocorreu um erro ao copiar as configurações do workspace. Tente novamente.'},
emptyWorkspace: {
title: 'Você não tem nenhum workspace',
subtitle: 'Controle recibos, reembolse despesas, gerencie viagens, envie faturas e muito mais.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5789,6 +5789,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
`您即将创建并共享 ${newWorkspaceName ?? ''},其中包含来自原始工作区的 ${totalMembers ?? 0} 位成员。`,
error: '复制您的新工作区时发生错误。请重试。',
},
copyPolicySettings: {error: '复制工作区设置时发生错误。请重试。'},
emptyWorkspace: {
title: '你还没有工作区',
subtitle: '跟踪收据、报销费用、管理差旅、发送发票等。',
Expand Down
1 change: 1 addition & 0 deletions src/libs/API/parameters/CopyPolicySettingsParams.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
type CopyPolicySettingsParams = {
/** Source policy ID we're copying settings from */
sourcePolicyID: string;

/** CSV list of target policy IDs to copy settings into */
Expand Down
288 changes: 288 additions & 0 deletions src/libs/actions/Policy/CopyPolicySettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import {write} from '@libs/API';
import type {CopyPolicySettingsParams} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
import {generateHexadecimalValue} from '@libs/NumberUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {CopyPolicySettings as CopyPolicySettingsState, Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx';
import type {CustomUnit} from '@src/types/onyx/Policy';

type Part = 'overview' | 'members' | 'reports' | 'accounting' | 'categories' | 'tags' | 'taxes' | 'workflows' | 'rules' | 'distanceRates' | 'perDiem' | 'invoices' | 'travel';

const PARTS_TO_POLICY_FIELDS = {
overview: ['outputCurrency', 'address'],
members: ['employeeList'],
reports: ['fieldList', 'areReportFieldsEnabled'],
accounting: ['connections', 'areConnectionsEnabled'],
categories: ['areCategoriesEnabled'],
tags: ['areTagsEnabled'],
taxes: ['tax', 'taxRates'],
workflows: ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'approvalMode', 'reimbursementChoice', 'achAccount'],
rules: [
'areRulesEnabled',
'maxExpenseAmount',
'maxExpenseAge',
'maxExpenseAmountNoReceipt',
'maxExpenseAmountNoItemizedReceipt',
'defaultBillable',
'prohibitedExpenses',
'eReceipts',
'isAttendeeTrackingEnabled',
'preventSelfApproval',
'shouldShowAutoApprovalOptions',
'shouldShowAutoReimbursementLimitOption',
],
distanceRates: ['areDistanceRatesEnabled', 'customUnits'],
perDiem: ['arePerDiemRatesEnabled', 'customUnits'],
invoices: ['areInvoicesEnabled', 'invoice'],
travel: ['isTravelEnabled', 'travelSettings'],
} as const satisfies Record<Part, ReadonlyArray<keyof Policy>>;

type PolicyFieldsForPart = (typeof PARTS_TO_POLICY_FIELDS)[Part][number];

function setCopyPolicySettingsData(data: Partial<CopyPolicySettingsState>): void {
Onyx.merge(ONYXKEYS.COPY_POLICY_SETTINGS, data);
}

function clearCopyPolicySettings(): void {
Onyx.set(ONYXKEYS.COPY_POLICY_SETTINGS, {});
}

function requestCopyPolicySettingsNotification(): void {
write(WRITE_COMMANDS.COPY_POLICY_SETTINGS_NOTIFY, {});
}

function findCustomUnitByName(policy: Policy | undefined, unitName: string): CustomUnit | undefined {
if (!policy?.customUnits) {
return undefined;
}
return Object.values(policy.customUnits).find((unit) => unit.name === unitName);
}

/**
* Returns the customUnits patch to merge into the target policy when distanceRates and/or perDiem are
* being copied. The source unit data is written under the target's existing unit ID — a new ID is
* generated only when the target has no unit of that type yet.
*/
function buildCustomUnitsPatch(sourcePolicy: Policy, targetPolicy: Policy, isDistanceSelected: boolean, isPerDiemSelected: boolean): {customUnits: Record<string, CustomUnit>} | undefined {
if (!isDistanceSelected && !isPerDiemSelected) {
return undefined;
}

const patch: Record<string, CustomUnit> = {};

if (isDistanceSelected) {
const sourceDistance = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE);
if (sourceDistance) {
const targetDistance = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE);
const targetUnitID = targetDistance?.customUnitID ?? generateHexadecimalValue(13);
patch[targetUnitID] = {...sourceDistance, customUnitID: targetUnitID};
}
}

if (isPerDiemSelected) {
const sourcePerDiem = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL);
if (sourcePerDiem) {
const targetPerDiem = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL);
const targetUnitID = targetPerDiem?.customUnitID ?? generateHexadecimalValue(13);
patch[targetUnitID] = {...sourcePerDiem, customUnitID: targetUnitID};
}
}

if (Object.keys(patch).length === 0) {
return undefined;
}
return {customUnits: patch};
}

/**
* Returns the partial Policy patch derived from the selected `parts`, excluding fields whose
* mapping is handled separately (customUnits, categories, tags collection keys).
*/
function buildPolicyFieldPatch(sourcePolicy: Policy, parts: Part[]): Partial<Policy> {
const patch: Partial<Policy> = {};
for (const part of parts) {
for (const field of PARTS_TO_POLICY_FIELDS[part]) {
if (field === 'customUnits') {
continue;
}
// The PARTS_TO_POLICY_FIELDS values are typed as keyof Policy, so this assignment is safe.
(patch as Record<string, unknown>)[field] = sourcePolicy[field as keyof Policy];
}
}
return patch;
}

function buildExpandedPendingFields(parts: Part[]): Partial<Record<PolicyFieldsForPart, typeof CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE>> {
const pendingFields: Partial<Record<PolicyFieldsForPart, typeof CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE>> = {};
for (const part of parts) {
for (const field of PARTS_TO_POLICY_FIELDS[part]) {
pendingFields[field] = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE;
}
}
return pendingFields;
}

function buildClearedPendingFields(parts: Part[]): Partial<Record<PolicyFieldsForPart, null>> {
const cleared: Partial<Record<PolicyFieldsForPart, null>> = {};
for (const part of parts) {
for (const field of PARTS_TO_POLICY_FIELDS[part]) {
cleared[field] = null;
}
}
return cleared;
}

function snapshotTargetFields(targetPolicy: Policy, parts: Part[]): Partial<Policy> {
const snapshot: Partial<Policy> = {};
for (const part of parts) {
for (const field of PARTS_TO_POLICY_FIELDS[part]) {
(snapshot as Record<string, unknown>)[field] = targetPolicy[field as keyof Policy];
}
}
return snapshot;
}

type CopyPolicySettingsOnyxKeys =
| typeof ONYXKEYS.COLLECTION.POLICY
| typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES
| typeof ONYXKEYS.COLLECTION.POLICY_TAGS
| typeof ONYXKEYS.COPY_POLICY_SETTINGS;

function buildCopyPolicySettingsData(
sourcePolicy: Policy,
targetPolicies: Policy[],
parts: Part[],
allPolicyCategories: OnyxCollection<PolicyCategories>,
allPolicyTags: OnyxCollection<PolicyTagLists>,
): {
optimisticData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>>;
successData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>>;
failureData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>>;
} {
const optimisticData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>> = [];
const successData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>> = [];
const failureData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>> = [];

const policyFieldPatch = buildPolicyFieldPatch(sourcePolicy, parts);
const pendingFields = buildExpandedPendingFields(parts);
const clearedPendingFields = buildClearedPendingFields(parts);

const isCategoriesSelected = parts.includes('categories');
const isTagsSelected = parts.includes('tags');
const isDistanceSelected = parts.includes('distanceRates');
const isPerDiemSelected = parts.includes('perDiem');

const sourceCategoriesKey = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${sourcePolicy.id}` as const;
const sourceTagsKey = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${sourcePolicy.id}` as const;
const sourceCategories = allPolicyCategories?.[sourceCategoriesKey] ?? {};
const sourceTags = allPolicyTags?.[sourceTagsKey] ?? {};

for (const targetPolicy of targetPolicies) {
const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${targetPolicy.id}` as const;
const customUnitsPatch = buildCustomUnitsPatch(sourcePolicy, targetPolicy, isDistanceSelected, isPerDiemSelected);
const snapshot = snapshotTargetFields(targetPolicy, parts);

// Step 1+2: optimistic merge of patched fields + pending markers
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: policyKey,
value: {
...policyFieldPatch,
...customUnitsPatch,
pendingFields,
},
});

// Success: clear the pending markers
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: policyKey,
value: {
pendingFields: clearedPendingFields,
},
});

// Failure: restore snapshot, clear pending markers, surface RBR
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: policyKey,
value: {
...snapshot,
...(customUnitsPatch ? {customUnits: targetPolicy.customUnits} : {}),
pendingFields: clearedPendingFields,
errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'),
},
});

// Step 3: collection keys (categories / tags) — SET-level overwrite with snapshot rollback
if (isCategoriesSelected) {
const targetCategoriesKey = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy.id}` as const;
const previousCategories = allPolicyCategories?.[targetCategoriesKey] ?? {};
optimisticData.push({
onyxMethod: Onyx.METHOD.SET,
key: targetCategoriesKey,
value: sourceCategories,
});
failureData.push({
onyxMethod: Onyx.METHOD.SET,
key: targetCategoriesKey,
value: previousCategories,
});
}

if (isTagsSelected) {
const targetTagsKey = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicy.id}` as const;
const previousTags = allPolicyTags?.[targetTagsKey] ?? {};
optimisticData.push({
onyxMethod: Onyx.METHOD.SET,
key: targetTagsKey,
value: sourceTags,
});
failureData.push({
onyxMethod: Onyx.METHOD.SET,
key: targetTagsKey,
value: previousTags,
});
}
}

// Step 4: drive currentStep on the COPY_POLICY_SETTINGS key itself
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.COPY_POLICY_SETTINGS,
value: {currentStep: 'loading'},
});

failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.COPY_POLICY_SETTINGS,
value: {currentStep: undefined},
});

return {optimisticData, successData, failureData};
}

function copyPolicySettings(
sourcePolicy: Policy,
targetPolicies: Policy[],
parts: Part[],
allPolicyCategories: OnyxCollection<PolicyCategories>,
allPolicyTags: OnyxCollection<PolicyTagLists>,
): void {
const {optimisticData, successData, failureData} = buildCopyPolicySettingsData(sourcePolicy, targetPolicies, parts, allPolicyCategories, allPolicyTags);

const params: CopyPolicySettingsParams = {
sourcePolicyID: sourcePolicy.id,
policyIDList: targetPolicies.map((policy) => policy.id).join(','),
parts: parts.join(','),
};

write(WRITE_COMMANDS.COPY_POLICY_SETTINGS, params, {optimisticData, successData, failureData});
}

export {PARTS_TO_POLICY_FIELDS, setCopyPolicySettingsData, clearCopyPolicySettings, requestCopyPolicySettingsNotification, buildCopyPolicySettingsData, copyPolicySettings};
export type {Part};
Loading
Loading