- leads_api_access toggle in Admin Tools (manager only) - Account Status section for end users (payment/licenses/API badges + CSV export button) - Sign-out fix: use Object.fromEntries instead of delete on PersistedState proxy - Shared passcode sign-in redirects directly to Manage tab (their role is config, not capture) - Manage tab section reorder: Account Status → Lead Retrieval Config → Booth Profile → Access & Security → App Settings - Filter dropdown: replace abstract "My Leads" with direct identity options (All / Booth (Shared) / per-licensee); auto-resolves and migrates stale 'my' values - Lead detail: replace Element_ae_obj_field_editor notes with direct TipTap editor + Save Notes button; Add Notes button on empty state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
349 lines
12 KiB
Svelte
349 lines
12 KiB
Svelte
<script lang="ts">
|
|
// import { browser } from '$app/environment';
|
|
import { untrack } from 'svelte';
|
|
import type { Snippet } from 'svelte';
|
|
import {
|
|
Check,
|
|
CircleAlert,
|
|
LoaderCircle,
|
|
Save,
|
|
SquarePen,
|
|
Trash2,
|
|
X
|
|
} from '@lucide/svelte';
|
|
import type { key_val } from '$lib/stores/ae_stores';
|
|
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
|
import { api } from '$lib/api/api';
|
|
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
|
|
|
|
interface Props {
|
|
// Core Identifiers
|
|
id?: string;
|
|
object_type: string;
|
|
object_id: string;
|
|
field_name: string;
|
|
|
|
// Value Handling
|
|
current_value: any;
|
|
field_type?:
|
|
| 'text'
|
|
| 'textarea'
|
|
| 'select'
|
|
| 'tiptap'
|
|
| 'checkbox'
|
|
| 'date'
|
|
| 'datetime'
|
|
| 'number';
|
|
allow_null?: boolean;
|
|
|
|
// Select Options
|
|
select_options?: key_val; // { value: label }
|
|
|
|
// UI Configuration
|
|
edit_label?: string;
|
|
display_block?: boolean;
|
|
display_absolute_edit?: boolean;
|
|
placeholder?: string;
|
|
class_li?: string;
|
|
textarea_rows?: number;
|
|
|
|
// Behavior
|
|
object_reload?: boolean; // SWR pattern
|
|
log_lvl?: number;
|
|
|
|
// Callbacks
|
|
on_success?: (data: any) => void;
|
|
on_error?: (error: any) => void;
|
|
|
|
// Snippets
|
|
children?: Snippet;
|
|
}
|
|
|
|
let {
|
|
id,
|
|
object_type,
|
|
object_id,
|
|
field_name,
|
|
current_value = $bindable(),
|
|
field_type = 'text',
|
|
allow_null = false,
|
|
select_options = {},
|
|
edit_label = 'Edit Field',
|
|
display_block = false,
|
|
display_absolute_edit = false,
|
|
placeholder = 'Enter value...',
|
|
class_li = '',
|
|
textarea_rows = 4,
|
|
object_reload = true,
|
|
log_lvl = 0,
|
|
on_success,
|
|
on_error,
|
|
children
|
|
}: Props = $props();
|
|
|
|
// Internal State
|
|
let is_editing = $state(false);
|
|
let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
|
let error_message = $state('');
|
|
let draft_value = $state(current_value);
|
|
|
|
// WHY: Optimistic display. After a successful PATCH, liveQuery may not fire
|
|
// immediately (or at all if on_success doesn't trigger a Dexie refresh). We show
|
|
// draft_value as the display until current_value catches up from liveQuery.
|
|
let has_optimistic = $state(false);
|
|
let display_value = $derived(has_optimistic ? draft_value : current_value);
|
|
|
|
// Sync draft with display_value when not editing.
|
|
// Suppress reset if optimistic is active — we already have the right value.
|
|
$effect(() => {
|
|
if (!is_editing) {
|
|
untrack(() => {
|
|
if (!has_optimistic) {
|
|
draft_value = current_value;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Clear optimistic once liveQuery catches up (current_value matches what we saved)
|
|
$effect(() => {
|
|
if (has_optimistic && current_value === draft_value) {
|
|
has_optimistic = false;
|
|
}
|
|
});
|
|
|
|
async function handle_patch() {
|
|
if (log_lvl)
|
|
console.log(
|
|
`AE Field Editor V3: Patching ${object_type}.${field_name}...`
|
|
);
|
|
|
|
patch_status = 'processing';
|
|
error_message = '';
|
|
|
|
try {
|
|
const result = await api.update_ae_obj({
|
|
api_cfg: $ae_api,
|
|
obj_type: object_type,
|
|
obj_id: object_id,
|
|
fields: {
|
|
[field_name]: draft_value
|
|
},
|
|
log_lvl
|
|
});
|
|
|
|
if (result) {
|
|
patch_status = 'success';
|
|
has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
|
|
if (on_success) on_success(result);
|
|
|
|
// Close edit mode after a brief success indicator
|
|
setTimeout(() => {
|
|
if (patch_status === 'success') {
|
|
patch_status = 'idle';
|
|
is_editing = false;
|
|
}
|
|
}, 1000);
|
|
} else {
|
|
throw new Error('No data returned from update.');
|
|
}
|
|
} catch (error: any) {
|
|
console.error('AE Field Editor V3: Patch failed.', error);
|
|
patch_status = 'error';
|
|
error_message = error?.message || 'Update failed.';
|
|
if (on_error) on_error(error);
|
|
}
|
|
}
|
|
|
|
function cancel_edit() {
|
|
has_optimistic = false;
|
|
draft_value = current_value;
|
|
is_editing = false;
|
|
patch_status = 'idle';
|
|
error_message = '';
|
|
}
|
|
|
|
function toggle_edit() {
|
|
if (is_editing) cancel_edit();
|
|
else {
|
|
has_optimistic = false; // clear optimistic so draft syncs from current prop
|
|
draft_value = current_value;
|
|
is_editing = true;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="ae_field_editor group relative {class_li}"
|
|
class:block={display_block}
|
|
class:inline-block={!display_block}>
|
|
<!-- VIEW MODE -->
|
|
<div class="view_wrapper flex items-center gap-2" class:hidden={is_editing}>
|
|
<div class="content_render grow">
|
|
{#if children}
|
|
{@render children()}
|
|
{:else if field_type === 'checkbox'}
|
|
<span
|
|
class="badge {display_value
|
|
? 'variant-filled-success'
|
|
: 'variant-soft-surface'}">
|
|
{display_value ? 'True' : 'False'}
|
|
</span>
|
|
{:else if field_type === 'tiptap'}
|
|
<div class="prose dark:prose-invert max-w-none">
|
|
{@html display_value ||
|
|
'<span class="opacity-50 italic">Empty</span>'}
|
|
</div>
|
|
{:else}
|
|
<span class:opacity-50={!display_value}>
|
|
{display_value || 'Not set'}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Edit Trigger (Visible on Hover or Edit Mode) -->
|
|
<!-- WHY: Always render to avoid layout shift when edit_mode toggles.
|
|
Use invisible (visibility:hidden, preserves space) when edit_mode is off. -->
|
|
<button
|
|
type="button"
|
|
class="btn-icon btn-icon-sm variant-soft-warning opacity-20 transition-opacity hover:opacity-100"
|
|
class:invisible={!$ae_loc.edit_mode}
|
|
class:pointer-events-none={!$ae_loc.edit_mode}
|
|
onclick={toggle_edit}
|
|
title="Edit {field_name}">
|
|
<SquarePen size="14" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- EDIT MODE -->
|
|
{#if is_editing}
|
|
<div
|
|
class="edit_wrapper border-warning-500/50 bg-surface-50-950 z-50 rounded-lg border-2 border-dashed p-3 shadow-xl"
|
|
class:absolute={display_absolute_edit}
|
|
class:top-0={display_absolute_edit}
|
|
class:left-0={display_absolute_edit}
|
|
class:w-full={display_absolute_edit}>
|
|
<header class="mb-2 flex items-center justify-between">
|
|
<span
|
|
class="text-xs font-bold tracking-wider uppercase opacity-60"
|
|
>{edit_label || field_name}</span>
|
|
<div class="flex gap-1">
|
|
<button
|
|
type="button"
|
|
class="btn-icon btn-icon-sm variant-soft-surface"
|
|
onclick={cancel_edit}
|
|
disabled={patch_status === 'processing'}>
|
|
<X size="14" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="input_container mb-3">
|
|
{#if field_type === 'textarea'}
|
|
<textarea
|
|
bind:value={draft_value}
|
|
rows={textarea_rows}
|
|
class="textarea"
|
|
{placeholder}></textarea>
|
|
{:else if field_type === 'select'}
|
|
<select bind:value={draft_value} class="select">
|
|
{#if allow_null}
|
|
<option value={null}>-- None --</option>
|
|
{/if}
|
|
{#each Object.entries(select_options) as [val, label] (val)}
|
|
<option value={val}>{label}</option>
|
|
{/each}
|
|
</select>
|
|
{:else if field_type === 'checkbox'}
|
|
<label class="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={draft_value}
|
|
class="checkbox" />
|
|
<span>{draft_value ? 'True' : 'False'}</span>
|
|
</label>
|
|
{:else if field_type === 'tiptap'}
|
|
<AE_Comp_Editor_TipTap
|
|
bind:content={draft_value}
|
|
{placeholder} />
|
|
{:else if field_type === 'date'}
|
|
<input type="date" bind:value={draft_value} class="input" />
|
|
{:else if field_type === 'datetime'}
|
|
<input
|
|
type="datetime-local"
|
|
bind:value={draft_value}
|
|
class="input" />
|
|
{:else if field_type === 'number'}
|
|
<input
|
|
type="number"
|
|
bind:value={draft_value}
|
|
class="input"
|
|
{placeholder}
|
|
onkeydown={(e) =>
|
|
e.key === 'Enter' && handle_patch()} />
|
|
{:else}
|
|
<input
|
|
type="text"
|
|
bind:value={draft_value}
|
|
class="input"
|
|
{placeholder}
|
|
onkeydown={(e) =>
|
|
e.key === 'Enter' && handle_patch()} />
|
|
{/if}
|
|
</div>
|
|
|
|
<footer class="flex items-center justify-between">
|
|
<div class="status_indicator text-xs">
|
|
{#if patch_status === 'processing'}
|
|
<span class="text-primary-500 flex items-center gap-1">
|
|
<LoaderCircle size="12" class="animate-spin" /> Saving...
|
|
</span>
|
|
{:else if patch_status === 'success'}
|
|
<span class="text-success-500 flex items-center gap-1">
|
|
<Check size="12" /> Saved
|
|
</span>
|
|
{:else if patch_status === 'error'}
|
|
<span
|
|
class="text-error-500 flex items-center gap-1"
|
|
title={error_message}>
|
|
<CircleAlert size="12" /> Error
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="actions flex gap-2">
|
|
{#if allow_null && draft_value !== null}
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft-error"
|
|
onclick={() => (draft_value = null)}>
|
|
Set Null
|
|
</button>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-filled-primary"
|
|
onclick={handle_patch}
|
|
disabled={patch_status === 'processing' ||
|
|
draft_value === display_value}>
|
|
{#if patch_status === 'processing'}
|
|
<LoaderCircle size="14" class="mr-1 animate-spin" />
|
|
{:else}
|
|
<Save size="14" class="mr-1" />
|
|
{/if}
|
|
Save
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
/* Add any specialized transitions if needed */
|
|
.ae_field_editor :global(.btn-icon-sm) {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
</style>
|