9eb2ce7ce3
mutawalli executes on any member's trigger, heir email notification Per explicit product direction: "all members of the family can make their own wassiyah or waqif. The mutawali or the trustee agent can access and execute those wassiyah and waqif upon any event triggers. Warith or the heir will be automatically notified via email." Schema: nf_family_members.role now includes 'member' (authors own documents, doesn't manage the family). nf_wassiyah_settings/nf_wassiyah_bequests/ nf_waqf_designations gained author_id — each is now per-author, not per-family. Added recipient_email / beneficiary_email columns for warith notification targets. New nf_member_triggers (composite PK family_id+ member_id) and nf_member_attestors: a per-member death trigger, separate from the legacy family-wide nf_death_triggers (kept for backward compatibility, still exercised by existing suites). RLS: any family member can READ any other member's Wassiyah/Waqf (the mutawalli needs full visibility to execute), but only the document's own author can WRITE to it — not even the owner. Firing a member's trigger requires the caller to have role agent/owner AND not be the member themselves (enforced in the policy's WITH CHECK, not just the UI) — matches "the mutawalli executes, never for themselves." New UI: FamilyManagement gained a role selector (member vs agent) on invites. New MutawalliDashboard.svelte — the trustee's execution surface: pick any family member, see their Wassiyah/Waqf read-only, set up attestors + death cert ref, fire their trigger (blocked for self both by disabled UI and by RLS), then trigger heir email notifications. Edge Function notify-heirs deployed (Deno, uses Resend): reads the triggered member's Wassiyah recipients and Waqf beneficiaries wherever an email was recorded, sends each a notice. Returns a clear 501 rather than failing silently until RESEND_API_KEY is set as a project secret. Three real bugs found via testing against the live backend, not visible from code review alone: - listFamilyMembers() never selected user_id — every member-scoped lookup on the new dashboard was silently keying off undefined. - nf_member_triggers keyed by member_id alone: since a person can belong to multiple families, firing a trigger in one family marked them "triggered" in every other family they belong to. Fixed to composite (family_id, member_id) key. - Classic Svelte 5 $state pitfall: (proxyObject[key] ??= []).push(item) mutates the plain array literal the ??= expression evaluates to, not the proxy-wrapped array Svelte actually tracks — so pushed items were silently invisible to the UI forever. Fixed by building on a plain object and assigning to the $state variable once. Also found and fixed the same design smell in the older WassiyahGenerator/FamilyWaqfDesignator authorId handling: it was snapshotted once via currentUser()?.id at mount instead of read live off the session store, which could silently break writes on a remount that happened before session hydration finished — now reads live and guards refresh() on it being present. CoverageDashboard and DeathTrigger updated to check ANY family member's Waqf corpus for coverage (not just one author's), since coverage is a family-wide view even though authorship is per-member now. e2e-per-member.cjs: new suite covering the full flow — owner invites a member and an agent; member authors a private Wassiyah (invisible to other members, confirming per-author isolation); mutawalli sees it on their dashboard and fires the member's trigger; member cannot fire their own; heir notification call completes with either Sent or a clear "not configured" failure, never hangs. 11/11 passing. Full regression sweep after these changes: e2e-uat 32/32 (stable across 3 consecutive runs), e2e-fastpath 16/16, e2e-trust 12/12, e2e-business 10/10, e2e-digital-vehicle 10/10, e2e-property 9/9, e2e-other 4/4, e2e-info 31/31, e2e-family-agent 12/12 (updated for the new invite-form role selector), e2e-per-member 11/11 — 178/178 total, no regressions.
144 lines
7.6 KiB
Svelte
144 lines
7.6 KiB
Svelte
<script>
|
|
// The whole point of this app, made visible as one number: what fraction of the
|
|
// estate is already arranged to bypass Faraid/probate entirely, versus what will
|
|
// still fall into the slow court queue because no fast-path instrument covers it.
|
|
import { onMount } from 'svelte';
|
|
import { computeCoverage, suggestedChannel } from './nonprobate.js';
|
|
import { activeFamilyId } from './family.js';
|
|
import { listAssets, listHibahGifts, listNominations, listAllWaqfForFamily } from './db.js';
|
|
import Disclaimer from './Disclaimer.svelte';
|
|
import InfoPanel from './InfoPanel.svelte';
|
|
|
|
let familyId = $state(null);
|
|
activeFamilyId.subscribe(v => familyId = v);
|
|
|
|
let coverage = $state({ rows: [], total: 0, fastTotal: 0, exposedTotal: 0, fastPercent: 0 });
|
|
|
|
async function refresh() {
|
|
if (!familyId) return;
|
|
const [assets, gifts, nominations, waqfDesignations] = await Promise.all([
|
|
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId)
|
|
]);
|
|
const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean);
|
|
coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds });
|
|
}
|
|
|
|
onMount(refresh);
|
|
$effect(() => { familyId; refresh(); });
|
|
|
|
const CHANNEL_LABELS = {
|
|
hibah: 'Hibah — lifetime gift, completed',
|
|
waqf: 'Waqf — dedicated corpus',
|
|
epf: 'EPF nomination',
|
|
takaful: 'Takaful / insurance nomination',
|
|
'bank-mandate': 'Bank death mandate',
|
|
trust: 'Trust / nominee holding',
|
|
'business-continuity': 'Business continuity instrument',
|
|
'digital-custody': 'Digital custody plan',
|
|
'hibah-flagged-capped': 'Hibah flagged (marad al-mawt) — capped, partially exposed',
|
|
none: 'Not covered — exposed to Faraid/probate'
|
|
};
|
|
|
|
const NEXT_STEP_TAB = {
|
|
hibah: 'the Hibah tab',
|
|
trust: 'Nomination Registry (trust setup)',
|
|
'digital-custody': 'Nomination Registry (digital custody)',
|
|
'business-continuity': 'Nomination Registry (business continuity)'
|
|
};
|
|
</script>
|
|
|
|
<div class="module">
|
|
<div class="module-header">
|
|
<h2>Coverage Dashboard</h2>
|
|
<InfoPanel
|
|
title="Coverage Dashboard"
|
|
what="The one number that tells you if this app is actually working for you: what percentage of everything you own will skip the slow Faraid/probate court process entirely. Green means covered and fast; red means it's still stuck in the old system."
|
|
how="This updates automatically as you use the other tabs — nothing to fill in here. Just check it, and for anything shown in red, tap the suggested next step to go fix it (usually Hibah, Waqf, or the Nomination Registry)."
|
|
fields={[]}
|
|
/>
|
|
</div>
|
|
<p class="sub">What fraction of your estate is already arranged to skip Faraid/probate entirely.</p>
|
|
|
|
<div class="big-number-card" class:full={coverage.fastPercent === 100} class:partial={coverage.fastPercent > 0 && coverage.fastPercent < 100} class:none={coverage.fastPercent === 0}>
|
|
<div class="big-percent">{coverage.fastPercent}%</div>
|
|
<div class="big-label">of your estate bypasses the Faraid/probate queue</div>
|
|
</div>
|
|
|
|
<div class="split-row">
|
|
<div class="split-card fast">
|
|
<span class="split-label">Fast path</span>
|
|
<strong>{coverage.fastTotal.toLocaleString()}</strong>
|
|
</div>
|
|
<div class="split-card slow">
|
|
<span class="split-label">Exposed to Faraid/probate</span>
|
|
<strong>{coverage.exposedTotal.toLocaleString()}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
{#if coverage.fastPercent < 100 && coverage.total > 0}
|
|
<div class="warning-box">
|
|
<strong>{coverage.exposedTotal.toLocaleString()} is still exposed.</strong> Anything not covered by Hibah, Waqf, or a Nomination channel goes into the conventional Faraid/probate process at death — the ~2-year route this app exists to avoid. Cover the remaining assets below.
|
|
</div>
|
|
{:else if coverage.total > 0}
|
|
<div class="success-box">
|
|
<strong>Full coverage.</strong> Every logged asset has a fast-path exit. Nothing here is queued for Faraid/probate adjudication.
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="asset-list">
|
|
{#each coverage.rows as r (r.asset.id)}
|
|
{@const suggestion = !r.fast ? suggestedChannel(r.asset.type) : null}
|
|
<div class="asset-row" class:fast={r.fast} class:slow={!r.fast}>
|
|
<div class="asset-info">
|
|
<strong>{r.asset.description}</strong>
|
|
<span class="muted">{r.ownedValue.toLocaleString()} · {CHANNEL_LABELS[r.channel]}</span>
|
|
{#if suggestion}
|
|
<span class="suggestion-inline">Next step: {suggestion.label} — go to {NEXT_STEP_TAB[suggestion.channel] || 'Nomination Registry'}.</span>
|
|
{/if}
|
|
</div>
|
|
<span class="status-dot" class:on={r.fast}></span>
|
|
</div>
|
|
{:else}
|
|
<p class="empty">No assets logged yet — start in Asset Registry.</p>
|
|
{/each}
|
|
</div>
|
|
|
|
<Disclaimer text="Coverage shown here reflects what you've logged in this app, not a legal audit. EPF/Takaful nominations, bank mandates, and trust structures only take effect once actually filed with the institution — this dashboard tracks intent, not confirmed legal status." />
|
|
</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: 18px; }
|
|
|
|
.big-number-card { text-align: center; border-radius: 16px; padding: 24px; margin-bottom: 16px; border: 1px solid rgba(255,255,255,0.08); }
|
|
.big-number-card.full { background: rgba(46,204,113,0.1); border-color: rgba(46,204,113,0.3); }
|
|
.big-number-card.partial { background: rgba(201,168,76,0.08); border-color: rgba(201,168,76,0.3); }
|
|
.big-number-card.none { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.3); }
|
|
.big-percent { font-family: 'DM Serif Display', serif; font-size: 48px; color: #E8E4DC; }
|
|
.big-number-card.full .big-percent { color: #2ECC71; }
|
|
.big-number-card.none .big-percent { color: #EF4444; }
|
|
.big-label { font-size: 12px; color: #B8B2A6; margin-top: 4px; }
|
|
|
|
.split-row { display: flex; gap: 10px; margin-bottom: 16px; }
|
|
.split-card { flex: 1; background: rgba(255,255,255,0.03); border-radius: 10px; padding: 12px; }
|
|
.split-card.fast { border-left: 3px solid #2ECC71; }
|
|
.split-card.slow { border-left: 3px solid #EF4444; }
|
|
.split-label { display: block; font-size: 11px; color: #8A8478; margin-bottom: 4px; }
|
|
.split-card strong { font-size: 15px; color: #E8E4DC; }
|
|
|
|
.warning-box { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 10px; padding: 12px; font-size: 12px; color: #E8E4DC; line-height: 1.5; margin-bottom: 16px; }
|
|
.success-box { background: rgba(46,204,113,0.08); border: 1px solid rgba(46,204,113,0.25); border-radius: 10px; padding: 12px; font-size: 12px; color: #E8E4DC; line-height: 1.5; margin-bottom: 16px; }
|
|
|
|
.asset-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px; border-radius: 10px; margin-bottom: 8px; }
|
|
.asset-row.fast { background: rgba(46,204,113,0.05); }
|
|
.asset-row.slow { background: rgba(239,68,68,0.05); }
|
|
.asset-info { display: flex; flex-direction: column; gap: 3px; font-size: 13px; color: #E8E4DC; }
|
|
.muted { color: #8A8478; font-size: 11px; }
|
|
.suggestion-inline { color: #C9A84C; font-size: 11px; }
|
|
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #EF4444; margin-top: 4px; flex-shrink: 0; }
|
|
.status-dot.on { background: #2ECC71; }
|
|
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
|
</style>
|