Een contract kopiëren klinkt eenvoudig: selecteren, op kopiëren klikken, klaar. In de praktijk is het bij contractmanagement in SharePoint allesbehalve dat. Contracten staan in verschillende sites per land, divisie en contracttype, ze hebben tientallen metadatavelden en personenvelden, en er hangen documenten en rechten & verplichtingen aan. Een naïeve kopie loopt vast, verliest stilletjes gegevens of laat een rommel achter die niemand meer opruimt.
Voor een klant in de Benelux bouwden we vanaf nul een contractmanagementmodule op het Shareflex-platform, in TypeScript. Een van de functies waar gebruikers het meest blij mee zijn, is Copy Contract (contract kopiëren). In dit artikel laten we zien hoe het werkt, welke aanpak we kozen en echte code uit het project.
Waarom gebruikers contracten willen kopiëren
- Een nieuw leverancierscontract lijkt vaak voor 90% op een bestaand contract: dezelfde partner, dezelfde eigenaar, dezelfde clausules.
- Soms moet een contract naar een ander contracttype, bijvoorbeeld van een huurcontract naar een leverancierscontract.
- Metadata opnieuw intypen kost tijd en is de grootste bron van fouten in een contractregister.
De eis was dus: kopieer een contract, desgewenst met documenten en rechten & verplichtingen, naar elk contracttype in de organisatie. Veilig, traceerbaar en terug te draaien.
Het Copy Contract-venster
Vanuit elk opgeslagen contract klikt de gebruiker op Copy Contract… in de werkbalk van het formulier. Er opent een venster met drie kolommen: links de invoer, in het midden de validatie en rechts een live kopieerlogboek.

De gebruiker kiest een nieuwe titel (vooraf ingevuld als “… (Copy)”), een bestemming uit een lijst met alle contractsites in de organisatie, en of de secundaire gegevens mee moeten. De aantallen naast Documents en Rights & Obligations laten direct zien wat er gekopieerd wordt.
Stap 1 – Eerst valideren, dan kopiëren
De belangrijkste ontwerpkeuze: Start copy blijft uitgeschakeld tot de validatie geslaagd is, en die validatie hoort bij precies deze invoer. Wijzigt de titel of de bestemming, dan vervalt de validatie automatisch.
const currentInputKey = JSON.stringify({
newContractTitle,
selectedTarget: selectedTarget.map(option => option.key),
includeDocuments,
includeRightsAndObligations,
});
// Start copy is only enabled for the exact input that passed validation
const canStartCopy = validatedInputKey === currentInputKey
&& validationResults.length > 0
&& validationResults.every(result => result.level !== 'error');
const invalidateValidation = () => {
setValidatedInputKey('');
if (validationResults.length > 0) setValidationResults([
{ level: 'info', text: 'Input changed. Run validation again before starting the copy.' },
]);
};
De controles zelf draaien tegen de doelsite, met de rechten van de huidige gebruiker:
- Is het broncontract opgeslagen?
- Is de nieuwe titel ingevuld en niet langer dan 255 tekens?
- Is er precies één bestemming gekozen?
- Mag de gebruiker items aanmaken in de doellijst Contracts, en in elke gekozen secundaire lijst?
- Bestaat er in het doel al een contract met dezelfde titel?
const checkTargetListAddPermission = async (webUrl: string, listUrl: string) => {
const endpoint = `${webUrl}/_api/web/GetList(@listUrl)/EffectiveBasePermissions`
+ `?@listUrl='${encodeURIComponent(SF.urlUtil.makeServerRelative(listUrl))}'`;
const permissions = await SF.request(endpoint, { headers: { 'Cache-Control': 'no-cache' } });
return SF.parseItemPermissions(permissions).hasAddListItemsPermission();
};
// Duplicate title check in the target contract web
const duplicates = await SF.getListDataService().getAllPages(target.webUrl, null, targetContractsListUrl, {
viewFields: ['ID'],
innerWhereCaml: SF.camlUtil.camlEq('Title', trimmedTitle),
});
results.push(duplicates.length > 0
? { level: 'error', text: `A contract with this title already exists in ${target.text}.` }
: { level: 'pass', text: `No existing contract with this title found in ${target.text}.` });

