leads: lead detail UX overhaul — notes editor, priority star, profile card cleanup
- Replace admin field editor with direct TipTap + Save Notes button for exhibitor notes; show Add Notes button when notes are empty (no dead placeholder) - Add one-click priority star toggle in header (always visible, no edit mode required) - Remove Exhibit Context card (exhibitors don't need to see their own booth name) - Move Captured By into profile card with human-readable labels (shared_passcode → "Booth (Shared)", access type codes → Staff/Admin) - Add location row (city/state + country) to profile card - Gate Remove button to edit mode only to prevent accidental taps - Fix button position stability: Edit/View always rightmost (same screen position), Remove grows in from left — prevents double-tap accidents - Add unsaved-changes guard (beforeNavigate) covering both notes and custom question form - Custom questions form: hide Save when no questions configured, show "Configure in Manage Tab" link instead Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,12 @@
|
||||
* Lead Detail View - Basic Read-Only version.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
|
||||
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
|
||||
import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte';
|
||||
import {
|
||||
@@ -29,9 +28,9 @@ import {
|
||||
ShieldCheck,
|
||||
SquarePen,
|
||||
Star,
|
||||
Store,
|
||||
Trash2,
|
||||
User
|
||||
User,
|
||||
UserCheck
|
||||
} from '@lucide/svelte';
|
||||
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
|
||||
|
||||
@@ -52,6 +51,51 @@ let lq__exhibit_obj = $derived(
|
||||
|
||||
let is_edit_mode = $state(false);
|
||||
|
||||
// Priority toggle — one-click, saves immediately without entering edit mode.
|
||||
let priority_saving = $state(false);
|
||||
async function toggle_priority() {
|
||||
if (!exhibit_tracking_id || priority_saving) return;
|
||||
priority_saving = true;
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: page.params.exhibit_id ?? '',
|
||||
exhibit_tracking_id,
|
||||
data: { priority: !$lq__lead_obj?.priority }
|
||||
});
|
||||
} finally {
|
||||
priority_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable label for external_person_id.
|
||||
// Raw values: 'shared_passcode' (booth shared login), an email (licensed user),
|
||||
// or an Aether access_type string (trusted/administrator — staff bypass).
|
||||
function format_captured_by(id: string | null | undefined): string {
|
||||
if (!id) return 'Unknown';
|
||||
if (id === 'shared_passcode') return 'Booth (Shared)';
|
||||
if (id.includes('@')) return id;
|
||||
const map: Record<string, string> = {
|
||||
trusted: 'Staff',
|
||||
administrator: 'Admin',
|
||||
edit_mode: 'Staff'
|
||||
};
|
||||
return map[id] ?? id;
|
||||
}
|
||||
|
||||
// Unsaved-changes guard — warn before navigating away if notes or responses are dirty.
|
||||
let form_is_dirty = $state(false);
|
||||
beforeNavigate(({ cancel }) => {
|
||||
const notes_dirty =
|
||||
is_edit_mode &&
|
||||
draft_notes !== ($lq__lead_obj?.exhibitor_notes ?? '');
|
||||
if (notes_dirty || form_is_dirty) {
|
||||
if (!confirm('You have unsaved changes. Leave without saving?')) {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notes editing state
|
||||
let draft_notes = $state('');
|
||||
let notes_status = $state<'idle' | 'saving' | 'success' | 'error'>('idle');
|
||||
@@ -151,6 +195,58 @@ function format_date(date: any) {
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $lq__lead_obj}
|
||||
<!-- Remove — leftmost so it never lands on top of Edit/View when it appears.
|
||||
Only shown in edit mode; soft-delete only (enable=false), recoverable. -->
|
||||
{#if is_edit_mode && $lq__lead_obj.enable}
|
||||
{#if remove_status === 'confirm'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-error font-bold"
|
||||
onclick={remove_lead}>
|
||||
<Trash2 size="1em" />
|
||||
Confirm?
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-surface"
|
||||
onclick={() => (remove_status = 'idle')}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-error"
|
||||
disabled={remove_status === 'removing'}
|
||||
onclick={() => (remove_status = 'confirm')}>
|
||||
{#if remove_status === 'removing'}
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size="1em" />
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Remove</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Priority toggle — always visible, one-click, no edit mode needed. -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm font-bold transition-all"
|
||||
class:preset-filled-warning={$lq__lead_obj.priority}
|
||||
class:preset-outlined-surface={!$lq__lead_obj.priority}
|
||||
class:opacity-40={!$lq__lead_obj.priority}
|
||||
disabled={priority_saving}
|
||||
onclick={toggle_priority}
|
||||
title={$lq__lead_obj.priority ? 'Remove priority flag' : 'Mark as priority lead'}>
|
||||
{#if priority_saving}
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
{:else}
|
||||
<Star size="1em" class={$lq__lead_obj.priority ? 'fill-current' : ''} />
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Priority</span>
|
||||
</button>
|
||||
|
||||
<!-- Edit/View — always rightmost so its position never shifts. -->
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:preset-filled-primary={is_edit_mode}
|
||||
@@ -160,51 +256,11 @@ function format_date(date: any) {
|
||||
remove_status = 'idle';
|
||||
}}>
|
||||
{#if is_edit_mode}
|
||||
<Eye size="1.2em" class="mr-1" /> View
|
||||
<Eye size="1.2em" /> <span class="hidden sm:inline ml-1">View</span>
|
||||
{:else}
|
||||
<SquarePen size="1.2em" class="mr-1" /> Edit
|
||||
<SquarePen size="1.2em" /> <span class="hidden sm:inline ml-1">Edit</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Remove Lead — two-click confirm to prevent accidental removal.
|
||||
Removing only sets enable=false (soft-delete); the record can be restored. -->
|
||||
{#if $lq__lead_obj.enable}
|
||||
{#if remove_status === 'confirm'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-error font-bold"
|
||||
onclick={remove_lead}>
|
||||
<Trash2 size="1em" />
|
||||
Confirm Remove?
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-surface opacity-60"
|
||||
onclick={() => (remove_status = 'idle')}
|
||||
>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-error opacity-70"
|
||||
disabled={remove_status === 'removing'}
|
||||
onclick={() => (remove_status = 'confirm')}>
|
||||
{#if remove_status === 'removing'}
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size="1em" />
|
||||
{/if}
|
||||
Remove
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if $lq__lead_obj?.priority}
|
||||
<span
|
||||
class="badge preset-filled-warning flex items-center gap-1 font-bold">
|
||||
<Star size="1em" class="mr-1" />
|
||||
Priority
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -258,6 +314,15 @@ function format_date(date: any) {
|
||||
$lq__lead_obj.event_badge_affiliations_override}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $lq__lead_obj.event_badge_location_override || $lq__lead_obj.event_badge_location || $lq__lead_obj.event_badge_country}
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">
|
||||
<MapPin size="1em" class="flex-none opacity-60" />
|
||||
<span>{[
|
||||
$lq__lead_obj.event_badge_location_override || $lq__lead_obj.event_badge_location,
|
||||
$lq__lead_obj.event_badge_country
|
||||
].filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm opacity-70">
|
||||
<a href={`mailto:${$lq__lead_obj.event_badge_email}`}
|
||||
@@ -282,6 +347,11 @@ function format_date(date: any) {
|
||||
)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm opacity-60">
|
||||
<UserCheck size="1em" class="flex-none" />
|
||||
<span>Captured by {format_captured_by($lq__lead_obj.external_person_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -300,10 +370,12 @@ function format_date(date: any) {
|
||||
<Comp_lead_detail_form
|
||||
exhibit_tracking_id={exhibit_tracking_id ?? ''}
|
||||
exhibit_id={page.params.exhibit_id ?? ''}
|
||||
event_id={page.params.event_id ?? ''}
|
||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ??
|
||||
'[]'}
|
||||
current_responses_json={$lq__lead_obj.responses_json ??
|
||||
'{}'} />
|
||||
'{}'}
|
||||
bind:is_dirty={form_is_dirty} />
|
||||
{:else if $lq__lead_obj.responses_json}
|
||||
{@const responses =
|
||||
typeof $lq__lead_obj.responses_json === 'string'
|
||||
@@ -412,77 +484,38 @@ function format_date(date: any) {
|
||||
|
||||
<!-- Right: Metadata & Stats -->
|
||||
<div class="space-y-6">
|
||||
<!-- exhibit association -->
|
||||
<div
|
||||
class="card bg-surface-100-900 border-surface-500/10 space-y-4 border p-5 shadow-md">
|
||||
<div class="text-primary-500 flex items-center gap-2">
|
||||
<Store size="1.2em" />
|
||||
<h3
|
||||
class="text-xs font-bold tracking-widest uppercase">
|
||||
Exhibit Context
|
||||
</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#if is_edit_mode}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm opacity-60"
|
||||
>Exhibit Name</span>
|
||||
<span class="font-bold"
|
||||
>{$lq__lead_obj.event_exhibit_name ||
|
||||
'...'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm opacity-60"
|
||||
>Captured By</span>
|
||||
<span class="font-mono text-[10px]"
|
||||
>{$lq__lead_obj.external_person_id ||
|
||||
'Unknown'}</span>
|
||||
</div>
|
||||
|
||||
{#if is_edit_mode}
|
||||
<div
|
||||
class="border-surface-500/10 flex items-center justify-between border-t pt-2">
|
||||
<span class="text-xs font-bold opacity-60"
|
||||
>Priority Lead</span>
|
||||
<Element_ae_obj_field_editor
|
||||
object_type="event_exhibit_tracking"
|
||||
object_id={exhibit_tracking_id ?? ''}
|
||||
field_name="priority"
|
||||
field_type="checkbox"
|
||||
current_value={$lq__lead_obj.priority}
|
||||
object_reload={true} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div
|
||||
class="card bg-surface-500/5 space-y-4 p-5 font-mono text-[10px] opacity-60 shadow-inner">
|
||||
<!-- System Audit — staff/admin only; end users don't need raw IDs. -->
|
||||
{#if $ae_loc.manager_access}
|
||||
<div
|
||||
class="border-surface-500/10 mb-2 border-b pb-2 font-black tracking-[0.2em] uppercase">
|
||||
System Audit
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<span class="opacity-50">LEAD ID:</span>
|
||||
{$lq__lead_obj.event_exhibit_tracking_id}
|
||||
class="card bg-surface-500/5 space-y-4 p-5 font-mono text-[10px] opacity-60 shadow-inner">
|
||||
<div
|
||||
class="border-surface-500/10 mb-2 border-b pb-2 font-black tracking-[0.2em] uppercase">
|
||||
System Audit
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-50">BADGE ID:</span>
|
||||
{$lq__lead_obj.event_badge_id}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-50">PERSON ID:</span>
|
||||
{$lq__lead_obj.event_person_id}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-50">MODIFIED:</span>
|
||||
{format_date($lq__lead_obj.updated_on)}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<span class="opacity-50">LEAD ID:</span>
|
||||
{$lq__lead_obj.event_exhibit_tracking_id}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-50">BADGE ID:</span>
|
||||
{$lq__lead_obj.event_badge_id}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-50">PERSON ID:</span>
|
||||
{$lq__lead_obj.event_person_id}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-50">EXHIBIT:</span>
|
||||
{$lq__lead_obj.event_exhibit_name || '—'}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-50">MODIFIED:</span>
|
||||
{format_date($lq__lead_obj.updated_on)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Restore Lead card — only shown when lead has been removed (enable=false/0).
|
||||
Removing sets enable=false rather than deleting so notes/responses are preserved. -->
|
||||
|
||||
@@ -19,21 +19,27 @@
|
||||
* Both are handled transparently.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { CircleCheck, LoaderCircle, Save } from '@lucide/svelte';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { ArrowRight, CircleCheck, LoaderCircle, Save, Settings } from '@lucide/svelte';
|
||||
interface Props {
|
||||
exhibit_tracking_id: string;
|
||||
exhibit_id: string;
|
||||
event_id: string; // For navigate-to-manage link
|
||||
custom_questions_json?: string; // From event_exhibit
|
||||
current_responses_json?: string; // From event_exhibit_tracking
|
||||
is_dirty?: boolean; // Bindable — parent reads this for unsaved-changes guard
|
||||
}
|
||||
|
||||
let {
|
||||
exhibit_tracking_id,
|
||||
exhibit_id,
|
||||
event_id,
|
||||
custom_questions_json = '[]',
|
||||
current_responses_json = '{}'
|
||||
current_responses_json = '{}',
|
||||
is_dirty = $bindable(false)
|
||||
}: Props = $props();
|
||||
|
||||
let question_defs: any[] = $state([]);
|
||||
@@ -42,6 +48,9 @@ let question_defs: any[] = $state([]);
|
||||
let flat_responses: Record<string, any> = $state({});
|
||||
let status = $state('idle'); // idle, saving, success
|
||||
|
||||
// Snapshot of responses as last saved (or as loaded). Used for dirty detection.
|
||||
let saved_snapshot = $state('{}');
|
||||
|
||||
$effect(() => {
|
||||
try {
|
||||
const defs =
|
||||
@@ -69,12 +78,18 @@ $effect(() => {
|
||||
}
|
||||
}
|
||||
flat_responses = flat;
|
||||
saved_snapshot = JSON.stringify(flat);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse questions/responses', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Expose dirty state to parent so the unsaved-changes guard can check it.
|
||||
$effect(() => {
|
||||
is_dirty = JSON.stringify(flat_responses) !== saved_snapshot;
|
||||
});
|
||||
|
||||
// Resolve the key for a question def (new: q.code, legacy: q.label)
|
||||
function q_key(q: any): string {
|
||||
return q.code || q.label || '';
|
||||
@@ -97,6 +112,7 @@ async function handle_save() {
|
||||
responses_json: JSON.stringify(nested)
|
||||
}
|
||||
});
|
||||
saved_snapshot = JSON.stringify(flat_responses); // mark clean
|
||||
status = 'success';
|
||||
setTimeout(() => (status = 'idle'), 2000);
|
||||
} catch (e) {
|
||||
@@ -166,21 +182,37 @@ async function handle_save() {
|
||||
</div>
|
||||
|
||||
{#if question_defs.length === 0}
|
||||
<p class="py-4 text-center italic opacity-30">
|
||||
No custom questions configured for this exhibit.
|
||||
</p>
|
||||
<!-- No questions set up yet — prompt them to configure rather than leaving a dead form. -->
|
||||
<div
|
||||
class="preset-tonal-surface border-surface-500/20 flex flex-col items-center gap-3 rounded-xl border p-6 text-center">
|
||||
<p class="text-sm font-semibold opacity-60">
|
||||
No custom questions configured for this exhibit yet.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-primary"
|
||||
onclick={() => {
|
||||
// Set the tab before navigating so the user lands directly in Manage.
|
||||
leads_loc.current.tab[exhibit_id] = 'manage';
|
||||
goto(`/events/${event_id}/leads/exhibit/${exhibit_id}`);
|
||||
}}>
|
||||
<Settings size="1.1em" class="mr-1" />
|
||||
Configure in Manage Tab
|
||||
<ArrowRight size="1.1em" class="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="btn preset-filled-primary w-full font-bold shadow-lg"
|
||||
disabled={status === 'saving'}
|
||||
onclick={handle_save}>
|
||||
{#if status === 'saving'}
|
||||
<LoaderCircle size="1.2em" class="mr-2 animate-spin" /> Saving...
|
||||
{:else if status === 'success'}
|
||||
<CircleCheck size="1.2em" class="mr-2" /> Saved!
|
||||
{:else}
|
||||
<Save size="1.2em" class="mr-2" /> Save Responses
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn preset-filled-primary w-full font-bold shadow-lg"
|
||||
disabled={status === 'saving'}
|
||||
onclick={handle_save}>
|
||||
{#if status === 'saving'}
|
||||
<LoaderCircle size="1.2em" class="mr-2 animate-spin" /> Saving...
|
||||
{:else if status === 'success'}
|
||||
<CircleCheck size="1.2em" class="mr-2" /> Saved!
|
||||
{:else}
|
||||
<Save size="1.2em" class="mr-2" /> Save Responses
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user