lariv-rs 0.1.0

Compile-time plugin web application framework built on Axum, SeaORM, Maud, and HTMX
Documentation
//! Batch payment allocation editor (Alpine + hidden JSON).

use maud::{Markup, PreEscaped, html};

use crate::components::{
    attrs::escape_attr,
    htmx::{HTMX_SWAP_BODY_MODAL, HTMX_TARGET_BODY_MODAL},
    text::icon,
};

const PAYMENT_BATCH_ALLOCATIONS_ALPINE: &str = r#"taxPickHref(rowIdx) {
	const b = this.tax_pick_base || '';
	if (!b) return '#';
	const sep = b.indexOf('?') >= 0 ? '&' : '?';
	const slot = 'BatchAllocTaxes_' + String(rowIdx);
	return b + sep + 'target_input=' + encodeURIComponent(slot);
},
formatDec(n) {
	if (typeof n !== 'number' || !isFinite(n)) return '—';
	let s = n.toFixed(6);
	s = s.replace(/0+$/, '');
	s = s.replace(/\.$/, '');
	return s || '0';
},
parseAmount(s) {
	return parseFloat(String(s || '').trim().replace(/,/g, '.')) || 0;
},
rowWithholdingNumber(row) {
	const base = this.parseAmount(row.amount);
	if (!Array.isArray(row.tax_items) || row.tax_items.length === 0) return 0;
	let sum = 0;
	for (const item of row.tax_items) {
		const id = String(item.Key);
		const pctStr = this.tax_pct_by_id[id];
		const pct = pctStr != null && pctStr !== '' ? parseFloat(String(pctStr)) : NaN;
		if (!isNaN(pct)) sum += base * (pct / 100);
	}
	return sum;
},
rowBankNumber(row) {
	return this.parseAmount(row.amount) - this.rowWithholdingNumber(row);
},
totalSettlementNumber() {
	if (!Array.isArray(this.allocations)) return 0;
	return this.allocations.reduce((s, row) => s + this.parseAmount(row.amount), 0);
},
totalBankNumber() {
	if (!Array.isArray(this.allocations)) return 0;
	return this.allocations.reduce((s, row) => s + this.rowBankNumber(row), 0);
},
syncHidden() {
	const payload = (this.allocations || []).map(row => ({
		posted_invoice_id: row.posted_invoice_id,
		amount: String(row.amount || ''),
		tax_ids: (row.tax_items || []).map(t => parseInt(String(t.Key), 10)).filter(id => !isNaN(id) && id > 0),
	}));
	this.hidden_json = JSON.stringify(payload);
},
taxLabel(item) {
	if (item.Value != null && String(item.Value).trim() !== '') return String(item.Value);
	return 'Tax #' + String(item.Key);
}"#;

pub struct InputPaymentBatchAllocations<'a> {
    pub name: &'a str,
    pub defaults: &'a str,
    pub tax_pick_url: &'a str,
    pub tax_pct_json: &'a str,
    pub all_taxes_json: &'a str,
    pub classes: &'a str,
}

impl Default for InputPaymentBatchAllocations<'_> {
    fn default() -> Self {
        Self {
            name: "allocations_json",
            defaults: "[]",
            tax_pick_url: "/finance-taxes/multi-select",
            tax_pct_json: "{}",
            all_taxes_json: "[]",
            classes: "w-full",
        }
    }
}