Elk resultaat heeft een niveau (pass, error, warning of info), zodat de gebruiker precies ziet waarom een kopie niet kan starten, in plaats van halverwege een foutmelding te krijgen.
Stap 2 – Velden generiek kopiëren, zonder vaste lijst
De eerste neiging is een vaste lijst met te kopiëren velden. Die lijst is verouderd zodra iemand een kolom toevoegt. Daarom vragen we SharePoint welke velden echt bij de lijst horen: niet verborgen, niet alleen-lezen en niet overgenomen van het basistype.
/**
* Reads the internal names of a list's own (non-system, non-computed) fields, so the copy does not
* depend on a hardcoded, easily-outdated field whitelist.
*/
async function getCopyableFieldNames(SF: any, webUrl: string, listTitle: string, excludedFieldNames: Set<string>) {
const endpoint = `${webUrl}/_api/web/lists/getbytitle('${listTitle}')/fields`
+ `?$select=InternalName,Hidden,ReadOnlyField,FromBaseType,TypeAsString`
+ `&$filter=Hidden eq false and ReadOnlyField eq false and FromBaseType eq false`;
const response = await SF.request(endpoint, { headers: { Accept: 'application/json;odata=nometadata' } });
const rawFields = Array.isArray(response) ? response : response?.value ?? [];
const eligibleFields = rawFields.filter(field =>
!excludedFieldNames.has(field.InternalName) && !field.InternalName.startsWith('_'));
const isUserField = field => field.TypeAsString === 'User' || field.TypeAsString === 'UserMulti';
return {
genericFieldNames: eligibleFields.filter(field => !isUserField(field)).map(field => field.InternalName),
userFieldNames: eligibleFields.filter(isUserField).map(field => field.InternalName),
};
}
Personenvelden zijn de uitzondering. Hun leesformaat kan niet zomaar worden teruggeschreven, dus we lezen ze apart met $expand en zetten ze om naar het formaat dat de personenkiezer verwacht:
// People fields: read {Name, Title, EMail} and re-serialize to the picker format
const normalized = users
.map(user => ({
Key: user.Name || user.EMail || user.Title || '',
DisplayText: user.Title || user.EMail || user.Name || '',
IsResolved: true,
}))
.filter(user => user.Key !== '');
if (normalized.length > 0) result[fieldName] = JSON.stringify(normalized);
Daarna wordt het doelcontract aangemaakt, met een nieuw contract-ID en het contracttype van de bestemming:
const { genericFieldNames, userFieldNames } = await getCopyableFieldNames(
SF, sourceWebUrl, 'Contracts',
new Set(['ContentType', 'Attachments', 'ContractId', 'Title', 'ContractType']));
// ...read the source item, serialize every copyable field...
Object.assign(createFields, await fetchPeopleFieldValues(SF, sourceWebUrl, sourceListUrl, sourceItemId, userFieldNames));
createFields.Title = trimmedTitle;
createFields.ContractId = SF.makePrimaryId('####-####'); // a fresh contract ID
createFields.ContractType = target.contractTypeName; // the target's contract type
appendCopyLogEntry('info', 'Creating target contract...');
const createResponse = await SF.getListDataService()
.createItem(target.webUrl, null, targetContractsListUrl, createFields, targetFolderPath, true, false, false);
Stap 3 – Documenten kopiëren met SP.MoveCopyUtil
Documenten zijn echte bestanden, dus gebruiken we de serverkopie van SharePoint zelf: SP.MoveCopyUtil.CopyFileByPath. Het bestand gaat nooit via de browser, de opties zijn veilig ingesteld en de kopie werkt over sites heen. Eén valkuil kwamen we tegen: anders dan bij het gewoon aanmaken van een item maakt CopyFileByPath de doelmap niet zelf aan. Die maken we dus eerst.
// CopyFileByPath does not create its destination folder itself,
// so the per-contract folder is ensured explicitly first.
await SF.request(`${targetWebUrl}/_api/web/folders`, {
method: 'POST',
headers: { Accept: 'application/json;odata=nometadata', 'Content-Type': 'application/json;odata=nometadata' },
body: JSON.stringify({ ServerRelativeUrl: targetFolderPath }),
});
await SF.request(`${targetWebUrl}/_api/SP.MoveCopyUtil.CopyFileByPath()`, {
method: 'POST',
headers: { Accept: 'application/json;odata=nometadata', 'Content-Type': 'application/json;odata=nometadata' },
body: JSON.stringify({
srcPath: { DecodedUrl: sourceFileUrl },
destPath: { DecodedUrl: targetFileUrl },
options: { KeepBoth: true, ResetAuthorAndCreatedOnCopy: true, ShouldBypassSharedLocks: true },
}),
});
// ...then re-link the copied document to the new contract and copy its metadata
await SF.getListDataService().updateItem(targetWebUrl, null, targetListUrl, copiedItemId, fields);
Rechten & verplichtingen worden op dezelfde manier item voor item gekopieerd en elk aan het nieuwe contract gekoppeld.
Stap 4 – Een live logboek en een audit trail
Tijdens het kopiëren ziet de gebruiker een logboek met tijdstempels, nieuwste bovenaan, dat met één klik naar het klembord kan voor support. Zodra het nieuwe contract bestaat, verschijnt er een link om het te openen.

