feat: add insurance/Takaful tracking, liabilities, and asset ownership verification
Per-member Insurance & Takaful tab (life/Takaful/medical/asset policies, same author-owns/family-reads pattern as Wassiyah/Waqf), a family-shared Liabilities section on the Asset Registry (kept distinct from assets since Faraid requires debts settled before distribution), and dual-path asset ownership verification (proof document + confirm by either the mutawalli/agent or any other family member, since not every demo family has an agent assigned). All proof documents share a new private nf-asset-documents storage bucket with the same RLS pattern as person photos. Mutawalli dashboard now surfaces each member's insurance policies. Covered by e2e-insurance-verification.cjs (12/12); full regression sweep 233/233 across all suites.
This commit is contained in:
@@ -34,3 +34,25 @@ Ran `verify-demo-families.cjs` against the live app (not just SQL success) —
|
||||
confirms sign-in, correct asset count, non-zero estate total, coverage
|
||||
percentage, correct person count, and non-empty tree render for all 5
|
||||
families. 35/35 passing.
|
||||
|
||||
## Insurance/Takaful, Liabilities & Asset Verification (added later)
|
||||
|
||||
Three additions, covered by `e2e-insurance-verification.cjs` (12/12):
|
||||
|
||||
- **Insurance & Takaful tab** — per-member (`nf_insurance_policies`), same
|
||||
author-owns-writes/family-reads pattern as Wassiyah/Waqf. Life, Takaful,
|
||||
medical, or asset-specific policies with an optional proof document.
|
||||
- **Liabilities** (bottom of the Assets tab) — family-shared
|
||||
(`nf_liabilities`), kept distinct from assets because Faraid requires debts
|
||||
settled before distribution. Optionally linked to the asset it's secured
|
||||
against, with a loan-document attachment.
|
||||
- **Asset ownership verification** — every asset in the Asset Registry can
|
||||
now take a proof document (car grant, land title, cover note) and a
|
||||
verified/unverified badge. Either the mutawalli/agent OR any other family
|
||||
member can confirm — not gated to a single approver, since several demo
|
||||
families have no agent assigned.
|
||||
- The Mutawalli dashboard's per-member document summary now also lists each
|
||||
member's insurance/Takaful policies.
|
||||
- All proof documents (policies, liabilities, assets) share one private
|
||||
Storage bucket, `nf-asset-documents`, with the same family-membership RLS
|
||||
pattern used for person photos.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Verifies the newest feature set end-to-end against the live backend:
|
||||
// per-member Insurance/Takaful policies, family-shared Liabilities, and
|
||||
// asset-ownership verification (proof document + dual-path confirm).
|
||||
const { chromium } = require('playwright');
|
||||
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
|
||||
const BASE = 'https://moslem04.falahos.my/';
|
||||
const results = [];
|
||||
const consoleErrors = [];
|
||||
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||
page.on('pageerror', e => consoleErrors.push(e.message));
|
||||
|
||||
await signInFreshFamily(page, BASE, 'e2e-insurance');
|
||||
|
||||
// ── Insurance & Takaful tab ──
|
||||
await page.locator('nav button[aria-label="Insurance"]').click();
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page.locator('.form-card .field:has-text("Type") select').selectOption('takaful');
|
||||
await page.locator('.form-card .field:has-text("Provider") input').fill('Etiqa Takaful');
|
||||
await page.locator('.form-card .field:has-text("Policy number") input').fill('TKF-00123');
|
||||
await page.locator('.form-card .field:has-text("Sum assured") input').fill('250000');
|
||||
await page.locator('.form-card .field:has-text("Beneficiary name") input').fill('Aisha binti Rahman');
|
||||
await page.locator('.form-card .field:has-text("Beneficiary relation") input').fill('spouse');
|
||||
await page.locator('.form-card button.btn-primary', { hasText: 'Add policy' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const policyRowVisible = await page.locator('.policy-row', { hasText: 'Etiqa Takaful' }).isVisible().catch(() => false);
|
||||
record('Insurance: policy added and listed', policyRowVisible);
|
||||
|
||||
const totalCardVisible = await page.locator('.total-card strong', { hasText: '250,000' }).isVisible().catch(() => false);
|
||||
record('Insurance: total sum assured reflects the added policy', totalCardVisible);
|
||||
|
||||
// Edit the policy
|
||||
await page.locator('.policy-row', { hasText: 'Etiqa Takaful' }).locator('button[aria-label="Edit"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('.form-card .field:has-text("Notes") input').fill('Renewed 2026');
|
||||
await page.locator('.form-card button.btn-primary', { hasText: 'Update policy' }).click();
|
||||
await page.waitForTimeout(800);
|
||||
record('Insurance: editing a policy succeeds without error', consoleErrors.length === 0, consoleErrors.join(' || '));
|
||||
|
||||
// ── Asset ownership verification (Assets tab) ──
|
||||
await page.locator('nav button[aria-label="Assets"]').click();
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator('.form-card .field:has-text("Description") input').fill('Family sedan');
|
||||
await page.locator('.form-card .field:has-text("Estimated value") input').fill('45000');
|
||||
await page.locator('.form-card select').first().selectOption('Vehicle');
|
||||
await page.locator('.form-card button.btn-primary', { hasText: 'Add asset' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const unverifiedBadge = await page.locator('.verify-badge', { hasText: 'Unverified' }).isVisible().catch(() => false);
|
||||
record('Asset Verification: new asset starts unverified', unverifiedBadge);
|
||||
|
||||
await page.locator('button.verify-toggle', { hasText: 'Confirm ownership' }).first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
const verifiedBadge = await page.locator('.verify-badge.verified', { hasText: 'Verified' }).isVisible().catch(() => false);
|
||||
record('Asset Verification: confirming ownership flips the badge to Verified', verifiedBadge);
|
||||
|
||||
await page.locator('button.verify-toggle', { hasText: 'Unverify' }).first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
const revertedToUnverified = await page.locator('.verify-badge', { hasText: 'Unverified' }).isVisible().catch(() => false);
|
||||
record('Asset Verification: unverify reverts the badge', revertedToUnverified);
|
||||
|
||||
// ── Liabilities (Assets tab, bottom section) ──
|
||||
await page.locator('.liabilities-section .field:has-text("Type") select').selectOption('mortgage');
|
||||
await page.locator('.liabilities-section .field:has-text("Lender") input').fill('Maybank Islamic');
|
||||
await page.locator('.liabilities-section .field:has-text("Outstanding balance") input').fill('180000');
|
||||
await page.locator('.liabilities-section .field:has-text("Linked asset") select').selectOption({ label: 'Family sedan' });
|
||||
await page.locator('.liabilities-section button.btn-primary', { hasText: 'Add liability' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const liabilityRowVisible = await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).isVisible().catch(() => false);
|
||||
record('Liabilities: liability added and listed', liabilityRowVisible);
|
||||
|
||||
const debtTotalVisible = await page.locator('.total-card.debt strong', { hasText: '180,000' }).isVisible().catch(() => false);
|
||||
record('Liabilities: total outstanding debt card shows the correct sum', debtTotalVisible);
|
||||
|
||||
await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).locator('button[aria-label="Remove"]').click();
|
||||
await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).waitFor({ state: 'detached', timeout: 10000 }).catch(() => {});
|
||||
const liabilityRemoved = await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).isVisible().catch(() => false);
|
||||
record('Liabilities: removing a liability removes it from the list', !liabilityRemoved);
|
||||
|
||||
// ── Mutawalli dashboard should surface the insurance policy for this member (owner-only family: owner sees their own row) ──
|
||||
await page.locator('nav button[aria-label="Mutawalli"]').click();
|
||||
await page.waitForTimeout(800);
|
||||
const mutawalliText = await page.locator('.module').innerText().catch(() => '');
|
||||
const mutawalliGated = mutawalliText.includes('Only the mutawalli');
|
||||
record('Mutawalli: owner-only family either sees the dashboard or the correct gate message', mutawalliGated || /INSURANCE/i.test(mutawalliText));
|
||||
|
||||
// ── Isolation: a second fresh family (same account) must not see this policy/liability/verification data ──
|
||||
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await signInFreshFamily(isolationPage, BASE, 'e2e-insurance-isolation');
|
||||
await isolationPage.locator('nav button[aria-label="Insurance"]').click();
|
||||
await isolationPage.waitForTimeout(600);
|
||||
const leakedPolicy = await isolationPage.locator('.policy-row', { hasText: 'Etiqa Takaful' }).isVisible().catch(() => false);
|
||||
record('Isolation: a different family sees none of this insurance data', !leakedPolicy);
|
||||
await isolationPage.close();
|
||||
|
||||
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
|
||||
|
||||
await browser.close();
|
||||
const passCount = results.filter(r => r.pass).length;
|
||||
const failCount = results.length - passCount;
|
||||
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
|
||||
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
|
||||
process.exit(failCount > 0 ? 1 : 0);
|
||||
}
|
||||
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
|
||||
+14
-12
@@ -18,6 +18,7 @@
|
||||
import FamilyManagement from './lib/FamilyManagement.svelte';
|
||||
import MutawalliDashboard from './lib/MutawalliDashboard.svelte';
|
||||
import FamilyTree from './lib/FamilyTree.svelte';
|
||||
import InsurancePolicies from './lib/InsurancePolicies.svelte';
|
||||
|
||||
let currentLang = $state('en');
|
||||
lang.subscribe(v => currentLang = v);
|
||||
@@ -41,8 +42,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
|
||||
const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
|
||||
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
|
||||
const icons = ['🎯', '📊', '📁', '🛡️', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
|
||||
let activeTab = $state(0);
|
||||
|
||||
function handleKeydown(e) {
|
||||
@@ -99,16 +100,17 @@
|
||||
{#if activeTab === 0}<CoverageDashboard />
|
||||
{:else if activeTab === 1}<FaraidCalculator />
|
||||
{:else if activeTab === 2}<AssetRegistry />
|
||||
{:else if activeTab === 3}<WassiyahGenerator />
|
||||
{:else if activeTab === 4}<HibahTracker />
|
||||
{:else if activeTab === 5}<FamilyWaqfDesignator />
|
||||
{:else if activeTab === 6}<NominationRegistry />
|
||||
{:else if activeTab === 7}<DeathTrigger />
|
||||
{:else if activeTab === 8}<MutawalliDashboard />
|
||||
{:else if activeTab === 9}<FamilyTree />
|
||||
{:else if activeTab === 10}<DigitalClaims />
|
||||
{:else if activeTab === 11}<FamilyManagement />
|
||||
{:else if activeTab === 12}
|
||||
{:else if activeTab === 3}<InsurancePolicies />
|
||||
{:else if activeTab === 4}<WassiyahGenerator />
|
||||
{:else if activeTab === 5}<HibahTracker />
|
||||
{:else if activeTab === 6}<FamilyWaqfDesignator />
|
||||
{:else if activeTab === 7}<NominationRegistry />
|
||||
{:else if activeTab === 8}<DeathTrigger />
|
||||
{:else if activeTab === 9}<MutawalliDashboard />
|
||||
{:else if activeTab === 10}<FamilyTree />
|
||||
{:else if activeTab === 11}<DigitalClaims />
|
||||
{:else if activeTab === 12}<FamilyManagement />
|
||||
{:else if activeTab === 13}
|
||||
<div class="module">
|
||||
<div class="module-header">
|
||||
<h2>Settings</h2>
|
||||
|
||||
@@ -2,21 +2,34 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { activeFamilyId } from './family.js';
|
||||
import { currentUser } from './auth.js';
|
||||
import { listAssets, addAsset, updateAsset, removeAsset, listTrustedContacts, addTrustedContact, removeTrustedContact, estateTotal } from './db.js';
|
||||
import {
|
||||
listAssets, addAsset, updateAsset, removeAsset, listTrustedContacts, addTrustedContact, removeTrustedContact, estateTotal,
|
||||
uploadAssetDocument, removeAssetDocument, getDocumentUrl, setAssetVerified,
|
||||
listLiabilities, addLiability, updateLiability, removeLiability, uploadLiabilityDocument, removeLiabilityDocument
|
||||
} from './db.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
import InfoPanel from './InfoPanel.svelte';
|
||||
|
||||
const TYPES = ['Property', 'Cash / Bank', 'Vehicle', 'Business interest', 'Digital assets', 'Jewelry / valuables', 'Other'];
|
||||
const LIABILITY_TYPES = ['loan', 'mortgage', 'credit', 'other'];
|
||||
|
||||
let familyId = $state(null);
|
||||
activeFamilyId.subscribe(v => familyId = v);
|
||||
|
||||
let assets = $state([]);
|
||||
let trustedContacts = $state([]);
|
||||
let liabilities = $state([]);
|
||||
let docUrls = $state({});
|
||||
|
||||
let form = $state(emptyForm());
|
||||
let editingId = $state(null);
|
||||
let contactForm = $state({ name: '', method: '' });
|
||||
let liabilityForm = $state(emptyLiabilityForm());
|
||||
let editingLiabilityId = $state(null);
|
||||
|
||||
function emptyLiabilityForm() {
|
||||
return { liabilityType: LIABILITY_TYPES[0], lender: '', outstandingBalance: '', linkedAssetId: '', notes: '' };
|
||||
}
|
||||
|
||||
function emptyForm() {
|
||||
return { type: TYPES[0], description: '', value: '', location: '', ownershipShare: 100 };
|
||||
@@ -26,6 +39,11 @@
|
||||
if (!familyId) return;
|
||||
assets = await listAssets(familyId);
|
||||
trustedContacts = await listTrustedContacts(familyId);
|
||||
liabilities = await listLiabilities(familyId);
|
||||
const urls = {};
|
||||
for (const a of assets) if (a.proofDocumentPath) urls[a.proofDocumentPath] = await getDocumentUrl(a.proofDocumentPath);
|
||||
for (const l of liabilities) if (l.documentPath) urls[l.documentPath] = await getDocumentUrl(l.documentPath);
|
||||
docUrls = urls;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
@@ -66,6 +84,56 @@
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onProofFileChange(assetId, e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadAssetDocument(familyId, assetId, file);
|
||||
e.target.value = '';
|
||||
await refresh();
|
||||
}
|
||||
async function removeProof(assetId, docPath) {
|
||||
await removeAssetDocument(assetId, docPath);
|
||||
await refresh();
|
||||
}
|
||||
async function toggleVerified(a) {
|
||||
await setAssetVerified(a.id, currentUser()?.id, !a.verified);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function addOrUpdateLiability() {
|
||||
if (!liabilityForm.lender) return;
|
||||
if (editingLiabilityId) {
|
||||
await updateLiability(editingLiabilityId, liabilityForm);
|
||||
editingLiabilityId = null;
|
||||
} else {
|
||||
await addLiability(familyId, currentUser()?.id, liabilityForm);
|
||||
}
|
||||
liabilityForm = emptyLiabilityForm();
|
||||
await refresh();
|
||||
}
|
||||
function editLiability(l) {
|
||||
editingLiabilityId = l.id;
|
||||
liabilityForm = { ...l, outstandingBalance: String(l.outstandingBalance ?? '') };
|
||||
}
|
||||
async function removeLiabilityRow(id) {
|
||||
await removeLiability(id);
|
||||
if (editingLiabilityId === id) { editingLiabilityId = null; liabilityForm = emptyLiabilityForm(); }
|
||||
await refresh();
|
||||
}
|
||||
async function onLiabilityFileChange(liabilityId, e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadLiabilityDocument(familyId, liabilityId, file);
|
||||
e.target.value = '';
|
||||
await refresh();
|
||||
}
|
||||
async function removeLiabilityProof(liabilityId, docPath) {
|
||||
await removeLiabilityDocument(liabilityId, docPath);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
const totalDebt = $derived((liabilities || []).reduce((sum, l) => sum + (Number(l.outstandingBalance) || 0), 0));
|
||||
|
||||
function exportSummary() {
|
||||
const total = estateTotal(assets);
|
||||
const lines = [
|
||||
@@ -136,6 +204,19 @@
|
||||
<button onclick={() => remove(a.id)} aria-label="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verify-row">
|
||||
<span class="verify-badge" class:verified={a.verified}>{a.verified ? '✓ Verified' : 'Unverified'}</span>
|
||||
{#if a.proofDocumentPath}
|
||||
<a class="doc-link" href={docUrls[a.proofDocumentPath]} target="_blank" rel="noopener">View proof</a>
|
||||
<button class="doc-remove" onclick={() => removeProof(a.id, a.proofDocumentPath)}>Remove proof</button>
|
||||
{:else}
|
||||
<label class="doc-upload">
|
||||
Attach proof (car grant, land title, cover note…)
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" onchange={(e) => onProofFileChange(a.id, e)} />
|
||||
</label>
|
||||
{/if}
|
||||
<button class="verify-toggle" onclick={() => toggleVerified(a)}>{a.verified ? 'Unverify' : 'Confirm ownership'}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No assets logged yet.</p>
|
||||
{/each}
|
||||
@@ -156,6 +237,70 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="liabilities-section">
|
||||
<div class="section-header">
|
||||
<h3>Liabilities</h3>
|
||||
<InfoPanel
|
||||
title="Liabilities"
|
||||
what="Debts against the estate — loans, mortgages, credit balances. Faraid requires debts to be settled before any distribution, so these are tracked separately from what you own, not attached as a note on an asset."
|
||||
how="Log the lender and outstanding balance. Optionally link it to the asset it's secured against (e.g. a mortgage linked to a property) and attach the loan agreement as proof."
|
||||
fields={[
|
||||
{ label: 'Type', hint: 'Loan, mortgage, credit balance, or other.' },
|
||||
{ label: 'Lender', hint: 'Who the debt is owed to.' },
|
||||
{ label: 'Outstanding balance', hint: 'The amount still owed, not the original loan amount.' },
|
||||
{ label: 'Linked asset', hint: 'Optional — e.g. link a mortgage to the property it secures.' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{#if totalDebt > 0}
|
||||
<div class="total-card debt"><span>Total outstanding debt</span><strong>{totalDebt.toLocaleString()}</strong></div>
|
||||
{/if}
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Type</span>
|
||||
<select bind:value={liabilityForm.liabilityType}>
|
||||
{#each LIABILITY_TYPES as t}<option value={t}>{t}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Lender</span><input type="text" bind:value={liabilityForm.lender} placeholder="e.g. Maybank" /></label>
|
||||
<label class="field"><span>Outstanding balance</span><input type="number" min="0" bind:value={liabilityForm.outstandingBalance} /></label>
|
||||
<label class="field"><span>Linked asset (optional)</span>
|
||||
<select bind:value={liabilityForm.linkedAssetId}>
|
||||
<option value="">— none —</option>
|
||||
{#each assets as a}<option value={a.id}>{a.description}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Notes</span><input type="text" bind:value={liabilityForm.notes} /></label>
|
||||
<button class="btn-primary" onclick={addOrUpdateLiability}>{editingLiabilityId ? 'Update liability' : 'Add liability'}</button>
|
||||
</div>
|
||||
{#each liabilities as l (l.id)}
|
||||
<div class="liability-row">
|
||||
<div class="asset-info">
|
||||
<span class="asset-type">{l.liabilityType}</span>
|
||||
<span class="asset-desc">{l.lender}</span>
|
||||
<span class="asset-meta">{l.notes || '—'}</span>
|
||||
</div>
|
||||
<div class="asset-value debt-value">{Number(l.outstandingBalance || 0).toLocaleString()}</div>
|
||||
<div class="asset-actions">
|
||||
<button onclick={() => editLiability(l)} aria-label="Edit">✎</button>
|
||||
<button onclick={() => removeLiabilityRow(l.id)} aria-label="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verify-row">
|
||||
{#if l.documentPath}
|
||||
<a class="doc-link" href={docUrls[l.documentPath]} target="_blank" rel="noopener">View loan document</a>
|
||||
<button class="doc-remove" onclick={() => removeLiabilityProof(l.id, l.documentPath)}>Remove</button>
|
||||
{:else}
|
||||
<label class="doc-upload">
|
||||
Attach loan document
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" onchange={(e) => onLiabilityFileChange(l.id, e)} />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No liabilities logged.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Disclaimer text="Manual entry only — bank/brokerage account linking and live balance sync are explicitly out of scope for Horizon 1." />
|
||||
</div>
|
||||
|
||||
@@ -187,5 +332,19 @@
|
||||
.contacts-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 4px; }
|
||||
.note { font-size: 11.5px; color: #8A8478; margin-bottom: 12px; }
|
||||
.contact-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.verify-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 0 12px; margin-top: -6px; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.verify-badge { font-size: 10.5px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,0.06); color: #8A8478; }
|
||||
.verify-badge.verified { background: rgba(46,204,113,0.15); color: #2ECC71; }
|
||||
.doc-link { font-size: 11.5px; color: #C9A84C; text-decoration: underline; }
|
||||
.doc-remove, .verify-toggle { background: none; border: 1px solid rgba(255,255,255,0.15); color: #B8B2A6; font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer; }
|
||||
.doc-upload { font-size: 11px; color: #8A8478; cursor: pointer; }
|
||||
.doc-upload input { display: none; }
|
||||
.liabilities-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
|
||||
.section-header { display: flex; align-items: center; margin-bottom: 4px; }
|
||||
.liabilities-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 0; font-family: 'DM Serif Display', serif; }
|
||||
.total-card.debt { background: rgba(231,76,60,0.08); border-color: rgba(231,76,60,0.25); }
|
||||
.total-card.debt strong { color: #E74C3C; }
|
||||
.liability-row { display: flex; align-items: center; gap: 10px; padding: 12px 0 6px; border-bottom: none; }
|
||||
.debt-value { color: #E74C3C; font-weight: 600; font-size: 13px; }
|
||||
.contact-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { activeFamilyId } from './family.js';
|
||||
import { session } from './auth.js';
|
||||
import {
|
||||
listInsurancePolicies, addInsurancePolicy, updateInsurancePolicy, removeInsurancePolicy,
|
||||
uploadPolicyDocument, removePolicyDocument, getDocumentUrl
|
||||
} from './db.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
import InfoPanel from './InfoPanel.svelte';
|
||||
|
||||
const POLICY_TYPES = [
|
||||
{ value: 'life', label: 'Life insurance' },
|
||||
{ value: 'takaful', label: 'Family Takaful' },
|
||||
{ value: 'medical', label: 'Medical / health' },
|
||||
{ value: 'asset', label: 'Asset (property, vehicle, fire)' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let familyId = $state(null);
|
||||
activeFamilyId.subscribe(v => familyId = v);
|
||||
// Read live off the session store, not a one-time snapshot — same fix as
|
||||
// WassiyahGenerator/FamilyWaqfDesignator: this component remounts on tab
|
||||
// switch, and a stale null captured once would silently break every write.
|
||||
let memberId = $state(null);
|
||||
session.subscribe(v => memberId = v?.user?.id ?? null);
|
||||
|
||||
let policies = $state([]);
|
||||
let docUrls = $state({});
|
||||
let form = $state(emptyForm());
|
||||
let editingId = $state(null);
|
||||
|
||||
function emptyForm() {
|
||||
return { policyType: 'life', provider: '', policyNumber: '', sumAssured: '', beneficiaryName: '', beneficiaryRelation: '', expiryDate: '', notes: '' };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!familyId || !memberId) return;
|
||||
policies = await listInsurancePolicies(familyId, memberId);
|
||||
const urls = {};
|
||||
for (const p of policies) if (p.documentPath) urls[p.documentPath] = await getDocumentUrl(p.documentPath);
|
||||
docUrls = urls;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
$effect(() => { familyId; memberId; refresh(); });
|
||||
|
||||
async function addOrUpdate() {
|
||||
if (!form.provider || !form.policyType) return;
|
||||
if (editingId) {
|
||||
await updateInsurancePolicy(editingId, form);
|
||||
editingId = null;
|
||||
} else {
|
||||
await addInsurancePolicy(familyId, memberId, memberId, form);
|
||||
}
|
||||
form = emptyForm();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function edit(p) {
|
||||
editingId = p.id;
|
||||
form = { ...p, sumAssured: String(p.sumAssured ?? ''), expiryDate: p.expiryDate || '' };
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await removeInsurancePolicy(id);
|
||||
if (editingId === id) { editingId = null; form = emptyForm(); }
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onFileChange(policyId, e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadPolicyDocument(familyId, policyId, file);
|
||||
e.target.value = '';
|
||||
await refresh();
|
||||
}
|
||||
async function removeDoc(policyId, docPath) {
|
||||
await removePolicyDocument(policyId, docPath);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function typeLabel(v) { return POLICY_TYPES.find(t => t.value === v)?.label || v; }
|
||||
const totalCover = $derived(policies.reduce((s, p) => s + (Number(p.sumAssured) || 0), 0));
|
||||
</script>
|
||||
|
||||
<div class="module">
|
||||
<div class="module-header">
|
||||
<h2>Insurance & Takaful</h2>
|
||||
<InfoPanel
|
||||
title="Insurance & Takaful"
|
||||
what="Life insurance, family Takaful, medical cover, or insurance tied to a specific asset (fire cover on a property, comprehensive on a car). Payouts from these policies usually go to a named beneficiary directly and outside the estate — separate from what Faraid distributes — so recording them here means your mutawalli/agent knows what to claim and who to notify."
|
||||
how="Add one policy per record. This is your own list — each family member logs their own cover, but your mutawalli/agent and other family members can see it so nothing gets missed when a claim needs to be made."
|
||||
fields={[
|
||||
{ label: 'Type', hint: 'Life, family Takaful, medical, or an asset-specific policy like fire or motor cover.' },
|
||||
{ label: 'Provider', hint: 'The insurer or Takaful operator, e.g. Etiqa, Prudential BSN.' },
|
||||
{ label: 'Sum assured', hint: 'The payout amount if the policy pays out — a rough figure is fine.' },
|
||||
{ label: 'Beneficiary', hint: 'Who the policy names to receive the payout directly.' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p class="sub">Log your own life, Takaful, medical, or asset insurance — visible to your mutawalli/agent for claims.</p>
|
||||
|
||||
{#if totalCover > 0}
|
||||
<div class="total-card"><span>Total sum assured</span><strong>{totalCover.toLocaleString()}</strong></div>
|
||||
{/if}
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Type</span>
|
||||
<select bind:value={form.policyType}>
|
||||
{#each POLICY_TYPES as t}<option value={t.value}>{t.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Provider</span><input type="text" bind:value={form.provider} placeholder="e.g. Etiqa, Prudential BSN" /></label>
|
||||
<label class="field"><span>Policy number</span><input type="text" bind:value={form.policyNumber} /></label>
|
||||
<label class="field"><span>Sum assured</span><input type="number" min="0" bind:value={form.sumAssured} /></label>
|
||||
<label class="field"><span>Beneficiary name</span><input type="text" bind:value={form.beneficiaryName} /></label>
|
||||
<label class="field"><span>Beneficiary relation</span><input type="text" bind:value={form.beneficiaryRelation} placeholder="e.g. spouse, child" /></label>
|
||||
<label class="field"><span>Expiry / renewal date</span><input type="date" bind:value={form.expiryDate} /></label>
|
||||
<label class="field"><span>Notes</span><input type="text" bind:value={form.notes} /></label>
|
||||
<button class="btn-primary" onclick={addOrUpdate}>{editingId ? 'Update policy' : 'Add policy'}</button>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
{#each policies as p (p.id)}
|
||||
<div class="policy-row">
|
||||
<div class="policy-info">
|
||||
<span class="policy-type">{typeLabel(p.policyType)}</span>
|
||||
<span class="policy-desc">{p.provider} {p.policyNumber ? `· ${p.policyNumber}` : ''}</span>
|
||||
<span class="policy-meta">Beneficiary: {p.beneficiaryName || '—'} {p.beneficiaryRelation ? `(${p.beneficiaryRelation})` : ''}</span>
|
||||
{#if p.expiryDate}<span class="policy-meta">Expires {p.expiryDate}</span>{/if}
|
||||
</div>
|
||||
{#if p.sumAssured}<div class="policy-value">{Number(p.sumAssured).toLocaleString()}</div>{/if}
|
||||
<div class="asset-actions">
|
||||
<button onclick={() => edit(p)} aria-label="Edit">✎</button>
|
||||
<button onclick={() => remove(p.id)} aria-label="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-row">
|
||||
{#if p.documentPath}
|
||||
<a class="doc-link" href={docUrls[p.documentPath]} target="_blank" rel="noopener">View policy document</a>
|
||||
<button class="doc-remove" onclick={() => removeDoc(p.id, p.documentPath)}>Remove</button>
|
||||
{:else}
|
||||
<label class="doc-upload">
|
||||
Attach policy document
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" onchange={(e) => onFileChange(p.id, e)} />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No insurance or Takaful policies logged yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Disclaimer text="Manual entry only — this does not verify or file a claim with any insurer. Policy payouts are typically outside the estate and go directly to the named beneficiary." />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module { padding: 4px 0 40px; }
|
||||
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
|
||||
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 0; }
|
||||
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
||||
.total-card { display: flex; justify-content: space-between; align-items: baseline; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 12px; padding: 16px; margin-bottom: 18px; }
|
||||
.total-card span { font-size: 12.5px; color: #B8B2A6; }
|
||||
.total-card strong { font-size: 22px; color: #C9A84C; }
|
||||
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.field span { font-size: 12px; color: #B8B2A6; }
|
||||
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
|
||||
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
|
||||
.list { margin-bottom: 12px; }
|
||||
.policy-row { display: flex; align-items: center; gap: 10px; padding: 12px 0 4px; border-bottom: none; }
|
||||
.policy-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
|
||||
.policy-type { font-size: 11px; color: #8A8478; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.policy-desc { font-size: 13.5px; color: #E8E4DC; }
|
||||
.policy-meta { font-size: 11px; color: #8A8478; }
|
||||
.policy-value { color: #2ECC71; font-weight: 600; font-size: 13px; }
|
||||
.asset-actions button { background: none; border: none; color: #8A8478; cursor: pointer; padding: 4px 6px; }
|
||||
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
||||
.doc-row { display: flex; align-items: center; gap: 8px; padding: 0 0 12px; border-bottom: 1px solid rgba(255,255,255,0.06); margin-bottom: 4px; }
|
||||
.doc-link { font-size: 11.5px; color: #C9A84C; text-decoration: underline; }
|
||||
.doc-remove { background: none; border: 1px solid rgba(255,255,255,0.15); color: #B8B2A6; font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer; }
|
||||
.doc-upload { font-size: 11px; color: #8A8478; cursor: pointer; }
|
||||
.doc-upload input { display: none; }
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
import { activeFamilyId, listFamilyMembers, listMyFamilies } from './family.js';
|
||||
import { currentUser } from './auth.js';
|
||||
import {
|
||||
listAllWassiyahForFamily, listAllWaqfForFamily,
|
||||
listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily,
|
||||
getMemberTrigger, upsertMemberTriggerSetup, fireMemberTrigger,
|
||||
listMemberAttestors, addMemberAttestor, updateMemberAttestorName, setMemberAttestorConfirmed,
|
||||
notifyHeirs
|
||||
@@ -23,6 +23,7 @@
|
||||
let selectedMemberId = $state(null);
|
||||
let wassiyahByAuthor = $state({});
|
||||
let waqfByAuthor = $state({});
|
||||
let insuranceByMember = $state({});
|
||||
|
||||
let trigger = $state(null);
|
||||
let attestors = $state([]);
|
||||
@@ -34,8 +35,8 @@
|
||||
|
||||
async function refresh() {
|
||||
if (!familyId) return;
|
||||
const [allMembers, myFamilies, wassiyah, waqf] = await Promise.all([
|
||||
listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId)
|
||||
const [allMembers, myFamilies, wassiyah, waqf, insurance] = await Promise.all([
|
||||
listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId), listAllInsuranceForFamily(familyId)
|
||||
]);
|
||||
myRole = myFamilies.find(f => f.id === familyId)?.role;
|
||||
members = allMembers.filter(m => m.status === 'active');
|
||||
@@ -50,6 +51,9 @@
|
||||
const waqfGrouped = {};
|
||||
for (const w of waqf) (waqfGrouped[w.author_id] ??= []).push(w);
|
||||
waqfByAuthor = waqfGrouped;
|
||||
const insuranceGrouped = {};
|
||||
for (const p of insurance) (insuranceGrouped[p.member_id] ??= []).push(p);
|
||||
insuranceByMember = insuranceGrouped;
|
||||
|
||||
if (!selectedMemberId && members.length) selectedMemberId = members[0].user_id;
|
||||
if (selectedMemberId) await loadMemberTrigger(selectedMemberId);
|
||||
@@ -156,6 +160,12 @@
|
||||
<div class="doc-row">Mutawalli: {w.mutawalli || '—'} · {(w.nf_waqf_beneficiaries || []).length} beneficiaries</div>
|
||||
{:else}<div class="doc-empty">None recorded</div>{/each}
|
||||
</div>
|
||||
<div class="doc-block">
|
||||
<span class="doc-label">Insurance & Takaful</span>
|
||||
{#each insuranceByMember[selectedMemberId] || [] as p}
|
||||
<div class="doc-row">{p.policy_type} — {p.provider || '—'} {p.sum_assured ? `· ${Number(p.sum_assured).toLocaleString()}` : ''} · beneficiary {p.beneficiary_name || '—'}</div>
|
||||
{:else}<div class="doc-empty">None recorded</div>{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trigger-card" class:triggered={trigger?.triggered}>
|
||||
|
||||
+136
-1
@@ -10,7 +10,10 @@ import { supabase } from './supabaseClient.js';
|
||||
export async function listAssets(familyId) {
|
||||
const { data, error } = await supabase.from('nf_assets').select('*').eq('family_id', familyId).order('created_at');
|
||||
if (error) throw error;
|
||||
return (data || []).map(a => ({ id: a.id, type: a.type, description: a.description, value: a.value, location: a.location, ownershipShare: a.ownership_share }));
|
||||
return (data || []).map(a => ({
|
||||
id: a.id, type: a.type, description: a.description, value: a.value, location: a.location, ownershipShare: a.ownership_share,
|
||||
verified: a.verified, verifiedBy: a.verified_by, verifiedAt: a.verified_at, proofDocumentPath: a.proof_document_path
|
||||
}));
|
||||
}
|
||||
export async function addAsset(familyId, userId, a) {
|
||||
const { data, error } = await supabase.from('nf_assets').insert({
|
||||
@@ -355,3 +358,135 @@ export async function removeRelationship(id) {
|
||||
const { error } = await supabase.from('nf_relationships').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ── Insurance / Takaful — per-member: each family member logs their own
|
||||
// policies, but any family member (and the mutawalli dashboard) can read them. ──
|
||||
export async function listInsurancePolicies(familyId, memberId) {
|
||||
const { data, error } = await supabase.from('nf_insurance_policies').select('*').eq('family_id', familyId).eq('member_id', memberId).order('created_at');
|
||||
if (error) throw error;
|
||||
return (data || []).map(p => ({
|
||||
id: p.id, policyType: p.policy_type, provider: p.provider, policyNumber: p.policy_number,
|
||||
sumAssured: p.sum_assured, beneficiaryName: p.beneficiary_name, beneficiaryRelation: p.beneficiary_relation,
|
||||
expiryDate: p.expiry_date, notes: p.notes, documentPath: p.document_path
|
||||
}));
|
||||
}
|
||||
export async function addInsurancePolicy(familyId, memberId, createdBy, p) {
|
||||
const { data, error } = await supabase.from('nf_insurance_policies').insert({
|
||||
family_id: familyId, member_id: memberId, created_by: createdBy, policy_type: p.policyType,
|
||||
provider: p.provider, policy_number: p.policyNumber, sum_assured: p.sumAssured ? Number(p.sumAssured) : null,
|
||||
beneficiary_name: p.beneficiaryName, beneficiary_relation: p.beneficiaryRelation, expiry_date: p.expiryDate || null, notes: p.notes
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
export async function updateInsurancePolicy(id, p) {
|
||||
const { error } = await supabase.from('nf_insurance_policies').update({
|
||||
policy_type: p.policyType, provider: p.provider, policy_number: p.policyNumber,
|
||||
sum_assured: p.sumAssured ? Number(p.sumAssured) : null, beneficiary_name: p.beneficiaryName,
|
||||
beneficiary_relation: p.beneficiaryRelation, expiry_date: p.expiryDate || null, notes: p.notes,
|
||||
updated_at: new Date().toISOString()
|
||||
}).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
export async function removeInsurancePolicy(id) {
|
||||
const { error } = await supabase.from('nf_insurance_policies').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
/** For the mutawalli dashboard: every family member's insurance/Takaful policies. */
|
||||
export async function listAllInsuranceForFamily(familyId) {
|
||||
const { data, error } = await supabase.from('nf_insurance_policies').select('*').eq('family_id', familyId).order('created_at');
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
const POLICY_DOC_BUCKET = 'nf-asset-documents';
|
||||
export async function uploadPolicyDocument(familyId, policyId, file) {
|
||||
const ext = (file.name.split('.').pop() || 'pdf').toLowerCase();
|
||||
const path = `${familyId}/policy-${policyId}/${Date.now()}.${ext}`;
|
||||
const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
|
||||
if (upErr) throw upErr;
|
||||
const { error: dbErr } = await supabase.from('nf_insurance_policies').update({ document_path: path, updated_at: new Date().toISOString() }).eq('id', policyId);
|
||||
if (dbErr) throw dbErr;
|
||||
return path;
|
||||
}
|
||||
export async function removePolicyDocument(policyId, docPath) {
|
||||
if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]);
|
||||
const { error } = await supabase.from('nf_insurance_policies').update({ document_path: null, updated_at: new Date().toISOString() }).eq('id', policyId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ── Liabilities — family-shared, same ownership model as Assets. Kept
|
||||
// distinct because Faraid requires debts settled before distribution. ──
|
||||
export async function listLiabilities(familyId) {
|
||||
const { data, error } = await supabase.from('nf_liabilities').select('*').eq('family_id', familyId).order('created_at');
|
||||
if (error) throw error;
|
||||
return (data || []).map(l => ({
|
||||
id: l.id, liabilityType: l.liability_type, lender: l.lender, outstandingBalance: l.outstanding_balance,
|
||||
linkedAssetId: l.linked_asset_id, notes: l.notes, documentPath: l.document_path
|
||||
}));
|
||||
}
|
||||
export async function addLiability(familyId, createdBy, l) {
|
||||
const { data, error } = await supabase.from('nf_liabilities').insert({
|
||||
family_id: familyId, created_by: createdBy, liability_type: l.liabilityType, lender: l.lender,
|
||||
outstanding_balance: l.outstandingBalance ? Number(l.outstandingBalance) : null,
|
||||
linked_asset_id: l.linkedAssetId || null, notes: l.notes
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
export async function updateLiability(id, l) {
|
||||
const { error } = await supabase.from('nf_liabilities').update({
|
||||
liability_type: l.liabilityType, lender: l.lender, outstanding_balance: l.outstandingBalance ? Number(l.outstandingBalance) : null,
|
||||
linked_asset_id: l.linkedAssetId || null, notes: l.notes, updated_at: new Date().toISOString()
|
||||
}).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
export async function removeLiability(id) {
|
||||
const { error } = await supabase.from('nf_liabilities').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
export async function uploadLiabilityDocument(familyId, liabilityId, file) {
|
||||
const ext = (file.name.split('.').pop() || 'pdf').toLowerCase();
|
||||
const path = `${familyId}/liability-${liabilityId}/${Date.now()}.${ext}`;
|
||||
const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
|
||||
if (upErr) throw upErr;
|
||||
const { error: dbErr } = await supabase.from('nf_liabilities').update({ document_path: path, updated_at: new Date().toISOString() }).eq('id', liabilityId);
|
||||
if (dbErr) throw dbErr;
|
||||
return path;
|
||||
}
|
||||
export async function removeLiabilityDocument(liabilityId, docPath) {
|
||||
if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]);
|
||||
const { error } = await supabase.from('nf_liabilities').update({ document_path: null, updated_at: new Date().toISOString() }).eq('id', liabilityId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ── Asset ownership verification — a proof document (car grant, land title,
|
||||
// cover note, loan agreement) plus a dual-path confirm: either the
|
||||
// mutawalli/agent OR any other family member can mark an asset verified. ──
|
||||
export async function uploadAssetDocument(familyId, assetId, file) {
|
||||
const ext = (file.name.split('.').pop() || 'pdf').toLowerCase();
|
||||
const path = `${familyId}/asset-${assetId}/${Date.now()}.${ext}`;
|
||||
const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
|
||||
if (upErr) throw upErr;
|
||||
const { error: dbErr } = await supabase.from('nf_assets').update({ proof_document_path: path }).eq('id', assetId);
|
||||
if (dbErr) throw dbErr;
|
||||
return path;
|
||||
}
|
||||
export async function removeAssetDocument(assetId, docPath) {
|
||||
if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]);
|
||||
const { error } = await supabase.from('nf_assets').update({ proof_document_path: null }).eq('id', assetId);
|
||||
if (error) throw error;
|
||||
}
|
||||
export async function getDocumentUrl(path) {
|
||||
if (!path) return null;
|
||||
const { data, error } = await supabase.storage.from(POLICY_DOC_BUCKET).createSignedUrl(path, 3600);
|
||||
if (error) return null;
|
||||
return data.signedUrl;
|
||||
}
|
||||
/** Verifying isn't restricted to the mutawalli — any other family member who has seen the proof can confirm. */
|
||||
export async function setAssetVerified(assetId, verifiedBy, verified) {
|
||||
const { error } = await supabase.from('nf_assets').update({
|
||||
verified, verified_by: verified ? verifiedBy : null, verified_at: verified ? new Date().toISOString() : null
|
||||
}).eq('id', assetId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user