pub fn input_payment_batch_allocations(opts: InputPaymentBatchAllocations<'_>) -> Markup {
    let name_escaped = escape_attr(opts.name);
    let cls = escape_attr(opts.classes);
    let tax_pick_base = serde_json::to_string(opts.tax_pick_url).unwrap_or_else(|_| "\"\"".into());
    let alpine_data = format!(
        "{{ allocations: {}, hidden_json: {}, tax_pick_base: {tax_pick_base}, tax_pct_by_id: {}, all_taxes: {}, {methods} }}",
        opts.defaults,
        serde_json::to_string(opts.defaults).unwrap_or_else(|_| "[]".into()),
        opts.tax_pct_json,
        opts.all_taxes_json,
        methods = PAYMENT_BATCH_ALLOCATIONS_ALPINE.trim_end_matches(','),
    );

    let init_js = format!(
        r#"
if (typeof Alpine !== 'undefined' && Alpine.store && !Alpine.store('m2mSelections')) {{
	Alpine.store('m2mSelections', {{}});
}}
(function () {{
	const d = Alpine.$data($el);
	if (!d || !Array.isArray(d.allocations)) return;
	for (const row of d.allocations) {{
		if (!Array.isArray(row.tax_items)) row.tax_items = [];
		const ids = row.tax_ids;
		if (Array.isArray(ids) && ids.length > 0 && row.tax_items.length === 0 && Array.isArray(d.all_taxes)) {{
			for (const tid of ids) {{
				const t = d.all_taxes.find(x => x.id === tid);
				if (t) row.tax_items.push({{ Key: String(t.id), Value: t.name }});
			}}
		}}
		delete row.tax_ids;
	}}
	d.syncHidden();
}})();
$nextTick(() => {{ if (window.htmx) window.htmx.process($el); }});
$el.closest('form').addEventListener('submit', () => {{
	const d = Alpine.$data($el);
	if (d && typeof d.syncHidden === 'function') d.syncHidden();
}}, true);"#,
    );

    let fk_multi_handler = r#"if (!$event.detail) return;
	const n = String($event.detail.name || '');
	const v = $event.detail.value;
	const disp = $event.detail.display || '';
	const m = n.match(/^BatchAllocTaxes_(\d+)$/);
	if (!m) return;
	const idx = parseInt(m[1], 10);
	const row = allocations[idx];
	if (!row) return;
	const value = String(v);
	const items = row.tax_items || (row.tax_items = []);
	const i = items.findIndex(x => x.Key === value);
	if (i >= 0) items.splice(i, 1);
	else items.push({ Key: value, Value: String(disp || value) });
	syncHidden();"#;

    let x_effect =
        "allocations.length; $nextTick(() => { if (window.htmx) window.htmx.process($el); })";

    html! {
        div class=(format!("my-1 {}", cls)) {
            (PreEscaped(format!(
                r#"<div data-batch-alloc-root="" x-data="{alpine}" x-init="{init}" x-effect="{effect}" @fk-multi-select.window="{fk_m2m}" @lariv-fk-created.window="{fk_created}">"#,
                alpine = escape_attr(&alpine_data),
                init = escape_attr(&init_js),
                effect = escape_attr(x_effect),
                fk_m2m = escape_attr(fk_multi_handler),
                fk_created = escape_attr(&format!(
                    "{fk_multi_handler}; document.querySelectorAll('dialog.fk-modal-container').forEach((d) => {{ if (d.querySelector('.fk-picker-results')) return; if (d.querySelector('.data-table-container')) d.remove() }})"
                )),
            )))
                input type="hidden" name=(name_escaped) x-model="hidden_json";
                div class="overflow-x-auto min-w-0 rounded-box border border-base-300 bg-base-100" {
                    table class="table table-sm min-w-max w-full" {
                        thead {
                            tr {
                                th { "Invoice" }
                                th { "Customer" }
                                th class="text-end" { "Open balance" }
                                th class="text-end" { "Amount" }
                                th { "Withholding taxes" }
                                th class="text-end" { "Bank net" }
                            }
                        }
                        tbody {
                            template x-for="(row, idx) in allocations" x-bind:key="row.posted_invoice_id" {
                                tr {
                                    td { span x-text="row.invoice_number || ('#' + row.posted_invoice_id)" {} }
                                    td { span x-text="row.customer_name || '—'" {} }
                                    td class="text-end tabular-nums" { span x-text="row.open_balance || '—'" {} }
                                    td class="text-end" {
                                        (PreEscaped(r#"<input type="text" class="input input-bordered input-sm w-28 text-right" x-model="row.amount" @input="syncHidden()" />"#))
                                    }
                                    td {
                                        div class="flex flex-wrap items-center gap-1 min-w-[10rem]" {
                                            template x-for="tItem in (row.tax_items || [])" x-bind:key="tItem.Key" {
                                                (PreEscaped(r#"<div class="flex items-center gap-1 rounded-lg bg-base-200 pl-2 pr-1 py-0.5 max-w-full" @click="$event.stopPropagation()">"#))
                                                span class="text-xs truncate max-w-[8rem]" x-text="taxLabel(tItem)" {}
                                                (PreEscaped(r#"<button type="button" class="btn btn-ghost btn-square btn-xs shrink-0" @click.stop="row.tax_items = (row.tax_items || []).filter(it => it.Key !== tItem.Key); syncHidden()" aria-label="Remove tax">"#))
                                                (icon("x-mark", ""))
                                                (PreEscaped("</button></div>"))
                                            }
                                            (PreEscaped(format!(
                                                r#"<div class="input input-bordered min-h-8 flex items-center cursor-pointer px-2 py-0.5 opacity-70" x-bind:hx-get="taxPickHref(idx)" hx-target="{}" hx-swap="{}" hx-push-url="false"><span class="text-xs">Add tax…</span></div>"#,
                                                HTMX_TARGET_BODY_MODAL,
                                                HTMX_SWAP_BODY_MODAL,
                                            )))
                                        }
                                    }
                                    td class="text-end tabular-nums" {
                                        span x-text="formatDec(rowBankNumber(row))" {}
                                    }
                                }
                            }
                        }
                        tfoot {
                            tr class="font-semibold" {
                                td colspan="3" { "Totals" }
                                td class="text-end tabular-nums" { span x-text="formatDec(totalSettlementNumber())" {} }
                                td {}
                                td class="text-end tabular-nums" { span x-text="formatDec(totalBankNumber())" {} }
                            }
                        }
                    }
                }
            (PreEscaped("</div>"))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn payment_batch_keeps_alpine_in_attributes() {
        let html =
            input_payment_batch_allocations(InputPaymentBatchAllocations::default()).into_string();
        assert!(html.contains("x-init="));
        assert!(
            html.contains("hidden_json: &quot;[]&quot;"),
            "x-data JSON quotes must be escaped: {html}"
        );
        assert!(
            !crate::components::attrs::alpine_js_leaked_as_text(&html),
            "Alpine JS rendered as text: {html}"
        );
    }
}