Elke kopieerpoging wordt ook vastgelegd in een centrale lijst ContractCopyLog in de hoofdsite: wie wat kopieerde, van waar naar waar, en de status (Primary created, Completed, Failed, Undone) met het volledige logboek. Contractbeheerders kunnen altijd terugzien waar een contract vandaan komt.
Stap 5 – Veilig ongedaan maken
Fouten gebeuren. Met de knop Undo copy verdwijnt de kopie weer, maar nooit definitief: eerst gaan de gekopieerde secundaire items en daarna het contract naar de SharePoint-prullenbak. Het broncontract blijft altijd onaangeroerd.
const confirmed = window.confirm(
`Undo copy?\n\nThis will move the copied contract "${copyResultLink.title}" (ID ${copyResultLink.itemId}) `
+ `from ${copyResultLink.webUrl} to the SharePoint recycle bin.\n\nThe source contract will not be changed.`);
if (!confirmed) return;
// Secondary items first, then the contract itself – always to the recycle bin, never a hard delete
for (const secondaryItem of copyResultLink.secondaryItems) {
const recycleEndpoint = `${secondaryItem.webUrl}/_api/web/GetList(@listUrl)/items(@itemId)/Recycle()`
+ `?@listUrl='${encodeURIComponent(SF.urlUtil.makeServerRelative(secondaryItem.listUrl))}'`
+ `&@itemId='${encodeURIComponent(secondaryItem.itemId)}'`;
await SF.request(recycleEndpoint, {
method: 'POST',
headers: { Accept: 'application/json;odata=nometadata', ...getDisableRulesEventHeaders() },
});
}
// ...same call for the copied contract, then:
await updateContractCopyLogItem('Undone');
Wat het oplevert
- Minuten in plaats van een uur voor elk nieuw contract dat op een bestaand contract lijkt.
- Geen gegevensverlies: nieuwe kolommen gaan automatisch mee, personenvelden ook.
- Geen verrassingen: rechten en dubbele titels worden gecontroleerd voordat er iets wordt geschreven.
- Volledig traceerbaar: elke kopieerpoging staat in de ContractCopyLog.
- Veilig om te proberen: ongedaan maken gaat via de prullenbak en de bron blijft intact.
Wat we geleerd hebben
- Valideer tegen het doel, met de rechten van de gebruiker zelf, niet op basis van aannames.
- Laat SharePoint vertellen welke velden er zijn; vermijd vaste veldlijsten.
- Schrijf eerst het hoofditem en log elke stap, zodat een fout halverwege altijd zichtbaar en herstelbaar is.
- Gebruik de eigen server-API’s van SharePoint voor bestanden, en ken hun eigenaardigheden (mappen!).
Ook zoiets nodig?
Copy Contract is één voorbeeld van wat we bouwen bovenop Shareflex Contract en het Shareflex-platform: modules die passen bij uw contractlandschap en die draaien in uw eigen Microsoft 365-tenant. Benieuwd hoe Shareflex in uw tenant komt? Lees onze handleiding over Shareflex installeren op uw SharePoint-tenant. Neem contact op, dan laten we u graag een demo zien.
