/api4/view-v1 works fine on my side.
"footers": [],
"custom_fields": [
{
"key": "d2e9265a-460e-4a06-83f9-29a523a4d516",
"label": "QR Code",
"text": null,
"image": {
"url": "/image?ogYKMFphdGNhVGVzdA&key=019e8360-908a-7311-af3a-eb0a1b00e3c3",
"width": 160,
"height": 160
},
"displayAtTheTop": false,
"emphasis": false,
"link": null,
"fields": null
}
],
"emphasis": {
"text": "Overdue",
"positive": false,
"negative": true
}
try these custom themes
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice Document</title>
<style>
/* Print & reset styles */
@page {
size: A4;
margin: 0mm;
}
:root {
--bg: #f5f5f5;
--paper: #ffffff;
--border: #e5e5e5;
--black: #000000;
}
body {
background-color: var(--bg);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
font-size: 0.875rem;
line-height: 1.4rem;
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
main {
background-color: var(--paper);
margin: 6mm;
border: 1px solid var(--border);
border-radius: 5px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
padding: 2rem;
max-width: 210mm;
width: 100%;
box-sizing: border-box;
}
main > *:first-child {
margin-top: 0;
padding-top: 0;
}
@media print {
body {
background: none;
margin: 0;
padding: 0;
display: block;
}
main {
margin: 0;
padding: 2rem;
border: none;
box-shadow: none;
border-radius: 0;
max-width: none;
width: auto;
}
}
address {
font-style: normal;
line-height: 1.4em;
}
/* Table styles */
table {
font-size: 14px;
width: 100%;
border-collapse: collapse;
}
#table-headers th {
font-weight: bold;
padding: 5px 10px;
border: 1px solid var(--black);
text-align: start;
}
#table-rows td {
padding: 5px 5px;
text-align: start;
vertical-align: top;
}
#table-rows tr.row td {
border-inline-start: 1px solid var(--black);
border-inline-end: 1px solid var(--black);
}
#table-rows tr.last-row td {
padding-bottom: 30px;
border-bottom: 1px solid var(--black);
}
#table-rows tr.column-total td {
font-weight: bold;
border: 1px solid var(--black);
white-space: nowrap;
text-align: right;
}
#table-rows tr.total td {
white-space: nowrap;
}
#table-rows tr.total td:first-child {
text-align: end;
}
#table-rows tr.total td:last-child {
border: 1px solid var(--black);
text-align: right;
}
.business-logo-img {
max-height: 75px;
max-width: 150px;
display: inline;
}
.separator-line {
width: 1px;
border-inline-start: 1px solid #000;
align-self: stretch;
}
.section-flex {
display: flex;
margin-bottom: 20px;
width: 100%;
align-items: flex-start;
gap: 20px;
}
.recipient-address {
flex: 1;
}
/* Perbaikan: fields-right agar sejajar */
.fields-right {
flex: 1;
text-align: end;
margin: 0;
padding: 0;
}
.fields-right dl {
margin: 0;
line-height: 1.4em; /* sama dengan address */
}
.fields-right dt {
margin: 0;
padding: 0;
font-weight: bold;
line-height: inherit;
}
.fields-right dd {
margin: 0 0 8px 0;
padding: 0;
line-height: inherit;
}
.fields-right dd:last-child {
margin-bottom: 0;
}
.business-address {
white-space: nowrap;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.footer-text {
margin-top: 20px;
}
.status-message {
margin-top: 40px;
text-align: center;
}
.status-span {
border-width: 5px;
border-style: solid;
padding: 10px;
font-size: 20px;
text-transform: uppercase;
}
.status-positive {
color: green;
border-color: green;
}
.status-negative {
color: red;
border-color: red;
}
.amount-words {
margin: 20px 0;
}
.amount-words div:first-child {
font-weight: bold;
}
</style>
</head>
<body>
<main>
<table style="width: 100%;">
<thead>
<tr>
<td>
<div class="flex-between">
<h1 id="title"></h1>
<div id="business-logo" style="text-align: end;"></div>
</div>
<div class="section-flex">
<address id="recipient-info" class="recipient-address"></address>
<div id="fields" class="fields-right"></div>
<div aria-hidden="true" class="separator-line"></div>
<address id="business-info" class="business-address"></address>
</div>
<p style="font-weight: bold; font-size: 14px; margin-bottom: 20px" id="description"></p>
</td>
</tr>
</thead>
<tbody>
<tr>
<td>
<table style="border-collapse: collapse; width: 100%;">
<thead id="table-headers"></thead>
<tbody id="table-rows"></tbody>
</table>
<div id="amount-in-words" class="amount-words"></div>
<div id="qrcode" style="margin-bottom: 20px;"></div>
<div id="custom-fields"></div>
<div id="footers"></div>
<div id="status" class="status-message"></div>
</td>
</tr>
</tbody>
</table>
</main>
<script>
// --------------------------------------------------------------
// Helper: Convert number to words (English)
// --------------------------------------------------------------
function numberToWordsEn(num) {
if (typeof num !== 'number' || !isFinite(num)) return '';
function spellOutInteger(n) {
if (n === 0) return 'zero';
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
'seventeen', 'eighteen', 'nineteen'];
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
const scales = ['', 'thousand', 'million', 'billion', 'trillion', 'quadrillion'];
const under100 = (x) => x < 20 ? ones[x] : tens[Math.floor(x / 10)] + (x % 10 ? '-' + ones[x % 10] : '');
const under1000 = (x) => x < 100 ? under100(x) : ones[Math.floor(x / 100)] + ' hundred' + (x % 100 ? ' ' + under100(x % 100) : '');
const parts = [];
let scale = 0;
while (n > 0 && scale < scales.length) {
const chunk = n % 1000;
if (chunk > 0) parts.unshift(under1000(chunk) + (scales[scale] ? ' ' + scales[scale] : ''));
n = Math.floor(n / 1000);
scale++;
}
return parts.join(' ');
}
const negative = num < 0;
const abs = Math.abs(num);
const intPart = Math.floor(abs);
const fracPart = Math.round((abs - intPart) * 100);
let words = spellOutInteger(intPart);
if (fracPart > 0) words += ' and ' + (fracPart < 10 ? '0' : '') + fracPart + '/100';
if (negative) words = 'minus ' + words;
return words.charAt(0).toUpperCase() + words.slice(1);
}
// --------------------------------------------------------------
// Helper: Convert number to words (Arabic)
// --------------------------------------------------------------
function numberToWordsAr(num) {
if (typeof num !== 'number' || !isFinite(num)) return '';
function spellOutInteger(n) {
if (n === 0) return 'صفر';
const ones = ['', 'واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة'];
const teens = ['عشرة', 'أحد عشر', 'إثنا عشر', 'ثلاثة عشر', 'أربعة عشر', 'خمسة عشر',
'ستة عشر', 'سبعة عشر', 'ثمانية عشر', 'تسعة عشر'];
const tens = { 2: 'عشرون', 3: 'ثلاثون', 4: 'أربعون', 5: 'خمسون',
6: 'ستون', 7: 'سبعون', 8: 'ثمانون', 9: 'تسعون' };
const hundreds = { 1: 'مائة', 2: 'مائتان', 3: 'ثلاثمائة', 4: 'أربعمائة',
5: 'خمسمائة', 6: 'ستمائة', 7: 'سبعمائة', 8: 'ثمانمائة', 9: 'تسعمائة' };
function under100(x) {
if (x === 0) return '';
if (x < 10) return ones[x];
if (x < 20) return teens[x - 10];
const t = Math.floor(x / 10), o = x % 10;
return o === 0 ? tens[t] : ones[o] + ' و' + tens[t];
}
function under1000(x) {
if (x < 100) return under100(x);
const h = Math.floor(x / 100), rest = x % 100;
return rest === 0 ? hundreds[h] : hundreds[h] + ' و' + under100(rest);
}
function unitForm(count, singular, dual, plural) {
if (count === 1) return singular;
if (count === 2) return dual;
if (count >= 3 && count <= 10) return under100(count) + ' ' + plural;
return under1000(count) + ' ' + singular;
}
const parts = [];
const tr = Math.floor(n / 1000000000000); n = n % 1000000000000;
if (tr > 0) parts.push(unitForm(tr, 'تريليون', 'تريليونان', 'تريليونات'));
const bn = Math.floor(n / 1000000000); n = n % 1000000000;
if (bn > 0) parts.push(unitForm(bn, 'مليار', 'ملياران', 'ملايير'));
const mn = Math.floor(n / 1000000); n = n % 1000000;
if (mn > 0) parts.push(unitForm(mn, 'مليون', 'مليونان', 'ملايين'));
const th = Math.floor(n / 1000); n = n % 1000;
if (th > 0) parts.push(unitForm(th, 'ألف', 'ألفان', 'آلاف'));
if (n > 0) parts.push(under1000(n));
return parts.map((p, i) => i === 0 ? p : 'و' + p).join(' ');
}
const negative = num < 0;
const abs = Math.abs(num);
const intPart = Math.floor(abs);
const fracPart = Math.round((abs - intPart) * 100);
let words = spellOutInteger(intPart);
if (fracPart > 0) words += ' و' + (fracPart < 10 ? '0' : '') + fracPart + '/100';
if (negative) words = 'ناقص ' + words;
return words;
}
// --------------------------------------------------------------
// Utility: Transform API data
// --------------------------------------------------------------
function transformApiData(originalData) {
const data = JSON.parse(JSON.stringify(originalData));
const standardKeys = ['InvoiceDate', 'DueDate', 'InvoiceNumber'];
const standardFields = [];
const customFields = [];
(data.fields || []).forEach(field => {
if (standardKeys.includes(field.key)) standardFields.push(field);
else customFields.push(field);
});
data.fields = standardFields;
data.custom_fields = customFields;
if (data.totals && !data.table.totals) data.table.totals = data.totals;
if (data.table.rows) data.table.rows = data.table.rows.filter(row => !row.isTotalRow);
if (!data.description && data.table.description) data.description = data.table.description;
if (data.status && !data.emphasis) {
data.emphasis = {
text: data.status.text,
positive: data.status.tone === 'positive',
negative: data.status.tone === 'negative'
};
}
if (!data.table.columns.some(col => col.sumText)) {
data.table.columns.forEach(col => { col.sumText = ''; });
}
return data;
}
// --------------------------------------------------------------
// Apply column style
// --------------------------------------------------------------
function applyColumnStyle(element, column, isLastColumn) {
element.style.textAlign = column.align || 'start';
if (column.shrinkToFit) {
element.style.whiteSpace = 'nowrap';
element.style.width = '1px';
element.style.minWidth = '';
return;
}
if (column.nowrap) {
element.style.whiteSpace = 'normal';
if (isLastColumn) {
element.style.minWidth = '60px';
element.style.width = 'auto';
} else {
element.style.width = 'auto';
element.style.minWidth = '';
}
} else {
element.style.whiteSpace = 'normal';
element.style.width = 'auto';
element.style.minWidth = '';
}
}
// --------------------------------------------------------------
// Main render function
// --------------------------------------------------------------
function renderDocument(data) {
console.log(JSON.stringify(data,null,2));
document.documentElement.dir = data.direction || 'ltr';
const titleParts = [data?.business?.name, data?.title, data?.reference].filter(Boolean);
document.title = titleParts.join(' - ');
document.getElementById('title').innerHTML = data.title || 'No title';
document.getElementById('description').innerHTML = data.description || '';
// Business logo
const businessLogoDiv = document.getElementById('business-logo');
businessLogoDiv.innerHTML = '';
if (data.business.logo) {
const logoImg = document.createElement('img');
logoImg.src = data.business.logo;
logoImg.classList.add('business-logo-img');
businessLogoDiv.appendChild(logoImg);
}
// Business info
const business = data.business || {};
const businessInfoElem = document.getElementById('business-info');
businessInfoElem.innerHTML = `<strong>${business.name || ''}</strong><br>${business.address ? business.address.replace(/\n/g, '<br>') : ''}`;
// Recipient info
const recipient = data.recipient || {};
let recipientAddress = recipient.address || '';
recipientAddress = recipientAddress.replace(/\n/g, '<br>');
const recipientElem = document.getElementById('recipient-info');
recipientElem.innerHTML = `<strong>${recipient.name || ''}</strong><br>${recipientAddress}`;
// ----------------------------------------------------------
// Standard fields (gunakan dt/dd, tampilkan 2 baris per field)
// ----------------------------------------------------------
const fieldsContainer = document.getElementById('fields');
fieldsContainer.innerHTML = '';
const dl = document.createElement('dl');
(data.fields || []).forEach(field => {
const dt = document.createElement('dt');
dt.innerHTML = field.label;
const dd = document.createElement('dd');
dd.innerHTML = field.text;
dl.appendChild(dt);
dl.appendChild(dd);
});
fieldsContainer.appendChild(dl);
const columns = data.table.columns || [];
const lastColumnIndex = columns.length - 1;
// Table headers
const headersRow = document.getElementById('table-headers');
headersRow.innerHTML = '';
columns.forEach((column, idx) => {
const th = document.createElement('th');
th.innerHTML = column.label;
applyColumnStyle(th, column, idx === lastColumnIndex);
headersRow.appendChild(th);
});
// Table rows
const tableBody = document.getElementById('table-rows');
tableBody.innerHTML = '';
(data.table.rows || []).forEach(row => {
const tr = document.createElement('tr');
tr.className = 'row';
row.cells.forEach((cell, idx) => {
const column = columns[idx];
const td = document.createElement('td');
td.innerHTML = (cell.text || '').split('\n').join('<br/>');
applyColumnStyle(td, column, idx === lastColumnIndex);
tr.appendChild(td);
});
tableBody.appendChild(tr);
});
const allRows = tableBody.querySelectorAll('tr.row');
if (allRows.length > 0) allRows[allRows.length - 1].classList.add('last-row');
// Column totals row
const totalRow = document.createElement('tr');
totalRow.classList.add('column-total');
columns.forEach(column => {
const td = document.createElement('td');
td.style.textAlign = column.align;
if (column.label !== 'No.') td.innerHTML = column.sumText;
totalRow.appendChild(td);
});
if (totalRow.innerText.trim() !== '') tableBody.appendChild(totalRow);
// Additional totals
(data.table.totals || []).forEach(total => {
const tr = document.createElement('tr');
tr.className = 'total';
const labelCell = document.createElement('td');
labelCell.innerHTML = total.label;
labelCell.colSpan = columns.length - 1;
const valueCell = document.createElement('td');
valueCell.innerHTML = total.text;
valueCell.id = total.key;
if (total.class) valueCell.classList.add(total.class);
valueCell.dataset.value = total.number;
if (total.emphasis) {
labelCell.style.fontWeight = 'bold';
valueCell.style.fontWeight = 'bold';
}
tr.appendChild(labelCell);
tr.appendChild(valueCell);
tableBody.appendChild(tr);
});
// Amount in Words
const amountWordsDiv = document.getElementById('amount-in-words');
amountWordsDiv.innerHTML = '';
let totalAmount = null;
if (data.table.totals) {
const totalEntry = data.table.totals.find(t => t.key === 'Total' || t.label === 'الإجمالي' || t.label === 'Total');
if (totalEntry && typeof totalEntry.number === 'number') totalAmount = totalEntry.number;
}
if (totalAmount === null && data.table.totals) {
const lastEmphasized = data.table.totals.filter(t => t.emphasis).pop();
if (lastEmphasized && typeof lastEmphasized.number === 'number') totalAmount = lastEmphasized.number;
}
if (totalAmount !== null && !isNaN(totalAmount)) {
const isArabic = data.language === 'ar' || data.direction === 'rtl';
if (isArabic) {
amountWordsDiv.innerHTML = `<div><strong>المبلغ بالكلمات</strong></div><div>${numberToWordsAr(totalAmount)}</div>`;
} else {
amountWordsDiv.innerHTML = `<div><strong>Amount in Words</strong></div><div>${numberToWordsEn(totalAmount)}</div>`;
}
}
// Custom fields & QR code
const customFieldsContainer = document.getElementById('custom-fields');
const qrContainer = document.getElementById('qrcode');
customFieldsContainer.innerHTML = '';
qrContainer.innerHTML = '';
const qrKey = 'd2e9265a-460e-4a06-83f9-29a523a4d516';
(data.custom_fields || []).forEach(field => {
if (field.key === qrKey && field.image?.url) {
const div = document.createElement('div');
const label = field.label || 'QR Code';
div.innerHTML = `<strong>${label}</strong><br><img src="${field.image.url}" alt="QR Code" width="160" height="160">`;
customFieldsContainer.appendChild(div);
}
else if (field.displayAtTheTop && field.key !== qrKey) {
// Ini kemungkinan tidak terpakai karena sudah di standard fields
// Tapi kita tetap proses
const dt = document.createElement('dt');
dt.innerHTML = field.label;
const dd = document.createElement('dd');
dd.innerHTML = field.text;
fieldsContainer.appendChild(dt);
fieldsContainer.appendChild(dd);
}
else if (field.key !== qrKey) {
const div = document.createElement('div');
div.innerHTML = `<strong>${field.label || ''}</strong><br>${(field.text || '').split('\n').join('<br>')}<br><br>`;
customFieldsContainer.appendChild(div);
}
});
// Footers
const footersContainer = document.getElementById('footers');
footersContainer.innerHTML = '';
(data.footers || []).forEach(footerText => {
const div = document.createElement('div');
div.className = 'footer-text';
div.innerHTML = footerText;
footersContainer.appendChild(div);
div.querySelectorAll('script').forEach(oldScript => {
const newScript = document.createElement('script');
for (const attr of oldScript.attributes) newScript.setAttribute(attr.name, attr.value);
if (oldScript.textContent) newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
});
});
// Status
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = '';
if (data.emphasis?.text) {
const statusSpan = document.createElement('span');
statusSpan.classList.add('status-span');
if (data.emphasis.positive) statusSpan.classList.add('status-positive');
if (data.emphasis.negative) statusSpan.classList.add('status-negative');
statusSpan.innerHTML = data.emphasis.text;
statusDiv.appendChild(statusSpan);
}
}
// --------------------------------------------------------------
// Fetch data and render
// --------------------------------------------------------------
window.addEventListener('load', () => {
const apiUrl = '/api4/view-v1' + window.location.search;
fetch(apiUrl, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(response => response.json())
.then(data => renderDocument(transformApiData(data)))
.catch(error => {
console.error('Failed to load invoice data:', error);
document.body.innerHTML += '<div style="color:red; padding:20px;">Failed to load invoice. Please try again.</div>';
});
});
</script>
</body>
</html>