Digitising Paper-Based Fault Logging: Building a Technical Job Card System with Ionic
I watched a technician fill out the same paper job card three times in one day. The first two copies were lost somewhere between the depot and the office. Third time, the admin finally got it, typed it up manually, and the job was logged - two days after the work was done.
This happened regularly. Paper forms got lost in cabs, left on dashboards, rained on. Handwriting was illegible. Technicians took photos on their personal phones and never attached them to the right job. By the time data reached the system, the context was gone and whoever was reading it had to guess at details.
The brief was simple: let technicians complete, photograph, sign and submit service reports from their phone in the field. The data goes in immediately, the paper trail disappears, and nobody has to retype anything. That's the goal.
What I actually had to build was a multi-step form wizard with dynamic fields from an API, inline photo capture with instant preview, signature pad integration with tricky canvas timing, a multipart upload that fought with Angular's HttpClient until I stopped using it, and a BFF endpoint that accepts mixed binary and JSON in one request.
Dynamic form schema - fields that change without an app update
The first architectural decision was whether to hardcode the form fields. I didn't.
The operations team regularly needs to add new checklist items, rename fields, or change validation rules when new hardware gets introduced to the fleet. If those changes required an app update, every change would mean waiting for App Store review - days of delay for what should be a five-minute edit.
The job card form schema comes from the API:
getAllFormFieldsForBlankJobCardAdd(faultID: string) { const toPass = `${this.authService.fetchUID()}&faultID=${faultID}`; return this.http.get( `${this.primaryUrl}technicalLogging/` + `getAllFormFieldsForBlankJobCardAdd/${toPass}` ).pipe( catchError(error => { return this.http.get( `${this.fallbackUrl}technicalLogging/` + `getAllFormFieldsForBlankJobCardAdd/${toPass}` ); }) ); }
The API returns field names, types, validation rules and groupings. The modal renders them dynamically. When the upstream system adds a new hardware item to the checklist, every technician sees it on their next job card without me touching a line of code or shipping an update. That trade-off was obvious once I understood the operations team's workflow - they're not developers, and they shouldn't need one to adjust a form.
The multi-step wizard - because a single scrolling form doesn't work in a truck cab
My first version was a single scrolling form. I tested it on my phone at my desk and it worked fine. Then I thought about the actual conditions: a technician under a truck, one hand holding the phone, possibly gloved, South African summer sunlight making the screen half-visible.
A single scrolling form in those conditions is a nightmare. Fields get missed. The technician scrolls past something and doesn't realise. They submit incomplete data and get a validation error with no indication of which field they missed.
I split the job card into four steps:
- Vehicle Details - order number, fleet number, registration, driver info (pre-populated where available)
- Hardware and Installation Items - dynamic checklist from the API schema, counters for billing
- Photos - inline camera capture of the work
- Signature and Summary - sign on canvas, add any notes
goToNextStep(): void { if (this.currentStep < this.totalSteps) { this.currentStep++; if (this.currentStep === this.totalSteps) { setTimeout(() => this.initSignaturePad(), 200); } } } goToPreviousStep(): void { if (this.currentStep > 1) { this.currentStep--; } }
The setTimeout on the signature step isn't optional. The canvas element needs to be in the DOM before SignaturePad can measure its dimensions. Without the delay, the signature pad initialises with zero width and height because Angular hasn't finished rendering the step transition. I found this by watching technicians try to sign and seeing nothing appear - the canvas was there visually but the pad had measured itself as 0x0 and wasn't registering input. 200ms is enough for Angular to finish the render.
Each step is a contained section of the form. The technician can see their progress, can go back if they need to, and validation errors are localised to the current step rather than scattered across a page-length list.
Photo capture - preview before submit, no second chances
I spent time evaluating Capacitor's Camera API for native camera access. Better control, native picker UI, direct access to the camera hardware. In the end I went with a plain file input:
<input type="file" accept="image/*" multiple (change)="onImageSelected($event)">
On mobile this triggers the device camera or gallery picker. It works identically on iOS, Android and the PWA with zero plugin code. Capacitor's Camera API would have added a dependency and platform-specific code for a feature that the browser already handles.
Selected images are stored as blobs with a preview URL generated immediately:
onImageSelected(event: Event): void { const files = (event.target as HTMLInputElement).files; if (!files) return; for (let i = 0; i < files.length; i++) { const file = files[i]; this.selectedImages.push({ blob: file, name: file.name, description: '', toUrl: this.generateUploadPath(file.name), preview: URL.createObjectURL(file) }); } }
The URL.createObjectURL preview shows immediately without uploading. This is the most important detail for field use. A technician photographs wiring inside a dashboard in dim lighting. They need to verify the photo is actually usable - in focus, correctly framed, showing the relevant component - before they submit and close the job. There's no going back to the vehicle 50 km down the road to retake a blurry photo. The preview shows before any data leaves the device.
Signature capture - and the canvas timing problem
The signature pad uses the signature_pad library on an HTML canvas element:
initSignaturePad(): void { const canvas = this.signatureCanvas.nativeElement; canvas.width = canvas.offsetWidth; canvas.height = 200; this.signaturePad = new SignaturePad(canvas, { backgroundColor: 'rgb(255, 255, 255)', penColor: 'rgb(0, 0, 0)' }); }
The canvas width is set dynamically to the container's width - whatever the screen size, the signature area spans the full available space. The signature exports as a data URL included in the submission payload. Technicians can clear and redo before submitting.
The timing issue - the 200ms delay before calling initSignaturePad() - is worth explaining properly. Angular's step transition works by toggling *ngIf on each step's content. When you move to the signature step, Angular removes the previous step's DOM and adds the signature step's DOM in the same change detection cycle. The canvas element technically exists in the DOM at that point, but its layout dimensions haven't been calculated yet - offsetWidth returns 0 because the browser hasn't had time to reflow. SignaturePad reads offsetWidth immediately, measures 0, and initialises a zero-width canvas that accepts no input. The delay gives the browser time to finish layout before the measurement happens.
Multipart submission - and why I stopped using HttpClient for it
Validation checks four conditions before the submit button enables: required fields complete, signature present, at least one photo, checklist fields valid.
onSubmit(): void { this.form.value.Images = this.formImages; if (this.isFieldsValid(this.form.value) && this.form.value.signature != null && !this.isObjectEmpty(this.form.value.Images) && this.isCheckFieldsValid(this.form.value)) { for (const prop in this.form.value) { if (this.form.value[prop] === true) this.form.value[prop] = '1'; if (this.form.value[prop] === false) this.form.value[prop] = '0'; } this.submitJobCard(); } }
The boolean-to-string conversion - true to '1', false to '0' - comes from the upstream API expecting string values for everything. Angular forms use native booleans for checkboxes. The upstream system was built around paper form data entry where every value was a string. I normalise at submission time rather than fighting the upstream API.
The actual submission uses the native fetch API instead of Angular's HttpClient:
postJob(objectToSend): void { const formData = new FormData(); for (let i = 0; i < this.selectedImages.length; i++) { const img = this.selectedImages[i]; formData.append(`file_${i}`, img.blob, img.name); formData.append(`description_${i}`, img.description); formData.append(`toUrl_${i}`, JSON.stringify(img.toUrl)); } formData.append('requestBody', JSON.stringify(objectToSend)); fetch(this.apiUrl + 'technicalLogging/postJobForm', { method: 'POST', body: formData, }) .then(response => response.json()) .then(data => { this.loadingController.dismissLoader(); this.showSuccess('Request sent successfully.'); this.modalCntrl.dismiss(this.details, 'confirm'); }) .catch(error => { this.loadingController.dismissLoader(); this.showError('Failed to submit technical logging form'); }); }
I started with HttpClient. It kept breaking the multipart upload. The problem is that HttpClient tries to set a Content-Type header on the request - but FormData requires the browser to set Content-Type automatically to include the multipart boundary value. HttpClient setting the header manually strips the boundary, and the server receives a malformed request it can't parse. Every time I set Content-Type: multipart/form-data explicitly, the boundary disappeared. Every time I left it blank, HttpClient set it anyway.
Native fetch doesn't touch the header when you pass a FormData body. The browser sets Content-Type with the correct boundary automatically. Switching from HttpClient to fetch fixed the upload immediately.
The BFF side - and an honest admission about a fragile piece of code
On the Node.js BFF, Multer handles the multipart parsing:
router.post('/postJobForm', upload.any(), postJobForm); async function postJobForm(body, files) { let x = JSON.parse(body); let y = [files, body]; y = JSON.stringify(y); const response = await axios.post( helpers.returnUrl() + '/api/post-jobcard?clientId=' + x[4]['section_items'][0].account_id + '&uid=' + x[5].uid, y, { headers: { "Authorization": helpers.returnAuth() } } ); let result = response.data; if (result.includes('[{"success":"true"]')) { return "success"; } }
x[4]['section_items'][0].account_id - I know. That's fragile. It relies on the mobile app sending form sections in a specific order so the BFF can find the account ID by index. If the order of sections changes on the mobile side, this breaks silently. It exists because the upstream API expects a very specific payload shape that I can't change, and extracting the account ID by field name from the payload structure is messier than it sounds given how the form schema is structured.
This is the kind of code that exists in real production systems when you're working with an upstream API you don't control. I've documented it and I know where to look when it breaks. I'm not proud of it but I understand why it's there.
The result - days to seconds, and an audit trail that didn't exist before
The technicians who used to fill out three copies of the same paper form now submit once from their phone. The data is in the system before they've driven back to the depot. The photos are attached to the correct job automatically. The signature is captured and timestamped.
The 2-5 day turnaround dropped to zero. But the more valuable thing wasn't the speed - it was the audit trail. Every job now has a complete record: the fault, the work performed, timestamped photos of the work, the technician's signature, and the GPS location of the submission. That record didn't exist before. When a repair is disputed, or a fault recurs on the same vehicle, there's something to look at. The paper forms didn't give you that even when they weren't lost.
This is the final article in the series on building the Telematic AI fleet telematics platform.