2549e9de0c
Big round covering two different asks: Khairat/emergency infrastructure (tightly coupled to what's already built) and a Muslim-lifestyle companion surface (a genuinely different product, added at the user's explicit request after being offered a smaller scope). Khairat & emergency contacts: - nf_khairat_memberships (per-member scheme membership) + nf_emergency_fund (per-family shared reserve tracker) — records intent/contacts only, never holds or moves real money. - nf_trusted_contacts gains category (mosque/police/ambulance/hospital/ khairat/family/other) and email columns. - New notify-emergency-contacts Edge Function, auto-invoked the moment a mutawalli fires a member's death trigger (the one place in this app where auto-send-on-trigger is actually correct), alongside — not instead of — the existing heir notification. Verified with a live fire end-to-end (e2e-emergency-notify.cjs, 5/5). Lifestyle-companion tabs — all computed/searched live, nothing fabricated: - Qibla: pure great-circle bearing math to the Kaaba from geolocation, no API/key. Verified against Kuala Lumpur (293°, matches expected ~292°). - Prayer Times: client-side astronomical calculation (single-pass solar position, MWL angles), sanity-checked against known KL/London times before shipping. - Locate: mosque/halal/cemetery search via the free, keyless OpenStreetMap Overpass API — real community-sourced results only, honest empty state when nothing's mapped nearby. - Quran: Surah list + Arabic/translation via the free alquran.cloud API, fetched fresh each time, nothing stored in this app's own database. - Neighbourhood: a join-by-code community announcement board — a genuinely separate multi-tenant concept from the estate-planning family structure, scoped to the user account. Found and fixed a real UX gap during testing: the create/join form was only reachable with zero existing neighbourhoods, with no way to join a second one. Also found and fixed a real bug: the heir-notify and emergency-notify status messages shared the same CSS class, breaking any script (including the pre-existing e2e-per-member.cjs) that targeted '.notify-status' without further filtering — gave each its own distinguishing class. Covered by e2e-khairat-lifestyle.cjs (13/13) and e2e-emergency-notify.cjs (5/5). Full regression: 316/316 across all suites (several transient flakes under heavy mail-relay load during the sweep, all confirmed clean on rerun — one led to the real class-collision fix above).
359 lines
18 KiB
Svelte
359 lines
18 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { activeFamilyId } from './family.js';
|
|
import { currentUser } from './auth.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: '', category: 'family', email: '' });
|
|
const CONTACT_CATEGORIES = ['family', 'mosque', 'khairat', 'police', 'ambulance', 'hospital', 'other'];
|
|
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 };
|
|
}
|
|
|
|
async function refresh() {
|
|
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);
|
|
$effect(() => { familyId; refresh(); });
|
|
|
|
async function addOrUpdate() {
|
|
if (!form.description || !form.value) return;
|
|
if (editingId) {
|
|
await updateAsset(editingId, form);
|
|
editingId = null;
|
|
} else {
|
|
await addAsset(familyId, currentUser()?.id, form);
|
|
}
|
|
form = emptyForm();
|
|
await refresh();
|
|
}
|
|
|
|
function edit(a) {
|
|
editingId = a.id;
|
|
form = { ...a, value: String(a.value) };
|
|
}
|
|
|
|
async function remove(id) {
|
|
await removeAsset(id);
|
|
if (editingId === id) { editingId = null; form = emptyForm(); }
|
|
await refresh();
|
|
}
|
|
|
|
async function addContact() {
|
|
if (!contactForm.name) return;
|
|
await addTrustedContact(familyId, contactForm.name, contactForm.method, contactForm.category, contactForm.email);
|
|
contactForm = { name: '', method: '', category: 'family', email: '' };
|
|
await refresh();
|
|
}
|
|
|
|
async function removeContact(id) {
|
|
await removeTrustedContact(id);
|
|
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 = [
|
|
'NUR FALAH — ASSET REGISTRY SUMMARY',
|
|
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
|
`Total estate value: ${total.toLocaleString()}`,
|
|
'',
|
|
...assets.map(a => `${a.type} — ${a.description} — value ${Number(a.value).toLocaleString()} — ${a.ownershipShare}% owned — ${a.location || 'no location noted'}`),
|
|
'',
|
|
'This is a portable "what I own" summary. Not a fatwa, not legal advice.'
|
|
];
|
|
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = 'asset-registry-summary.txt'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const total = $derived(estateTotal(assets));
|
|
</script>
|
|
|
|
<div class="module">
|
|
<div class="module-header">
|
|
<h2>Asset Registry</h2>
|
|
<InfoPanel
|
|
title="Asset Registry"
|
|
what="A simple list of everything you own — your house, bank accounts, car, business, crypto, jewelry, anything of value. This is the foundation everything else in the app builds on: your Faraid shares, wassiyah limit, and coverage percentage are all calculated from what you log here."
|
|
how="Add one asset at a time. Start with the big things — property, savings, your car — you can always add smaller items later. Nothing here is submitted anywhere; it just stays on your device."
|
|
fields={[
|
|
{ label: 'Type', hint: 'What kind of asset it is — property, cash, vehicle, business, digital, jewelry, or other.' },
|
|
{ label: 'Description', hint: 'A short name so you recognize it later, e.g. \'Terrace house, Shah Alam\'.' },
|
|
{ label: 'Estimated value', hint: 'A rough number is fine — you can update it anytime.' },
|
|
{ label: 'Ownership share', hint: 'If you only own part of it (e.g. jointly with a sibling), enter your percentage — otherwise leave at 100%.' }
|
|
]}
|
|
/>
|
|
</div>
|
|
<p class="sub">Log what you own — feeds the Faraid Calculator, Wassiyah one-third meter, and Waqf corpus selector.</p>
|
|
|
|
<div class="total-card">
|
|
<span>Estate total</span>
|
|
<strong>{total.toLocaleString()}</strong>
|
|
</div>
|
|
|
|
<div class="form-card">
|
|
<label class="field"><span>Type</span>
|
|
<select bind:value={form.type}>
|
|
{#each TYPES as t}<option value={t}>{t}</option>{/each}
|
|
</select>
|
|
</label>
|
|
<label class="field"><span>Description</span><input type="text" bind:value={form.description} placeholder="e.g. Terrace house, Shah Alam" /></label>
|
|
<label class="field"><span>Estimated value</span><input type="number" min="0" bind:value={form.value} /></label>
|
|
<label class="field"><span>Location / jurisdiction</span><input type="text" bind:value={form.location} placeholder="e.g. Selangor, UK" /></label>
|
|
<label class="field"><span>Ownership share (%)</span><input type="number" min="0" max="100" bind:value={form.ownershipShare} /></label>
|
|
<button class="btn-primary" onclick={addOrUpdate}>{editingId ? 'Update asset' : 'Add asset'}</button>
|
|
</div>
|
|
|
|
<div class="list">
|
|
{#each assets as a (a.id)}
|
|
<div class="asset-row">
|
|
<div class="asset-info">
|
|
<span class="asset-type">{a.type}</span>
|
|
<span class="asset-desc">{a.description}</span>
|
|
<span class="asset-meta">{a.ownershipShare}% owned · {a.location || '—'}</span>
|
|
</div>
|
|
<div class="asset-value">{Number(a.value).toLocaleString()}</div>
|
|
<div class="asset-actions">
|
|
<button onclick={() => edit(a)} aria-label="Edit">✎</button>
|
|
<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}
|
|
</div>
|
|
|
|
<button class="btn-secondary" onclick={exportSummary}>Export "what I own" summary</button>
|
|
|
|
<div class="contacts-section">
|
|
<h3>Trusted contacts</h3>
|
|
<p class="note">Notified automatically by email when your mutawalli fires your death trigger — mosque, khairat officer, police, ambulance/hospital, or family. Not an automated death-detection mechanism.</p>
|
|
<div class="form-card">
|
|
<label class="field"><span>Name</span><input type="text" bind:value={contactForm.name} /></label>
|
|
<label class="field"><span>Category</span>
|
|
<select bind:value={contactForm.category}>
|
|
{#each CONTACT_CATEGORIES as cat}<option value={cat}>{cat}</option>{/each}
|
|
</select>
|
|
</label>
|
|
<label class="field"><span>Contact method (phone, etc.)</span><input type="text" bind:value={contactForm.method} placeholder="e.g. phone number" /></label>
|
|
<label class="field"><span>Email (for automatic notification)</span><input type="email" bind:value={contactForm.email} placeholder="required to auto-notify this contact" /></label>
|
|
<button class="btn-secondary" onclick={addContact}>Add trusted contact</button>
|
|
</div>
|
|
{#each trustedContacts as c (c.id)}
|
|
<div class="contact-row"><span><span class="contact-category">{c.category}</span> {c.name} — {c.method}{c.email ? ` · ${c.email}` : ''}</span><button onclick={() => removeContact(c.id)}>✕</button></div>
|
|
{/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>
|
|
|
|
<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, .btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
|
|
.btn-primary { background: #C9A84C; color: #070A0D; }
|
|
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; margin-bottom: 10px; }
|
|
.list { margin-bottom: 12px; }
|
|
.asset-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
|
.asset-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
|
|
.asset-type { font-size: 11px; color: #8A8478; text-transform: uppercase; letter-spacing: 0.4px; }
|
|
.asset-desc { font-size: 13.5px; color: #E8E4DC; }
|
|
.asset-meta { font-size: 11px; color: #8A8478; }
|
|
.asset-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; }
|
|
.contacts-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
|
|
.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); }
|
|
.contact-category { font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; color: #C9A84C; background: rgba(201,168,76,0.1); padding: 1px 6px; border-radius: 4px; margin-right: 6px; }
|
|
.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>
|