<template>
<lightning-card title="Expense Claim Form" icon-name="custom:custom14">
<div class="slds-var-m-around_medium">
<div class="slds-grid slds-wrap">
<div class="slds-col slds-size_1-of-2 slds-var-p-around_x-small">
<lightning-input type="text" label="Naam" value={expense.Naam__c} onchange={handleInputChange} data-field="Naam__c" required></lightning-input>
</div>
<div class="slds-col slds-size_1-of-2 slds-var-p-around_x-small">
<lightning-radio-group
label="Rol"
options={roleOptions}
value={expense.Role__c}
variant="label-hidden"
onchange={handleRoleChange}
data-field="Role__c">
</lightning-radio-group>
</div>
<div class="slds-col slds-size_1-of-2 slds-var-p-around_x-small">
<lightning-input
type="email"
label="E-mail"
value={expense.Email_Volunteer__c}
onchange={handleInputChange}
data-field="Email_Volunteer__c"
required>
</lightning-input>
</div>
<div class="slds-col slds-size_1-of-2 slds-var-p-around_x-small">
<lightning-input
type="text"
label="IBAN"
value={expense.IBAN__c}
onchange={handleInputChange}
data-field="IBAN__c"
required >
</lightning-input>
</div>
<template if:true={isCodeEenheidVisible}>
<div class="slds-col slds-size_1-of-2 slds-var-p-around_x-small">
<lightning-combobox
name="codeEenheid"
label="Code eenheid"
value={expense.Code_eenheid2__c}
options={codeEenheidOptions}
onchange={handleInputChange}
data-field="Code_eenheid2__c"
required>
</lightning-combobox>
</div>
</template>
<div class="slds-col slds-size_1-of-2 slds-var-p-around_x-small">
<lightning-input
type="text"
label="Woonplaats"
value={expense.Woonplaats__c}
onchange={handleInputChange}
data-field="Woonplaats__c"
required></lightning-input>
</div>
</div>
<table class="slds-table slds-table_cell-buffer slds-table_bordered slds-table_striped">
<thead>
<tr class="slds-line-height_reset">
<th scope="col" style="width: 15%;">
<div class="slds-truncate" title=" Selecteer de datum waarop de kosten zijn gemaakt. Je kunt ook zelf de datum intypen zoals 27-11-2024">Datum (i)</div>
</th>
<th scope="col" style="width: 25%;">
<div class="slds-truncate" title="Vul een korte omschrijving in. Bij km vergoeding kan dit bijvoorbeeld zijn: 'van huis naar beheerkantoor x' of ‘treinkaartje begin- en eindstation’.">Omschrijving (i)</div>
</th>
<th scope="col" style="width: 15%;">
<div class="slds-truncate" title="Maak een keuze tussen een kilometer- of kostenvergoeding">Declaratiecategorie (i)</div>
</th>
<th scope="col" style="width: 15%;">
<div class="slds-truncate" title="Bij de keuze voor kilometervergoeding kan er gekozen worden voor type vervoermiddel. Bij Keuze voor kostenvergoeding kan gekozen worden voor openbaar vervoer, veerboot/pont/tunnel en intermediaire kosten (een bedrag voorgeschoten voor NM)">Declaratietype (i)</div>
</th>
<th scope="col" style="width: 10%;">
<div class="slds-truncate" title="Als je reist van de ene naar de andere werklocatie van NM, dan is dit een dienstreis. Vink dan Dienstreis aan">Dienstreis?(i)</div>
</th>
<th scope="col" style="width: 10%;">
<div class="slds-truncate" title="Vul de gereden kilometers heen en terug in, max. 50 km">Kilometers (km)(i)</div>
</th>
<th scope="col" style="width: 10%;">
<div class="slds-truncate" title="Vul het bedrag van de gemaakte kosten in">Bedrag (€)(i)</div>
</th>
<th scope="col" style="width: 5%;">
<div class="slds-truncate" title="Als je de ingevulde regel wilt verwijderen, klik je op de prullebak ">Acties(i)</div>
</th>
</tr>
</thead>
<tbody>
<template for:each={expenseItems} for:item="item" for:index="index">
<tr key={item.id}>
<td style="width: 15%;">
<lightning-input type="date" label="Datum" variant="label-hidden" value={item.expenseDate} onchange={handleItemChange}
data-index={index} data-field="expenseDate" required></lightning-input>
</td>
<td style="width: 25%;">
<lightning-input type="text" label="Omschrijving" variant="label-hidden" value={item.description} onchange={handleItemChange}
data-index={index} data-field="description" required></lightning-input>
</td>
<td style="width: 15%;">
<lightning-combobox name="expenseCategory" label="Categorie" variant="label-hidden" value={item.expenseCategory}
options={expenseCategoryOptions} onchange={handleItemChange}
data-index={index} data-field="expenseCategory"
required></lightning-combobox>
</td>
<td style="width: 15%;">
<lightning-combobox name="expenseType" label="Type" variant="label-hidden" value={item.expenseType}
options={item.expenseTypeOptions} onchange={handleItemChange}
data-index={index} data-field="expenseType"
required></lightning-combobox>
</td>
<td style="width: 10%;">
<lightning-input type="checkbox" label="Dienstreis?" variant="label-hidden" checked={item.isBusinesstrip} onchange={handleItemChange} data-index={index} data-field="isBusinesstrip"></lightning-input>
</td>
<td style="width: 10%;">
<lightning-input type="number" label="Kilometers (km)" variant="label-hidden" value={item.mileage} readonly={item.isReimbursement} onchange={handleItemChange} data-index={index} data-field="mileage"></lightning-input>
</td>
<td style="width: 10%;">
<template if:true={item.isMileage}>
<lightning-formatted-number
value={item.calculatedAmount}
format-style="currency"
currency-code="EUR">
</lightning-formatted-number>
</template>
<template if:true={item.isReimbursement}>
<lightning-input
type="text" label="Bedrag (€)" variant="label-hidden" value={item.amount}
onchange={handleItemChange} onblur={handleBlur} data-index={index} data-field="amount" required></lightning-input>
</template>
</td>
<td style="width: 5%;">
<lightning-button variant="destructive" icon-name="utility:delete" size="small" title="Verwijderen" onclick={removeItem} data-index={index}></lightning-button>
</td>
</tr>
</template>
</tbody>
</table>
<div class="slds-var-m-top_medium slds-var-m-bottom_small">
<lightning-button variant="brand" label="Declaratieregel Toevoegen" title="Declaratieregel Toevoegen" icon-name="utility:add" onclick={addItem}></lightning-button>
</div>
<div class="slds-var-m-top_medium slds-var-m-bottom_small">
<lightning-formatted-number value={totalAmount} format-style="currency" currency-code="EUR"></lightning-formatted-number>
</div>
<lightning-button type="submit" label="Indienen" variant="brand" onclick={handleSubmit} disabled={isSubmitDisabled}></lightning-button>
</div>
</lightning-card>
</template>
import { LightningElement, track, wire } from 'lwc';
import { getPicklistValues, getObjectInfo } from 'lightning/uiObjectInfoApi';
import EXPENSE_ITEM_OBJECT from '@salesforce/schema/ExpenseItem__c';
import EXPENSE_TYPE_KM_FIELD from '@salesforce/schema/ExpenseItem__c.Expense_Type_KM__c';
import CODE_EENHEID_FIELD from '@salesforce/schema/Expense__c.Code_eenheid2__c';
import EXPENSE_OBJECT from '@salesforce/schema/Expense__c';
import createExpenseWithItems from '@salesforce/apex/ExpenseForm.createExpenseWithItems';
import doesEmailExist from '@salesforce/apex/ExpenseForm.doesEmailExist';
import ToastContainer from 'lightning/toastContainer';
import Toast from 'lightning/toast';
export default class ExpenseForm extends LightningElement {
@track expense = { Naam__c: '', Email_Volunteer__c: '', IBAN__c: '', Code_eenheid2__c: '', Woonplaats__c: '', Role__c: 'Vrijwilliger' };
@track expenseItems = [
{ id: Date.now(), expenseDate: '', description: '', expenseCategory: '', expenseType: '', isBusinesstrip: false, mileage: null, amount: null, calculatedAmount: null, isMileage: false, isReimbursement: false, expenseTypeOptions: [] }
];
@track totalAmount = 0;
@track codeEenheidOptions = []; // Store Code_eenheid options here
@track mileageTypeOptions = []; // Store fetched mileage type options
@track isCodeEenheidVisible = true; // Track visibility of codeEenheid field
isLoading = false;
isSubmitDisabled = false;
error;
expenseCategoryOptions = [
{ label: 'Kilometervergoeding', value: 'Mileage' },
{ label: 'Kostenvergoeding', value: 'Reimbursement' }
];
reimbursementTypeOptions = [
{ label: 'Openbaar vervoer', value: 'Openbaar vervoer' },
// { label: 'Overige kosten', value: 'Overige kosten' },
{ label: 'Intermediaire kosten', value: 'Intermediaire kosten' },
{ label: 'Veerboot/Pont/Tunnel', value: 'Veerboot/Pont/Tunnel' }
];
roleOptions = [
{ label: 'Vrijwilliger', value: 'Vrijwilliger' },
{ label: 'LC Lid', value: 'LC Lid' }
];
@wire(getObjectInfo, { objectApiName: EXPENSE_OBJECT })
objectInfo;
@wire(getPicklistValues, {
recordTypeId: '$objectInfo.data.defaultRecordTypeId',
fieldApiName: CODE_EENHEID_FIELD
})
getCodeEenheidPicklistValues({ error, data }) {
if (data) {
this.codeEenheidOptions = data.values.map(value => {
return { label: value.label, value: value.value };
});
this.error = undefined;
} else if (error) {
this.error = 'Error loading Code eenheid picklist values';
console.error('Error fetching Code eenheid picklist values:', error);
}
}
@wire(getObjectInfo, { objectApiName: EXPENSE_ITEM_OBJECT })
expenseItemObjectInfo;
@wire(getPicklistValues, {
recordTypeId: '$expenseItemObjectInfo.data.defaultRecordTypeId',
fieldApiName: EXPENSE_TYPE_KM_FIELD
})
getMileageTypeOptions({ error, data }) {
if (data) {
this.mileageTypeOptions = data.values.map(value => {
return { label: value.label, value: value.value };
});
this.error = undefined;
} else if (error) {
this.error = 'Error loading mileage type picklist values';
console.error('Error fetching mileage type picklist values:', error);
}
}
handleInputChange(event) {
const { field } = event.target.dataset;
let value = event.target.value;
if (field === 'Email_Volunteer__c') {
this.checkEmail(event.target.value);
}
if (field === 'IBAN__c') {
// Remove spaces and convert to uppercase
value = value.replace(/\s+/g, '').toUpperCase();
event.target.value = value; // Reflect changes in the UI
this.validateIBAN(event.target); // Validate IBAN
}
this.expense[field] = value; // Update the expense object
console.log('handleInputChange:', this.expense);
}
validateIBAN(inputField) {
const ibanPattern = /^NL\d{2}[A-Z0-9]{4}[A-Z0-9]{10}$/;
const value = inputField.value;
let errorMessage = '';
// Perform both validations
if (!ibanPattern.test(value) || !this.validateIBANMod97(value)) {
errorMessage = 'Voer een geldig Nederlands IBAN-nummer in.';
}
inputField.setCustomValidity(errorMessage);
inputField.reportValidity();
}
// Modulus 97 check for IBAN validation
validateIBANMod97(iban) {
try {
// Move the first four characters to the end
const rearrangedIban = iban.slice(4) + iban.slice(0, 4);
// Convert letters to numbers (A = 10, B = 11, ..., Z = 35)
const numericIban = rearrangedIban.replace(/[A-Z]/g, (char) => {
return char.charCodeAt(0) - 55;
});
// Perform modulus 97 operation
const remainder = BigInt(numericIban) % 97n;
// If remainder is 1, the IBAN is valid
return remainder === 1n;
} catch (error) {
console.error('IBAN Mod-97 validation failed:', error);
return false; // Return false if there's an issue
}
}
handleRoleChange(event) {
const { field } = event.target.dataset;
this.expense = { ...this.expense, [field]: event.target.value };
// Check if the role is 'LC Lid' and set Code eenheid to '21000 Bestuur en Vereniging 913'
if (this.expense.Role__c === 'LC Lid') {
this.expense.Code_eenheid2__c = '21000 Bestuur en Vereniging 913'; // Automatically set Code eenheid to '21000 Bestuur en Vereniging 913'
this.isCodeEenheidVisible = false; // Optionally hide the Code eenheid field
} else {
this.isCodeEenheidVisible = true; // Show the Code eenheid field for other roles
}
}
handleItemChange(event) {
const { index, field } = event.target.dataset;
const value = this.extractFieldValue(event, field);
// Process field-specific logic
switch (field) {
case 'expenseCategory':
this.updateExpenseCategory(index, value);
break;
case 'expenseType': // When expense type changes
this.updateGenericField(index, field, value);
this.updateCalculatedFields(index); // Recompute after type selection
break;
case 'isBusinesstrip':
this.updateBusinessTrip(index, field, value);
break;
case 'mileage':
this.updateMileage(index, field, value, event.target);
this.updateCalculatedFields(index); // Recompute after type selection
break;
case 'amount':
this.updateAmount(index, field, value, event.target);
break;
default:
this.updateGenericField(index, field, value);
break;
}
// Recompute calculated fields and total amount
this.updateCalculatedFields(index);
this.expenseItems = [...this.expenseItems]; // Trigger reactivity
this.calculateTotalAmount();
}
//Updates the expense category and adjusts related fields.
updateExpenseCategory(index, value) {
const item = this.expenseItems[index];
// Set flags based on the selected category
item.expenseCategory = value; // Assign the selected value
item.isMileage = value === 'Mileage';
item.isReimbursement = value === 'Reimbursement';
// Reset fields and options based on the selected category
if (value === 'Mileage') {
item.expenseTypeOptions = this.mileageTypeOptions;
item.mileage = null; // Ensure mileage starts fresh
item.calculatedAmount = 0; // Reset calculated amount
} else if (value === 'Reimbursement') {
item.expenseTypeOptions = this.reimbursementTypeOptions;
item.mileage = null; // Clear mileage if switching to reimbursement
item.calculatedAmount = null; // Hide calculated amount
} else {
item.expenseTypeOptions = []; // Reset options if the category doesn't match predefined values
}
this.expenseItems = [...this.expenseItems]; // Trigger reactivity
}
//Extracts the field value from the event based on its type.
extractFieldValue(event, field) {
return field === 'isBusinesstrip'
: field === 'amount'
? event.target.value.replace(',', '.')
}
updateGenericField(index, field, value) {
this.expenseItems[index][field] = value;
}
updateBusinessTrip(index, field, value) {
this.expenseItems[index][field] = value;
}
//Updates the mileage field and validates it.
updateMileage(index, field, value, target) {
const parsedValue = parseFloat(value);
this.expenseItems[index][field] = parsedValue;
if (parsedValue > 50) {
target.setCustomValidity('De maximale afstand per declaratie is 50 kilometer.');
} else {
target.setCustomValidity('');
}
target.reportValidity();
}
handleBlur(event) {
const index = event.target.dataset.index;
const field = event.target.dataset.field;
const rawInput = event.target.value.trim();
try {
// Parse the input to handle commas as decimal separators
const parsedValue = parseFloat(rawInput.replace(',', '.'));
if (isNaN(parsedValue) || parsedValue <= 0) {
// Show validation error if the input is invalid
event.target.setCustomValidity('Voer een geldig bedrag in (bijvoorbeeld 25,99).');
} else {
// Update internal model with the parsed value
this.expenseItems[index][field] = parsedValue;
// Format the value to Dutch style
const formattedValue = new Intl.NumberFormat('nl-NL', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(parsedValue);
// Reflect the formatted value in the UI
event.target.value = formattedValue;
event.target.setCustomValidity(''); // Clear previous errors
}
event.target.reportValidity();
} catch (error) {
console.error('Error in handleBlur:', error);
event.target.setCustomValidity('Er is een fout opgetreden.');
event.target.reportValidity();
}
}
updateAmount(index, field, value) {
// Raw input handling during typing (optional, for live validation)
const rawInput = value.trim();
// Handle commas and typing dynamically without formatting
if (!rawInput.endsWith(',')) {
const parsedValue = parseFloat(rawInput.replace(',', '.'));
if (!isNaN(parsedValue)) {
this.expenseItems[index][field] = parsedValue;
}
}
}
//Recomputes calculated fields such as the mileage amount.
updateCalculatedFields(index) {
const item = this.expenseItems[index];
if (item.isMileage && item.expenseType && item.mileage) {
item.calculatedAmount = this.calculateMileageAmount(item);
// Format the calculated amount for Dutch locale
const formattedAmount = this.formatNumberForDutchLocale(item.calculatedAmount);
console.log('Formatted Amount (Dutch):', formattedAmount); // Logs: €1.234,56
} else {
item.calculatedAmount = 0; // Reset if conditions are not met
}
}
formatCurrency(value) {
// Convert the input to a number with 2 decimal places
const number = parseFloat(value).toFixed(2);
if (isNaN(number)) {
return null;
}
return parseFloat(number);
}
formatNumberForDutchLocale(value) {
const formatter = new Intl.NumberFormat('nl-NL', {
style: 'decimal', //Pieter: of 'currency' ?
//currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return formatter.format(value);
}
calculateMileageAmount(item) {
if (item.expenseCategory === 'Mileage' && item.expenseType) {
const rate = this.getMileageRate(item.expenseType); // Fetch rate
const amount = item.mileage ? item.mileage * rate : 0;
// Format the amount for the Dutch locale
const formatter = new Intl.NumberFormat('nl-NL', {
style: 'currency',
currency: 'EUR',
});
return parseFloat(amount.toFixed(2)); // Store number format internally
}
return 0; // Default to 0
}
getMileageRate(expenseType) {
// Fetch mileage rate based on expense type
const rates = {
'(e-)Fiets of lopen': 0.23,
'Motor': 0.28,
'Auto, benzine': 0.28,
'Auto, diesel': 0.28,
'Auto, (plug-in) hybride': 0.28,
'Auto, 100% elektrisch': 0.28,
'Auto, andere brandstoffen': 0.28,
'Motorfiets, benzine': 0.28,
'Motorfiets, elektrisch': 0.28,
'Bromfiets/scooter, benzine': 0.28,
'Bromfiets/scooter, elektrisch (inclusief speed-pedelec)': 0.28
};
return rates[expenseType] || 0; // Default to 0 if type is undefined
}
addItem() {
this.expenseItems = [
...this.expenseItems,
{ id: Date.now(), expenseDate: '', description: '', expenseCategory: '', expenseType: '', isBusinesstrip: false, mileage: null, amount: null, calculatedAmount: null, isMileage: false, isReimbursement: false, expenseTypeOptions: [] }
];
this.calculateTotalAmount();
}
removeItem(event) {
const index = event.target.dataset.index;
this.expenseItems.splice(index, 1);
this.expenseItems = [...this.expenseItems];
this.calculateTotalAmount();
}
calculateTotalAmount() {
this.totalAmount = this.expenseItems.reduce((total, item) => {
if (item.isMileage) {
return total + (item.calculatedAmount || 0);
}
return total + (item.amount || 0);
}, 0);
}
validateForm() {
const allValid = [...this.template.querySelectorAll('lightning-input, lightning-combobox')]
.reduce((validSoFar, inputCmp) => {
inputCmp.reportValidity();
return validSoFar && inputCmp.checkValidity();
}, true);
// Check if any expense item exceeds 999 euros
const validAmounts = this.expenseItems.every(item => item.amount <= 999);
if (!validAmounts) {
this.showErrorToast('Fout', 'Het maximale bedrag per declaratie is 999 euro.');
return false;
}
return allValid && validAmounts;
}
handleSubmit() {
if (!this.validateForm()) {
this.showErrorToast('Fout', 'Vul alle verplichte velden in.');
return;
}
this.isLoading = true;
const itemsToSubmit = this.expenseItems.map(item => ({
expenseDate: item.expenseDate,
description: item.description,
expenseCategory: item.expenseCategory,
expenseType: item.expenseType,
isBusinesstrip: item.isBusinesstrip,
mileage: item.mileage,
amount: item.isMileage ? this.calculateMileageAmount(item) : item.amount
}));
const payload = { expense: this.expense, items: itemsToSubmit };
console.log('Submitting data to Apex:', JSON.stringify(payload));
createExpenseWithItems({ payload: JSON.stringify(payload) })
.then(() => {
console.log('Expense submitted successfully');
this.showSuccessToast();
this.resetForm();
})
.catch(error => {
console.error('Error submitting expense:', error);
this.showErrorToast('Error', 'Error submitting expense');
})
.finally(() => {
this.isLoading = false;
});
}
checkEmail(email) {
doesEmailExist({ email })
.then(result => {
this.isSubmitDisabled = !result;
})
.catch(error => {
console.error('Error checking email:', error);
// Only log the error without showing a toast
});
}
showSuccessToast() {
{
label: 'Succes',
message: 'uw declaratie is naar het door u opgegeven e-mailadres verzonden. Open uw e-mail om de declaratie verder af te maken',
mode: 'dismissible',
variant: 'success'
},
this
);
}
showErrorToast(title, message) {
{
label: title,
message: message,
mode: 'dismissible',
variant: 'error'
},
this
);
}
resetForm() {
this.expense = { Naam__c: '', Email_Volunteer__c: '', IBAN__c: '', Code_eenheid2__c: '', Woonplaats__c: '', Role__c: 'Vrijwilliger' };
this.isCodeEenheidVisible = true; // Reset visibility of codeEenheid field
this.expenseItems = [
{ id: Date.now(), expenseDate: '', description: '', expenseCategory: '', expenseType: '', isBusinesstrip: false, mileage: null, amount: null, calculatedAmount: null, isMileage: false, isReimbursement: false, expenseTypeOptions: [] }
];
this.totalAmount = 0;
}
}