Standardize Core UI forms and unify schemas for V3 API compatibility

- Implement new Svelte 5 Person, Address, and Contact form components with surgical payload logic.
- Refactor core routes (People, Addresses, Contacts) to support unified Create/Edit workflows.
- Update ae_types.ts, db_core.ts, and db_journals.ts to align with V3 backend object models.
- Fix type safety issues in Journal history views and refine metadata display.
- Migrate person core functions to the newer ae_core__person module.
This commit is contained in:
Scott Idem
2026-01-09 15:14:36 -05:00
parent 8e205b2db9
commit 5056d5d8f0
18 changed files with 1276 additions and 944 deletions

View File

@@ -2,11 +2,13 @@
import { onMount } from 'svelte';
import { ae_loc, ae_api, slct } from '$lib/stores/ae_stores';
import { goto } from '$app/navigation';
import { MapPin, Plus, Search, ExternalLink } from 'lucide-svelte';
import { MapPin, Plus, Search, ExternalLink, X } from 'lucide-svelte';
import { load_ae_obj_li__address, create_ae_obj__address } from '$lib/ae_core/ae_core__address';
import Address_form from './ae_comp__address_form.svelte';
let address_li: any[] = $state([]);
let loading = $state(true);
let show_add_form = $state(false);
async function load_addresses() {
if (!$ae_loc.account_id) return;
@@ -20,27 +22,6 @@
loading = false;
}
async function handle_add() {
const city = prompt('Enter city:');
if (!city) return;
const state_province = prompt('Enter state/province:');
const country = prompt('Enter country:', 'USA');
const result = await create_ae_obj__address({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: { city, state_province, country, enable: true },
log_lvl: 1
});
if (result) {
load_addresses();
if (result.address_id_random) {
goto(`/core/addresses/${result.address_id_random}`);
}
}
}
onMount(() => {
if (!$ae_loc.manager_access) {
goto('/core');
@@ -56,11 +37,30 @@
<MapPin size={24} />
<h1 class="h2">Address Management</h1>
</div>
<button class="btn variant-filled-primary" onclick={handle_add}>
<Plus size={16} class="mr-2" /> Add Address
<button class="btn variant-filled-primary" onclick={() => show_add_form = !show_add_form}>
{#if show_add_form}
<X size={16} class="mr-2" /> Cancel
{:else}
<Plus size={16} class="mr-2" /> Add Address
{/if}
</button>
</header>
{#if show_add_form}
<div class="mb-8">
<Address_form
onSave={(new_addr) => {
show_add_form = false;
load_addresses();
if (new_addr.address_id_random) {
goto(`/core/addresses/${new_addr.address_id_random}`);
}
}}
onCancel={() => show_add_form = false}
/>
</div>
{/if}
{#if loading}
<div class="placeholder animate-pulse h-64 w-full"></div>
{:else if address_li.length === 0}

View File

@@ -9,12 +9,13 @@
import { editable_fields__address } from '$lib/ae_core/ae_core__address.editable_fields';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { goto } from '$app/navigation';
import { Save, Trash2, ArrowLeft, MapPin } from 'lucide-svelte';
import { Save, Trash2, ArrowLeft, MapPin, Edit, Eye } from 'lucide-svelte';
import Address_form from '../ae_comp__address_form.svelte';
let address_id = $page.params.address_id;
let address: any = $state(null);
let loading = $state(true);
let saving = $state(false);
let is_editing = $state(false);
async function load_data() {
loading = true;
@@ -34,24 +35,6 @@
load_data();
});
async function handle_save() {
saving = true;
const data_kv: any = {};
editable_fields__address.forEach(field => {
if (address[field] !== undefined) {
data_kv[field] = address[field];
}
});
await update_ae_obj__address({
api_cfg: $ae_api,
address_id,
data_kv,
log_lvl: 1
});
saving = false;
}
async function handle_delete() {
if (!confirm('Permanently delete this address?')) return;
await delete_ae_obj_id__address({
@@ -76,11 +59,15 @@
</div>
</div>
<div class="flex gap-2">
<button class="btn variant-soft-error" onclick={handle_delete} disabled={loading || saving}>
<Trash2 size={16} class="mr-2" /> Delete
<button class="btn btn-sm variant-soft-secondary" onclick={() => is_editing = !is_editing} disabled={loading}>
{#if is_editing}
<Eye size={16} class="mr-2" /> View Mode
{:else}
<Edit size={16} class="mr-2" /> Edit Mode
{/if}
</button>
<button class="btn variant-filled-primary" onclick={handle_save} disabled={loading || saving}>
<Save size={16} class="mr-2" /> Save Changes
<button class="btn btn-sm variant-soft-error" onclick={handle_delete} disabled={loading}>
<Trash2 size={16} class="mr-2" /> Delete
</button>
</div>
</header>
@@ -88,72 +75,99 @@
{#if loading}
<div class="placeholder animate-pulse w-full h-64"></div>
{:else if address}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="card p-4 space-y-4">
<h3 class="h4 border-b border-surface-500/30 pb-2">Address Details</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label">
<span>City</span>
<input class="input" type="text" bind:value={address.city} />
</label>
<label class="label">
<span>State / Province</span>
<input class="input" type="text" bind:value={address.state_province} />
</label>
<label class="label md:col-span-2">
<span>Country</span>
<input class="input" type="text" bind:value={address.country} />
</label>
{#if is_editing}
<Address_form
{address}
onSave={(updated) => {
address = updated;
is_editing = false;
}}
onCancel={() => is_editing = false}
/>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="card p-6 space-y-4 variant-soft">
<h3 class="h4 border-b border-surface-500/30 pb-2">Address Details</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-xs opacity-60 uppercase font-bold">Attention To</p>
<p>{address.attention_to || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Organization</p>
<p>{address.organization_name || '--'}</p>
</div>
<div class="md:col-span-2">
<p class="text-xs opacity-60 uppercase font-bold">Address Lines</p>
<p>{address.line_1}</p>
{#if address.line_2}<p>{address.line_2}</p>{/if}
{#if address.line_3}<p>{address.line_3}</p>{/if}
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">City, State/Province</p>
<p>{address.city}, {address.state_province || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Postal Code / Country</p>
<p>{address.postal_code || '--'} / {address.country_name || address.country || '--'}</p>
</div>
</div>
</div>
<div class="card p-6 space-y-4 variant-soft">
<h3 class="h4 border-b border-surface-500/30 pb-2">Technical Details</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-xs opacity-60 uppercase font-bold">Timezone</p>
<p>{address.timezone || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Coordinates</p>
<p>{address.latitude || '--'}, {address.longitude || '--'}</p>
</div>
<div class="md:col-span-2">
<p class="text-xs opacity-60 uppercase font-bold">Internal Notes</p>
<p class="whitespace-pre-wrap">{address.notes || '--'}</p>
</div>
</div>
</div>
</div>
<div class="card p-4 space-y-4">
<h3 class="h4 border-b border-surface-500/30 pb-2">Internal Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label">
<span>Group</span>
<input class="input" type="text" bind:value={address.group} />
</label>
<label class="label">
<span>Sort Priority</span>
<input class="input" type="number" bind:value={address.sort} />
</label>
<label class="label md:col-span-2">
<span>Internal Notes</span>
<textarea class="textarea" rows="3" bind:value={address.notes}></textarea>
</label>
<div class="space-y-6">
<div class="card p-6 space-y-4 variant-soft">
<h3 class="h4 border-b border-surface-500/30 pb-2">Status</h3>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span>Enabled</span>
<span class="badge {address.enable ? 'variant-filled-success' : 'variant-filled-error'}">
{address.enable ? 'Yes' : 'No'}
</span>
</div>
<div class="flex justify-between items-center">
<span>Hidden</span>
<span class="badge {address.hide ? 'variant-filled-warning' : 'variant-filled-surface'}">
{address.hide ? 'Yes' : 'No'}
</span>
</div>
<div class="flex justify-between items-center">
<span>Priority</span>
<span class="badge {address.priority ? 'variant-filled-secondary' : 'variant-filled-surface'}">
{address.priority ? 'Yes' : 'No'}
</span>
</div>
</div>
</div>
<div class="card p-4 opacity-60 text-xs font-mono variant-soft">
<p>ID: {address.address_id_random}</p>
<p>Created: {new Date(address.created_on).toLocaleString()}</p>
{#if address.updated_on}
<p>Updated: {new Date(address.updated_on).toLocaleString()}</p>
{/if}
</div>
</div>
</div>
<div class="space-y-6">
<div class="card p-4 space-y-4">
<h3 class="h4 border-b border-surface-500/30 pb-2">Status & Visibility</h3>
<div class="space-y-4">
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={address.enable} />
<p>Enabled</p>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={address.hide} />
<p>Hidden from Public</p>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={address.priority} />
<p>High Priority</p>
</label>
</div>
</div>
<div class="card p-4 space-y-2 opacity-60 text-sm font-mono">
<p>ID: {address.address_id_random}</p>
<p>Created: {new Date(address.created_on).toLocaleString()}</p>
{#if address.updated_on}
<p>Updated: {new Date(address.updated_on).toLocaleString()}</p>
{/if}
</div>
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,258 @@
<script lang="ts">
/**
* Address Form Component
* Standardized 2026-01-09 for Core UI Polish.
* Uses unified ae_Address type and Svelte 5 Runes.
*/
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { update_ae_obj__address, create_ae_obj__address } from '$lib/ae_core/ae_core__address';
import type { ae_Address } from '$lib/types/ae_types';
import { Save, X, MapPin, Globe, Clock, Navigation } from 'lucide-svelte';
interface Props {
address?: ae_Address | null;
onSave?: (address: ae_Address) => void;
onCancel?: () => void;
}
let { address = null, onSave, onCancel }: Props = $props();
// Form State (Runes)
let formData = $state({
attention_to: address?.attention_to ?? '',
organization_name: address?.organization_name ?? '',
line_1: address?.line_1 ?? '',
line_2: address?.line_2 ?? '',
line_3: address?.line_3 ?? '',
city: address?.city ?? '',
state_province: address?.state_province ?? '',
postal_code: address?.postal_code ?? '',
country: address?.country ?? '',
// country_name: address?.country_name ?? '', // DO NOT USE - Scott 2026-01-09
timezone: address?.timezone ?? '',
latitude: address?.latitude ?? '',
longitude: address?.longitude ?? '',
notes: address?.notes ?? '',
enable: address?.enable ?? true,
hide: address?.hide ?? false,
priority: address?.priority ?? false
});
let is_loading = $state(false);
let error_msg = $state('');
async function handleSubmit(event: Event) {
event.preventDefault();
is_loading = true;
error_msg = '';
// Surgical Payload
const payload: any = { ...formData };
for (const key in payload) {
if (typeof payload[key] === 'string' && payload[key].trim() === '') {
// line_1 and city are likely required, but we'll trim them
if (key === 'line_1' || key === 'city') {
payload[key] = payload[key].trim();
} else {
payload[key] = null;
}
}
}
try {
let result;
if (address?.address_id_random) {
// Update existing
result = await update_ae_obj__address({
api_cfg: $ae_api,
address_id: address.address_id_random,
data_kv: payload,
log_lvl: 1
});
} else {
// Create new
result = await create_ae_obj__address({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: payload,
log_lvl: 1
});
}
if (result) {
if (onSave) onSave(result);
} else {
error_msg = 'Failed to save address record.';
}
} catch (err: any) {
error_msg = err.message || 'An error occurred while saving.';
} finally {
is_loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="card p-6 space-y-6 shadow-xl variant-glass-surface">
<header class="flex justify-between items-center border-b border-surface-500/30 pb-4">
<h3 class="h3 flex items-center gap-2">
<MapPin size={24} />
{address ? 'Edit Address' : 'Create New Address'}
</h3>
<div class="flex gap-2">
{#if onCancel}
<button type="button" class="btn btn-sm variant-soft" onclick={onCancel}>
<X size={16} class="mr-1" /> Cancel
</button>
{/if}
<button type="submit" class="btn btn-sm variant-filled-primary" disabled={is_loading}>
{#if is_loading}
<span class="animate-spin mr-2"></span>
{:else}
<Save size={16} class="mr-1" />
{/if}
Save Address
</button>
</div>
</header>
{#if error_msg}
<aside class="alert variant-filled-error">
<div class="alert-message">
<p>{error_msg}</p>
</div>
</aside>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Location Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Location Details</legend>
<label class="label">
<span>Attention To / Name</span>
<input class="input" type="text" bind:value={formData.attention_to} placeholder="John Doe" />
</label>
<label class="label">
<span>Organization Name</span>
<input class="input" type="text" bind:value={formData.organization_name} placeholder="Acme Corp" />
</label>
<label class="label">
<span>Address Line 1</span>
<input class="input" type="text" bind:value={formData.line_1} required placeholder="123 Main St" />
</label>
<div class="grid grid-cols-2 gap-2">
<label class="label">
<span>Line 2</span>
<input class="input" type="text" bind:value={formData.line_2} placeholder="Suite 100" />
</label>
<label class="label">
<span>Line 3</span>
<input class="input" type="text" bind:value={formData.line_3} placeholder="Floor 2" />
</label>
</div>
</fieldset>
<!-- Region Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Region & Code</legend>
<div class="grid grid-cols-2 gap-2">
<label class="label">
<span>City</span>
<input class="input" type="text" bind:value={formData.city} required placeholder="Metropolis" />
</label>
<label class="label">
<span>State / Province</span>
<input class="input" type="text" bind:value={formData.state_province} placeholder="NY" />
</label>
</div>
<div class="grid grid-cols-2 gap-2">
<label class="label">
<span>Postal Code</span>
<input class="input" type="text" bind:value={formData.postal_code} placeholder="12345" />
</label>
<label class="label">
<span>Country (Code)</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Globe size={16} /></div>
<input type="text" bind:value={formData.country} placeholder="USA" />
</div>
</label>
</div>
<!-- DO NOT USE - Scott 2026-01-09 -->
<!-- <label class="label">
<span>Country Name</span>
<input class="input" type="text" bind:value={formData.country_name} placeholder="United States" />
</label> -->
</fieldset>
<!-- Technical Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Technical & GIS</legend>
<label class="label">
<span>Timezone</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Clock size={16} /></div>
<input type="text" bind:value={formData.timezone} placeholder="America/New_York" />
</div>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="label">
<span>Latitude</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Navigation size={16} /></div>
<input type="text" bind:value={formData.latitude} placeholder="40.7128" />
</div>
</label>
<label class="label">
<span>Longitude</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Navigation size={16} /></div>
<input type="text" bind:value={formData.longitude} placeholder="-74.0060" />
</div>
</label>
</div>
</fieldset>
<!-- Status Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Status</legend>
<div class="flex flex-wrap gap-4 pt-2">
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.enable} />
<span>Enabled</span>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.hide} />
<span>Hidden</span>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.priority} />
<span>Priority</span>
</label>
</div>
<label class="label">
<span>Internal Notes</span>
<textarea class="textarea" rows="2" bind:value={formData.notes} placeholder="Notes about this address..."></textarea>
</label>
</fieldset>
</div>
<footer class="flex justify-end gap-2 border-t border-surface-500/30 pt-4">
<button type="submit" class="btn variant-filled-primary w-full md:w-auto" disabled={is_loading}>
{#if is_loading}
<span class="animate-spin mr-2"></span>
{/if}
{address ? 'Update Address' : 'Create Address'}
</button>
</footer>
</form>

View File

@@ -2,11 +2,13 @@
import { onMount } from 'svelte';
import { ae_loc, ae_api, slct } from '$lib/stores/ae_stores';
import { goto } from '$app/navigation';
import { Phone, Plus, Search, Mail, User, ExternalLink } from 'lucide-svelte';
import { Phone, Plus, Search, Mail, User, ExternalLink, X } from 'lucide-svelte';
import { load_ae_obj_li__contact, create_ae_obj__contact } from '$lib/ae_core/ae_core__contact';
import Contact_form from './ae_comp__contact_form.svelte';
let contact_li: any[] = $state([]);
let loading = $state(true);
let show_add_form = $state(false);
async function load_contacts() {
if (!$ae_loc.account_id) return;
@@ -20,27 +22,6 @@
loading = false;
}
async function handle_add() {
const name = prompt('Enter contact name:');
if (!name) return;
const email = prompt('Enter email address:');
const phone = prompt('Enter phone number:');
const result = await create_ae_obj__contact({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: { name, email, phone, enable: true },
log_lvl: 1
});
if (result) {
load_contacts();
if (result.contact_id_random) {
goto(`/core/contacts/${result.contact_id_random}`);
}
}
}
onMount(() => {
if (!$ae_loc.manager_access) {
goto('/core');
@@ -56,11 +37,30 @@
<Phone size={24} />
<h1 class="h2">Contact Management</h1>
</div>
<button class="btn variant-filled-primary" onclick={handle_add}>
<Plus size={16} class="mr-2" /> Add Contact
<button class="btn variant-filled-primary" onclick={() => show_add_form = !show_add_form}>
{#if show_add_form}
<X size={16} class="mr-2" /> Cancel
{:else}
<Plus size={16} class="mr-2" /> Add Contact
{/if}
</button>
</header>
{#if show_add_form}
<div class="mb-8">
<Contact_form
onSave={(new_con) => {
show_add_form = false;
load_contacts();
if (new_con.contact_id_random) {
goto(`/core/contacts/${new_con.contact_id_random}`);
}
}}
onCancel={() => show_add_form = false}
/>
</div>
{/if}
{#if loading}
<div class="placeholder animate-pulse h-64 w-full"></div>
{:else if contact_li.length === 0}

View File

@@ -9,12 +9,13 @@
import { editable_fields__contact } from '$lib/ae_core/ae_core__contact.editable_fields';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { goto } from '$app/navigation';
import { Save, Trash2, ArrowLeft, UserRound } from 'lucide-svelte';
import { Save, Trash2, ArrowLeft, UserRound, Edit, Eye } from 'lucide-svelte';
import Contact_form from '../ae_comp__contact_form.svelte';
let contact_id = $page.params.contact_id;
let contact: any = $state(null);
let loading = $state(true);
let saving = $state(false);
let is_editing = $state(false);
async function load_data() {
loading = true;
@@ -34,24 +35,6 @@
load_data();
});
async function handle_save() {
saving = true;
const data_kv: any = {};
editable_fields__contact.forEach(field => {
if (contact[field] !== undefined) {
data_kv[field] = contact[field];
}
});
await update_ae_obj__contact({
api_cfg: $ae_api,
contact_id,
data_kv,
log_lvl: 1
});
saving = false;
}
async function handle_delete() {
if (!confirm('Permanently delete this contact?')) return;
await delete_ae_obj_id__contact({
@@ -72,15 +55,19 @@
</a>
<div class="flex items-center gap-2">
<UserRound size={24} />
<h1 class="h2">{contact?.name ?? 'Loading Contact...'}</h1>
<h1 class="h2">{contact?.name || contact?.title || 'Loading Contact...'}</h1>
</div>
</div>
<div class="flex gap-2">
<button class="btn variant-soft-error" onclick={handle_delete} disabled={loading || saving}>
<Trash2 size={16} class="mr-2" /> Delete
<button class="btn btn-sm variant-soft-secondary" onclick={() => is_editing = !is_editing} disabled={loading}>
{#if is_editing}
<Eye size={16} class="mr-2" /> View Mode
{:else}
<Edit size={16} class="mr-2" /> Edit Mode
{/if}
</button>
<button class="btn variant-filled-primary" onclick={handle_save} disabled={loading || saving}>
<Save size={16} class="mr-2" /> Save Changes
<button class="btn btn-sm variant-soft-error" onclick={handle_delete} disabled={loading}>
<Trash2 size={16} class="mr-2" /> Delete
</button>
</div>
</header>
@@ -88,72 +75,110 @@
{#if loading}
<div class="placeholder animate-pulse w-full h-64"></div>
{:else if contact}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="card p-4 space-y-4">
<h3 class="h4 border-b border-surface-500/30 pb-2">Contact Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label md:col-span-2">
<span>Display Name</span>
<input class="input" type="text" bind:value={contact.name} />
</label>
<label class="label">
<span>Email Address</span>
<input class="input" type="email" bind:value={contact.email} />
</label>
<label class="label">
<span>Phone Number</span>
<input class="input" type="tel" bind:value={contact.phone} />
</label>
{#if is_editing}
<Contact_form
{contact}
onSave={(updated) => {
contact = updated;
is_editing = false;
}}
onCancel={() => is_editing = false}
/>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="card p-6 space-y-4 variant-soft">
<h3 class="h4 border-b border-surface-500/30 pb-2">Contact Details</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-xs opacity-60 uppercase font-bold">Title / Name</p>
<p>{contact.title || contact.name || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Tagline</p>
<p>{contact.tagline || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Email</p>
<p>{contact.email || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Website</p>
<p>
{#if contact.website_url}
<a href={contact.website_url} target="_blank" class="text-blue-500 underline">{contact.website_url}</a>
{:else}
--
{/if}
</p>
</div>
</div>
</div>
<div class="card p-6 space-y-4 variant-soft">
<h3 class="h4 border-b border-surface-500/30 pb-2">Communication & Social</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-xs opacity-60 uppercase font-bold">Mobile Phone</p>
<p>{contact.phone_mobile || contact.phone || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Office Phone</p>
<p>{contact.phone_office || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">LinkedIn</p>
<p>{contact.linkedin_url || '--'}</p>
</div>
<div>
<p class="text-xs opacity-60 uppercase font-bold">Socials</p>
<div class="flex gap-2">
{#if contact.facebook_url}<span>FB</span>{/if}
{#if contact.instagram_url}<span>IG</span>{/if}
</div>
</div>
<div class="md:col-span-2">
<p class="text-xs opacity-60 uppercase font-bold">Internal Notes</p>
<p class="whitespace-pre-wrap">{contact.notes || '--'}</p>
</div>
</div>
</div>
</div>
<div class="card p-4 space-y-4">
<h3 class="h4 border-b border-surface-500/30 pb-2">Internal Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label">
<span>Group</span>
<input class="input" type="text" bind:value={contact.group} />
</label>
<label class="label">
<span>Sort Priority</span>
<input class="input" type="number" bind:value={contact.sort} />
</label>
<label class="label md:col-span-2">
<span>Internal Notes</span>
<textarea class="textarea" rows="3" bind:value={contact.notes}></textarea>
</label>
<div class="space-y-6">
<div class="card p-6 space-y-4 variant-soft">
<h3 class="h4 border-b border-surface-500/30 pb-2">Status</h3>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span>Enabled</span>
<span class="badge {contact.enable ? 'variant-filled-success' : 'variant-filled-error'}">
{contact.enable ? 'Yes' : 'No'}
</span>
</div>
<div class="flex justify-between items-center">
<span>Hidden</span>
<span class="badge {contact.hide ? 'variant-filled-warning' : 'variant-filled-surface'}">
{contact.hide ? 'Yes' : 'No'}
</span>
</div>
<div class="flex justify-between items-center">
<span>Priority</span>
<span class="badge {contact.priority ? 'variant-filled-secondary' : 'variant-filled-surface'}">
{contact.priority ? 'Yes' : 'No'}
</span>
</div>
</div>
</div>
<div class="card p-4 opacity-60 text-xs font-mono variant-soft">
<p>ID: {contact.contact_id_random}</p>
<p>Created: {new Date(contact.created_on).toLocaleString()}</p>
{#if contact.updated_on}
<p>Updated: {new Date(contact.updated_on).toLocaleString()}</p>
{/if}
</div>
</div>
</div>
<div class="space-y-6">
<div class="card p-4 space-y-4">
<h3 class="h4 border-b border-surface-500/30 pb-2">Status & Visibility</h3>
<div class="space-y-4">
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={contact.enable} />
<p>Enabled</p>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={contact.hide} />
<p>Hidden from Public</p>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={contact.priority} />
<p>High Priority</p>
</label>
</div>
</div>
<div class="card p-4 space-y-2 opacity-60 text-sm font-mono">
<p>ID: {contact.contact_id_random}</p>
<p>Created: {new Date(contact.created_on).toLocaleString()}</p>
{#if contact.updated_on}
<p>Updated: {new Date(contact.updated_on).toLocaleString()}</p>
{/if}
</div>
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,240 @@
<script lang="ts">
/**
* Contact Form Component
* Standardized 2026-01-09 for Core UI Polish.
* Uses unified ae_Contact type and Svelte 5 Runes.
*/
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { update_ae_obj__contact, create_ae_obj__contact } from '$lib/ae_core/ae_core__contact';
import type { ae_Contact } from '$lib/types/ae_types';
import { Save, X, Phone, Mail, Globe, Facebook, Instagram, Linkedin, UserPlus } from 'lucide-svelte';
interface Props {
contact?: ae_Contact | null;
onSave?: (contact: ae_Contact) => void;
onCancel?: () => void;
}
let { contact = null, onSave, onCancel }: Props = $props();
// Form State (Runes)
let formData = $state({
title: contact?.title ?? '',
tagline: contact?.tagline ?? '',
email: contact?.email ?? '',
phone_mobile: contact?.phone_mobile ?? '',
phone_office: contact?.phone_office ?? '',
website_url: contact?.website_url ?? '',
facebook_url: contact?.facebook_url ?? '',
instagram_url: contact?.instagram_url ?? '',
linkedin_url: contact?.linkedin_url ?? '',
notes: contact?.notes ?? '',
enable: contact?.enable ?? true,
hide: contact?.hide ?? false,
priority: contact?.priority ?? false
});
let is_loading = $state(false);
let error_msg = $state('');
async function handleSubmit(event: Event) {
event.preventDefault();
is_loading = true;
error_msg = '';
// Surgical Payload
const payload: any = { ...formData };
for (const key in payload) {
if (typeof payload[key] === 'string' && payload[key].trim() === '') {
// title is likely required, but we'll trim it
if (key === 'title') {
payload[key] = payload[key].trim();
} else {
payload[key] = null;
}
}
}
try {
let result;
if (contact?.contact_id_random) {
// Update existing
result = await update_ae_obj__contact({
api_cfg: $ae_api,
contact_id: contact.contact_id_random,
data_kv: payload,
log_lvl: 1
});
} else {
// Create new
result = await create_ae_obj__contact({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: payload,
log_lvl: 1
});
}
if (result) {
if (onSave) onSave(result);
} else {
error_msg = 'Failed to save contact record.';
}
} catch (err: any) {
error_msg = err.message || 'An error occurred while saving.';
} finally {
is_loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="card p-6 space-y-6 shadow-xl variant-glass-surface">
<header class="flex justify-between items-center border-b border-surface-500/30 pb-4">
<h3 class="h3 flex items-center gap-2">
<UserPlus size={24} />
{contact ? 'Edit Contact' : 'Create New Contact'}
</h3>
<div class="flex gap-2">
{#if onCancel}
<button type="button" class="btn btn-sm variant-soft" onclick={onCancel}>
<X size={16} class="mr-1" /> Cancel
</button>
{/if}
<button type="submit" class="btn btn-sm variant-filled-primary" disabled={is_loading}>
{#if is_loading}
<span class="animate-spin mr-2"></span>
{:else}
<Save size={16} class="mr-1" />
{/if}
Save Contact
</button>
</div>
</header>
{#if error_msg}
<aside class="alert variant-filled-error">
<div class="alert-message">
<p>{error_msg}</p>
</div>
</aside>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Identity Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Identity & Branding</legend>
<label class="label">
<span>Title / Name</span>
<input class="input" type="text" bind:value={formData.title} required placeholder="Business Office" />
</label>
<label class="label">
<span>Tagline</span>
<input class="input" type="text" bind:value={formData.tagline} placeholder="Primary contact for business inquiries" />
</label>
<label class="label">
<span>Email Address</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Mail size={16} /></div>
<input type="email" bind:value={formData.email} placeholder="contact@example.com" />
</div>
</label>
</fieldset>
<!-- Communication Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Phone & Web</legend>
<div class="grid grid-cols-2 gap-2">
<label class="label">
<span>Mobile Phone</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Phone size={16} /></div>
<input type="tel" bind:value={formData.phone_mobile} placeholder="+1..." />
</div>
</label>
<label class="label">
<span>Office Phone</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Phone size={16} /></div>
<input type="tel" bind:value={formData.phone_office} placeholder="+1..." />
</div>
</label>
</div>
<label class="label">
<span>Website URL</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Globe size={16} /></div>
<input type="url" bind:value={formData.website_url} placeholder="https://..." />
</div>
</label>
</fieldset>
<!-- Social Media Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Social Media</legend>
<label class="label">
<span>LinkedIn URL</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Linkedin size={16} /></div>
<input type="url" bind:value={formData.linkedin_url} placeholder="https://linkedin.com/in/..." />
</div>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="label">
<span>Facebook URL</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Facebook size={16} /></div>
<input type="url" bind:value={formData.facebook_url} placeholder="https://facebook.com/..." />
</div>
</label>
<label class="label">
<span>Instagram URL</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Instagram size={16} /></div>
<input type="url" bind:value={formData.instagram_url} placeholder="https://instagram.com/..." />
</div>
</label>
</div>
</fieldset>
<!-- Status Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Status</legend>
<div class="flex flex-wrap gap-4 pt-2">
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.enable} />
<span>Enabled</span>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.hide} />
<span>Hidden</span>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.priority} />
<span>Priority</span>
</label>
</div>
<label class="label">
<span>Internal Notes</span>
<textarea class="textarea" rows="2" bind:value={formData.notes} placeholder="Additional details..."></textarea>
</label>
</fieldset>
</div>
<footer class="flex justify-end gap-2 border-t border-surface-500/30 pt-4">
<button type="submit" class="btn variant-filled-primary w-full md:w-auto" disabled={is_loading}>
{#if is_loading}
<span class="animate-spin mr-2"></span>
{/if}
{contact ? 'Update Contact' : 'Create Contact'}
</button>
</footer>
</form>

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { X } from 'lucide-svelte';
import { ae_loc, ae_sess, slct, ae_api } from '$lib/stores/ae_stores';
import { core_func } from '$lib/ae_core/ae_core_functions';
import { Users, Plus } from 'lucide-svelte';
import Comp_person_search from './ae_comp__person_search.svelte';
import Comp_person_obj_tbl from '../ae_comp__person_obj_tbl.svelte';
import Person_form from './ae_comp__person_form.svelte';
let person_id_random_li: string[] = $state([]);
let show_add_form = $state(false);
onMount(() => {
if (!$ae_loc.manager_access) {
@@ -25,31 +28,30 @@
</div>
<button
type="button"
onclick={async () => {
if (!confirm(`Add a new person to ${$ae_loc.account_name}?`)) return;
let person_data = {
account_id_random: $slct.account_id,
source_code: 'manual:SK-core',
given_name: 'New',
family_name: 'Person',
enable: true
};
let new_person_obj = await core_func.create_ae_obj__person({
api_cfg: $ae_api,
data_kv: person_data,
log_lvl: 1
});
if (new_person_obj && confirm(`Person created. View details?`)) {
goto(`/core/people/${new_person_obj.person_id_random}`);
}
}}
onclick={() => show_add_form = !show_add_form}
class="btn variant-filled-primary"
class:hidden={!$ae_loc.edit_mode}
>
<Plus size={16} class="mr-2" /> Add Person
{#if show_add_form}
<X size={16} class="mr-2" /> Cancel
{:else}
<Plus size={16} class="mr-2" /> Add Person
{/if}
</button>
</header>
{#if show_add_form}
<div class="mb-8">
<Person_form
onSave={(new_person) => {
show_add_form = false;
goto(`/core/people/${new_person.person_id_random}`);
}}
onCancel={() => show_add_form = false}
/>
</div>
{/if}
<Comp_person_search on_results={(results) => {
person_id_random_li = results.map(p => p.person_id_random);
$ae_sess.person.show_report__person_li = true;

View File

@@ -4,6 +4,7 @@
// Imports
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
// import { api } from '$lib/api';
@@ -27,12 +28,13 @@
// import { events_func } from '$lib/ae_events_functions';
import Person_view from './../../person_view.svelte';
import Person_form from './../ae_comp__person_form.svelte';
import { load_ae_obj_li__user } from '$lib/ae_core/ae_core__user';
import { update_ae_obj__person } from '$lib/ae_core/ae_core__person';
import { qry_ae_obj_li__event } from '$lib/ae_events/ae_events__event';
import { qry__post } from '$lib/ae_posts/ae_posts__post';
import { qry__activity_log } from '$lib/ae_core/core__activity_log';
import { Users, Link, Unlink, UserPlus, ShieldCheck, User, Calendar, MessageSquare, History, Activity } from 'lucide-svelte';
import { Users, Link, Unlink, UserPlus, ShieldCheck, User, Calendar, MessageSquare, History, Activity, Edit, Eye } from 'lucide-svelte';
interface Props {
data: any;
@@ -49,7 +51,7 @@
$slct.person_id = ae_acct.slct.person_id;
$slct.person_obj = ae_acct.slct.person_obj;
$ae_sess.person.show_edit__person = false;
let is_editing = $state(false);
let lq__person_obj = liveQuery(() => db_core.person.get($slct.person_id));
$slct.lq__person_obj = lq__person_obj;
@@ -171,11 +173,25 @@
class:border-gray-100={!$ae_loc.person.show_content__person_page_help}
>
<div>
<a href="/core" class={ae_snip.classes__core_menu__button}>
<a href="/core/people" class={ae_snip.classes__core_menu__button}>
<span class="fas fa-arrow-left mx-1"></span>
Back to Core
Back to People
</a>
{#if $ae_loc.edit_mode}
<button
type="button"
onclick={() => is_editing = !is_editing}
class="btn btn-sm variant-soft-secondary mx-1"
>
{#if is_editing}
<Eye size={16} class="mr-2" /> View Mode
{:else}
<Edit size={16} class="mr-2" /> Edit Mode
{/if}
</button>
{/if}
<button
type="button"
onclick={() => {
@@ -183,7 +199,7 @@
!$ae_loc.person.show_content__person_page_help;
}}
class={ae_snip.classes__core_menu__button}
title="Help and information about the session search"
title="Help and information about the person page"
>
<span class="fas fa-question-circle mx-1"></span>
{#if $ae_loc.person.show_content__person_page_help}
@@ -383,20 +399,25 @@
{/if}
{#if !$lq__person_obj}
<div class="flex flex-row items-center justify-center">
<div class="flex flex-row items-center justify-center p-8">
<span class="fas fa-spinner fa-spin mx-1"></span>
<span>Loading...</span>
<span>Loading Person...</span>
</div>
{:else if is_editing}
<div class="px-4">
<Person_form
person={$lq__person_obj}
onSave={(updated) => {
is_editing = false;
// The liveQuery should pick up the changes after they are saved to IndexedDB
// inside update_ae_obj__person -> db_save_ae_obj_li__ae_obj
}}
onCancel={() => is_editing = false}
/>
</div>
{:else}
<!-- {$lq__person_obj?.full_name} -->
<Person_view person_id={$slct.person_id} />
{/if}
<!-- <hr class="w-full border border-gray-200" /> -->
<!-- {#await $slct.person_obj}
<span class="fas fa-spinner fa-spin text-xl text-blue-500"></span>
{:then result} -->
<Person_view person_id={$slct.person_id} />
<!-- {:catch error}
<div class="text-red-800">
<span class="fas fa-exclamation-triangle text-xl"></span>

View File

@@ -0,0 +1,262 @@
<script lang="ts">
/**
* Person Form Component
* Standardized 2026-01-09 for Core UI Polish.
* Uses unified ae_Person type and Svelte 5 Runes.
*/
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { core_func } from '$lib/ae_core/ae_core_functions';
import type { ae_Person } from '$lib/types/ae_types';
import { Save, X, User, Mail, Phone, Building, Briefcase, Tag } from 'lucide-svelte';
interface Props {
person?: ae_Person | null;
onSave?: (person: ae_Person) => void;
onCancel?: () => void;
}
let { person = null, onSave, onCancel }: Props = $props();
// Form State (Runes)
let formData = $state({
given_name: person?.given_name ?? '',
family_name: person?.family_name ?? '',
middle_name: person?.middle_name ?? '',
prefix: person?.prefix ?? person?.title_names ?? '',
suffix: person?.suffix ?? person?.designations ?? '',
nickname: person?.informal_name ?? '',
professional_title: person?.professional_title ?? '',
affiliations: person?.affiliations ?? '',
primary_email: person?.primary_email ?? '',
phone: person?.phone ?? '',
tagline: person?.tagline ?? '',
notes: person?.notes ?? '',
enable: person?.enable ?? true,
hide: person?.hide ?? false,
priority: person?.priority ?? false
});
let is_loading = $state(false);
let error_msg = $state('');
async function handleSubmit(event: Event) {
event.preventDefault();
is_loading = true;
error_msg = '';
// Clean payload: Map fields and handle optional values
const payload: any = {
given_name: formData.given_name.trim(),
family_name: formData.family_name.trim() || null,
middle_name: formData.middle_name.trim() || null,
informal_name: formData.nickname.trim() || null,
// title_names: formData.prefix.trim() || null, // DO NOT USE - Scott 2026-01-09
designations: formData.suffix.trim() || null,
// NOTE:DO NOT USE Do note send the full_name field at this time - Scott 2026-01-09
// full_name: `${formData.prefix ? formData.prefix + ' ' : ''}${formData.given_name} ${formData.family_name}${formData.suffix ? ', ' + formData.suffix : ''}`.trim(), // DO NOT USE - Scott 2026-01-09
professional_title: formData.professional_title.trim() || null,
affiliations: formData.affiliations.trim() || null,
primary_email: formData.primary_email.trim() || null,
tagline: formData.tagline.trim() || null,
notes: formData.notes.trim() || null,
allow_auth_key: true,
enable: formData.enable,
hide: formData.hide,
priority: formData.priority
};
// Ensure strings are truly null if empty after trim
for (const key in payload) {
if (payload[key] === '') {
if (key !== 'given_name' && key !== 'full_name') {
payload[key] = null;
}
}
}
try {
let result;
if (person?.person_id_random) {
// Update existing
result = await core_func.update_ae_obj__person({
api_cfg: $ae_api,
person_id: person.person_id_random,
data_kv: payload,
log_lvl: 1
});
} else {
// Create new
result = await core_func.create_ae_obj__person({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: payload,
log_lvl: 1
});
}
if (result) {
if (onSave) onSave(result);
} else {
error_msg = 'Failed to save person record. The server rejected the request (400).';
}
} catch (err: any) {
error_msg = err.message || 'An error occurred while saving.';
} finally {
is_loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="card p-6 space-y-6 shadow-xl variant-glass-surface">
<header class="flex justify-between items-center border-b border-surface-500/30 pb-4">
<h3 class="h3 flex items-center gap-2">
<User size={24} />
{person ? 'Edit Person' : 'Create New Person'}
</h3>
<div class="flex gap-2">
{#if onCancel}
<button type="button" class="btn btn-sm variant-soft" onclick={onCancel}>
<X size={16} class="mr-1" /> Cancel
</button>
{/if}
<button type="submit" class="btn btn-sm variant-filled-primary" disabled={is_loading}>
{#if is_loading}
<span class="animate-spin mr-2"></span>
{:else}
<Save size={16} class="mr-1" />
{/if}
Save Person
</button>
</div>
</header>
{#if error_msg}
<aside class="alert variant-filled-error">
<div class="alert-message">
<p>{error_msg}</p>
</div>
</aside>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Name Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Identity</legend>
<div class="grid grid-cols-4 gap-2">
<label class="label col-span-1">
<span>Prefix</span>
<input class="input placeholder-surface-400" type="text" bind:value={formData.prefix} placeholder="Mr." />
</label>
<label class="label col-span-3">
<span>Given Name</span>
<input class="input placeholder-surface-400" type="text" bind:value={formData.given_name} required placeholder="Jane" />
</label>
</div>
<div class="grid grid-cols-4 gap-2">
<label class="label col-span-3">
<span>Family Name</span>
<input class="input placeholder-surface-400" type="text" bind:value={formData.family_name} required placeholder="Doe" />
</label>
<label class="label col-span-1">
<span>Suffix</span>
<input class="input placeholder-surface-400" type="text" bind:value={formData.suffix} placeholder="PhD" />
</label>
</div>
<label class="label">
<span>Middle Name / Informal Name (Nickname)</span>
<div class="grid grid-cols-2 gap-2">
<input class="input placeholder-surface-400" type="text" bind:value={formData.middle_name} placeholder="Middle" />
<input class="input placeholder-surface-400" type="text" bind:value={formData.nickname} placeholder="Nickname" />
</div>
</label>
</fieldset>
<!-- Contact Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Contact Information</legend>
<label class="label">
<span>Primary Email</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Mail size={16} /></div>
<input class="input placeholder-surface-400" type="email" bind:value={formData.primary_email} placeholder="jane.doe@example.com" />
</div>
</label>
<label class="label">
<span>Phone Number</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Phone size={16} /></div>
<input class="input placeholder-surface-400" type="tel" bind:value={formData.phone} placeholder="+1 (555) 000-0000" />
</div>
<small class="opacity-60 text-xs">(Saved only locally until Contact created)</small>
</label>
<label class="label">
<span>Tagline</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Tag size={16} /></div>
<input class="input placeholder-surface-400" type="text" bind:value={formData.tagline} placeholder="Software Architect & Visionary" />
</div>
</label>
</fieldset>
<!-- Professional Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Professional</legend>
<label class="label">
<span>Professional Title</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Briefcase size={16} /></div>
<input class="input placeholder-surface-400" type="text" bind:value={formData.professional_title} placeholder="Senior Engineer" />
</div>
</label>
<label class="label">
<span>Affiliations</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr]">
<div class="input-group-shim"><Building size={16} /></div>
<input class="input placeholder-surface-400" type="text" bind:value={formData.affiliations} placeholder="One Sky IT, LLC" />
</div>
</label>
</fieldset>
<!-- Metadata Section -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold uppercase tracking-widest opacity-60">Status & Flags</legend>
<div class="flex flex-wrap gap-4 pt-2">
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.enable} />
<span>Enabled</span>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.hide} />
<span>Hidden</span>
</label>
<label class="flex items-center space-x-2">
<input class="checkbox" type="checkbox" bind:checked={formData.priority} />
<span>Priority</span>
</label>
</div>
<label class="label">
<span>Notes (Internal)</span>
<textarea class="textarea placeholder-surface-400" rows="3" bind:value={formData.notes} placeholder="Additional details..."></textarea>
</label>
</fieldset>
</div>
<footer class="flex justify-end gap-2 border-t border-surface-500/30 pt-4">
<button type="submit" class="btn variant-filled-primary w-full md:w-auto" disabled={is_loading}>
{#if is_loading}
<span class="animate-spin mr-2"></span>
{/if}
{person ? 'Update Person Record' : 'Create Person Record'}
</button>
</footer>
</form>