- Gateway proxies /api/brain/* to brain-api:8200/api/* via pangolin network - User identity injected via X-Gateway-User-Id header - Brain app registered in gateway database (sort_order 9) - Added to GATEWAY_KEY_SERVICES for dashboard integration - Tested: health, config, list, create all working through gateway Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1068 lines
38 KiB
Svelte
1068 lines
38 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { page } from '$app/state';
|
|
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
|
|
|
|
interface InventoryItem {
|
|
id: number;
|
|
name: string;
|
|
order: string;
|
|
sku: string;
|
|
serial: string;
|
|
status: string;
|
|
price: number;
|
|
tax: number;
|
|
total: number;
|
|
qty: number;
|
|
tracking: string;
|
|
vendor: string;
|
|
buyerName: string;
|
|
date: string;
|
|
notes: string;
|
|
photos: number;
|
|
photoUrls: string[];
|
|
}
|
|
|
|
interface ItemDetailRaw {
|
|
Id: number;
|
|
Item: string;
|
|
'Order Number': string;
|
|
'Serial Numbers': string;
|
|
SKU: string;
|
|
Received: string;
|
|
'Price Per Item': number;
|
|
Tax: number;
|
|
Total: number;
|
|
QTY: number;
|
|
'Tracking Number': string;
|
|
Source: string;
|
|
Name: string;
|
|
Date: string;
|
|
Notes: string;
|
|
photos: any[];
|
|
[key: string]: any;
|
|
}
|
|
|
|
let activeTab = $state<'issues' | 'review' | 'all'>('issues');
|
|
let searchQuery = $state('');
|
|
let detailOpen = $state(false);
|
|
let selectedItem = $state<InventoryItem | null>(null);
|
|
let selectedDetail = $state<ItemDetailRaw | null>(null);
|
|
let nocodbUrl = $state('');
|
|
|
|
let recentItems = $state<InventoryItem[]>([]);
|
|
let issueItems = $state<InventoryItem[]>([]);
|
|
let reviewItems = $state<InventoryItem[]>([]);
|
|
let recentLoaded = $state(false);
|
|
let loading = $state(true);
|
|
let searching = $state(false);
|
|
let searchResults = $state<InventoryItem[] | null>(null);
|
|
let saving = $state(false);
|
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
|
|
const issueCount = $derived(issueItems.length);
|
|
const reviewCount = $derived(reviewItems.length);
|
|
const recentCount = $derived(recentItems.length);
|
|
|
|
const displayedItems = $derived(() => {
|
|
if (searchResults !== null) return searchResults;
|
|
if (activeTab === 'issues') return issueItems;
|
|
if (activeTab === 'review') return reviewItems;
|
|
return recentItems;
|
|
});
|
|
const activeCount = $derived(displayedItems().length);
|
|
|
|
function mapIssue(raw: any): InventoryItem {
|
|
return {
|
|
id: raw.id,
|
|
name: raw.item || '',
|
|
order: raw.orderNumber || '',
|
|
sku: raw.sku || '',
|
|
serial: raw.serialNumbers || '',
|
|
status: normalizeStatus(raw.received || ''),
|
|
price: 0,
|
|
tax: 0,
|
|
total: 0,
|
|
qty: 1,
|
|
tracking: raw.trackingNumber || '',
|
|
vendor: '',
|
|
buyerName: '',
|
|
date: '',
|
|
notes: raw.notes || '',
|
|
photos: 0,
|
|
photoUrls: []
|
|
};
|
|
}
|
|
|
|
const NOCODB_BASE = 'https://noco.quadjourney.com';
|
|
|
|
function extractPhotoUrls(photos: any[]): string[] {
|
|
if (!Array.isArray(photos)) return [];
|
|
return photos
|
|
.filter((p: any) => p && p.signedPath)
|
|
.map((p: any) => `${NOCODB_BASE}/${p.signedPath}`);
|
|
}
|
|
|
|
function mapDetail(raw: ItemDetailRaw): InventoryItem {
|
|
const photoUrls = extractPhotoUrls(raw.photos);
|
|
return {
|
|
id: raw.Id,
|
|
name: raw.Item || '',
|
|
order: raw['Order Number'] || '',
|
|
sku: raw.SKU || '',
|
|
serial: raw['Serial Numbers'] || '',
|
|
status: normalizeStatus(raw.Received || ''),
|
|
price: raw['Price Per Item'] || 0,
|
|
tax: raw.Tax || 0,
|
|
total: raw.Total || 0,
|
|
qty: raw.QTY || 1,
|
|
tracking: raw['Tracking Number'] || '',
|
|
vendor: raw.Source || '',
|
|
buyerName: raw.Name || '',
|
|
date: raw.Date || '',
|
|
notes: raw.Notes || '',
|
|
photos: Array.isArray(raw.photos) ? raw.photos.length : 0,
|
|
photoUrls
|
|
};
|
|
}
|
|
|
|
function normalizeStatus(received: string): string {
|
|
if (!received) return 'Pending';
|
|
const lower = received.toLowerCase();
|
|
if (lower === 'issue' || lower === 'issues') return 'Issue';
|
|
if (lower === 'needs review') return 'Needs Review';
|
|
if (lower === 'pending') return 'Pending';
|
|
if (lower === 'closed') return 'Closed';
|
|
return 'Received';
|
|
}
|
|
|
|
async function loadSummary() {
|
|
try {
|
|
const res = await fetch('/api/inventory/summary', { credentials: 'include' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
issueItems = (data.issues || []).map(mapIssue);
|
|
reviewItems = (data.needsReview || []).map(mapIssue);
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
function mapSearchResult(r: any): InventoryItem {
|
|
return {
|
|
id: r.id,
|
|
name: r.item || '',
|
|
order: '', sku: '', serial: '',
|
|
status: normalizeStatus(r.received || ''),
|
|
price: 0, tax: 0, total: 0, qty: 1,
|
|
tracking: '', vendor: '', buyerName: '', date: '',
|
|
notes: '', photos: 0, photoUrls: []
|
|
};
|
|
}
|
|
|
|
async function loadRecent() {
|
|
if (recentLoaded) return;
|
|
try {
|
|
const res = await fetch('/api/inventory/recent?limit=30', { credentials: 'include' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
recentItems = (data.items || []).map(mapSearchResult);
|
|
recentLoaded = true;
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
async function searchItems(query: string) {
|
|
if (!query.trim()) { searchResults = null; return; }
|
|
searching = true;
|
|
try {
|
|
const res = await fetch(`/api/inventory/search-records?q=${encodeURIComponent(query)}`, { credentials: 'include' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
searchResults = (data.results || []).map((r: any) => ({
|
|
id: r.id,
|
|
name: r.item || '',
|
|
order: '', sku: '', serial: '',
|
|
status: normalizeStatus(r.received || ''),
|
|
price: 0, tax: 0, total: 0, qty: 1,
|
|
tracking: '', vendor: '', buyerName: '', date: '',
|
|
notes: '', photos: 0, photoUrls: []
|
|
}));
|
|
}
|
|
} catch { searchResults = []; }
|
|
finally { searching = false; }
|
|
}
|
|
|
|
async function loadItemDetail(id: number) {
|
|
try {
|
|
const res = await fetch(`/api/inventory/item-details/${id}`, { credentials: 'include' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
selectedDetail = data.item;
|
|
selectedItem = mapDetail(data.item);
|
|
activePhoto = 0;
|
|
nocodbUrl = data.nocodb_url || '';
|
|
detailOpen = true;
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
async function updateField(field: string, value: string | number) {
|
|
if (!selectedItem) return;
|
|
saving = true;
|
|
try {
|
|
const res = await fetch(`/api/inventory/item/${selectedItem.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ [field]: value }),
|
|
credentials: 'include'
|
|
});
|
|
if (res.ok && selectedDetail) {
|
|
(selectedDetail as any)[field] = value;
|
|
selectedItem = mapDetail(selectedDetail);
|
|
}
|
|
} catch { /* silent */ }
|
|
finally { saving = false; }
|
|
}
|
|
|
|
function onSearchInput() {
|
|
clearTimeout(debounceTimer);
|
|
if (!searchQuery.trim()) { searchResults = null; return; }
|
|
debounceTimer = setTimeout(() => searchItems(searchQuery), 300);
|
|
}
|
|
|
|
function openDetail(item: InventoryItem) {
|
|
loadItemDetail(item.id);
|
|
}
|
|
|
|
async function refreshVisibleData() {
|
|
await loadSummary();
|
|
if (activeTab === 'all') {
|
|
recentLoaded = false;
|
|
await loadRecent();
|
|
}
|
|
if (searchQuery.trim()) {
|
|
await searchItems(searchQuery);
|
|
}
|
|
}
|
|
|
|
async function closeDetail() {
|
|
await refreshVisibleData();
|
|
detailOpen = false;
|
|
selectedItem = null;
|
|
selectedDetail = null;
|
|
activePhoto = 0;
|
|
}
|
|
|
|
const statusOptions = ['Issue', 'Needs Review', 'Pending', 'Received', 'Closed'];
|
|
|
|
function statusToReceived(status: string): string {
|
|
if (status === 'Issue') return 'Issues';
|
|
if (status === 'Needs Review') return 'Needs Review';
|
|
if (status === 'Pending') return 'Pending';
|
|
if (status === 'Closed') return 'Closed';
|
|
return new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
async function changeStatus(newStatus: string) {
|
|
if (!selectedItem) return;
|
|
const receivedValue = statusToReceived(newStatus);
|
|
await updateField('Received', receivedValue);
|
|
}
|
|
|
|
function statusColor(status: string) {
|
|
if (status === 'Issue' || status === 'Issues') return 'error';
|
|
if (status === 'Needs Review') return 'warning';
|
|
if (status === 'Received') return 'success';
|
|
if (status === 'Pending') return 'warning';
|
|
if (status === 'Closed') return 'muted';
|
|
return 'muted';
|
|
}
|
|
|
|
function formatPrice(n: number) {
|
|
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
let editingField = $state('');
|
|
let editValue = $state('');
|
|
|
|
const editableFields: Record<string, { nocoField: string; type: 'text' | 'number' }> = {
|
|
'Item': { nocoField: 'Item', type: 'text' },
|
|
'Price Per Item': { nocoField: 'Price Per Item', type: 'number' },
|
|
'Tax': { nocoField: 'Tax', type: 'number' },
|
|
'Total': { nocoField: 'Total', type: 'number' },
|
|
'QTY': { nocoField: 'QTY', type: 'number' },
|
|
'SKU': { nocoField: 'SKU', type: 'text' },
|
|
'Serial Numbers': { nocoField: 'Serial Numbers', type: 'text' },
|
|
'Order Number': { nocoField: 'Order Number', type: 'text' },
|
|
'Source': { nocoField: 'Source', type: 'text' },
|
|
'Name': { nocoField: 'Name', type: 'text' },
|
|
'Date': { nocoField: 'Date', type: 'text' },
|
|
'Tracking Number': { nocoField: 'Tracking Number', type: 'text' },
|
|
'Notes': { nocoField: 'Notes', type: 'text' },
|
|
};
|
|
|
|
function startEdit(nocoField: string, currentValue: any) {
|
|
editingField = nocoField;
|
|
editValue = currentValue != null ? String(currentValue) : '';
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (!editingField || !selectedItem) return;
|
|
const field = editableFields[editingField];
|
|
const value = field?.type === 'number' ? Number(editValue) || 0 : editValue;
|
|
await updateField(editingField, value);
|
|
editingField = '';
|
|
editValue = '';
|
|
}
|
|
|
|
function handleEditKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') saveEdit();
|
|
if (e.key === 'Escape') { editingField = ''; editValue = ''; }
|
|
}
|
|
|
|
function rawField(field: string): any {
|
|
return selectedDetail ? (selectedDetail as any)[field] : '';
|
|
}
|
|
|
|
async function duplicateItem() {
|
|
if (!selectedItem) return;
|
|
saving = true;
|
|
try {
|
|
const res = await fetch(`/api/inventory/duplicate/${selectedItem.id}`, {
|
|
method: 'POST', credentials: 'include'
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const newId = data.newId || data.id;
|
|
if (newId) loadItemDetail(newId);
|
|
}
|
|
} catch { /* silent */ }
|
|
finally { saving = false; }
|
|
}
|
|
|
|
async function sendToPhone() {
|
|
if (!selectedItem) return;
|
|
saving = true;
|
|
try {
|
|
await fetch('/api/inventory/send-to-phone', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rowId: selectedItem.id }),
|
|
credentials: 'include'
|
|
});
|
|
} catch { /* silent */ }
|
|
finally { saving = false; }
|
|
}
|
|
|
|
let fileInput: HTMLInputElement;
|
|
let uploading = $state(false);
|
|
let uploadMenuOpen = $state(false);
|
|
let immichOpen = $state(false);
|
|
let activePhoto = $state(0);
|
|
|
|
function triggerUpload() {
|
|
fileInput?.click();
|
|
}
|
|
|
|
async function handleFileSelect(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
if (!input.files?.length || !selectedItem) return;
|
|
uploading = true;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('rowId', String(selectedItem.id));
|
|
for (const file of input.files) formData.append('photos', file);
|
|
const res = await fetch('/api/inventory/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'include'
|
|
});
|
|
if (res.ok) loadItemDetail(selectedItem.id);
|
|
} catch { /* silent */ }
|
|
finally {
|
|
uploading = false;
|
|
input.value = '';
|
|
}
|
|
}
|
|
|
|
async function handleImmichSelect(assetIds: string[]) {
|
|
if (!selectedItem || !assetIds.length) return;
|
|
uploading = true;
|
|
try {
|
|
await fetch('/api/inventory/upload-from-immich', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ rowId: selectedItem.id, assetIds, deleteAfter: true })
|
|
});
|
|
loadItemDetail(selectedItem.id);
|
|
} catch { /* silent */ }
|
|
finally { uploading = false; }
|
|
}
|
|
|
|
async function createNewItem() {
|
|
saving = true;
|
|
try {
|
|
const res = await fetch('/api/inventory/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Item: 'New Item' }),
|
|
credentials: 'include'
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.id) loadItemDetail(data.id);
|
|
}
|
|
} catch { /* silent */ }
|
|
finally { saving = false; }
|
|
}
|
|
|
|
onMount(async () => {
|
|
await loadSummary();
|
|
loading = false;
|
|
const itemId = page.url.searchParams.get('item');
|
|
if (itemId) loadItemDetail(Number(itemId));
|
|
});
|
|
</script>
|
|
|
|
{#snippet editableRow(nocoField: string, displayValue: string, classes: string)}
|
|
{#if editingField === nocoField}
|
|
<div class="detail-row editing">
|
|
<span class="field-label">{nocoField}</span>
|
|
<input
|
|
class="edit-input {classes}"
|
|
type={editableFields[nocoField]?.type === 'number' ? 'number' : 'text'}
|
|
bind:value={editValue}
|
|
onkeydown={handleEditKeydown}
|
|
onblur={saveEdit}
|
|
autofocus
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="detail-row editable" onclick={() => startEdit(nocoField, rawField(nocoField))}>
|
|
<span class="field-label">{nocoField}</span>
|
|
<span class="field-value {classes}">{displayValue}</span>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<div class="page">
|
|
<div class="app-surface">
|
|
<section class="inventory-command reveal">
|
|
<div class="command-copy">
|
|
<div class="command-label">Atelier inventory</div>
|
|
<h1>Inventory</h1>
|
|
<p>Review blockers, verify arrivals, and open records without leaving the live operating queue.</p>
|
|
</div>
|
|
<div class="command-actions">
|
|
<div class="command-kicker">Live queue</div>
|
|
<div class="command-stats">{issueCount + reviewCount} active · {recentCount} recent</div>
|
|
<button class="btn-primary intro-btn" onclick={createNewItem}>New item</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="signal-strip stagger">
|
|
<button class="signal-card" class:active={activeTab === 'issues'} onclick={() => activeTab = 'issues'}>
|
|
<div class="signal-topline">
|
|
<div class="signal-label">Issues</div>
|
|
<div class="signal-value">{issueCount}</div>
|
|
</div>
|
|
<div class="signal-note">Blocked items that need a decision now.</div>
|
|
</button>
|
|
<button class="signal-card" class:active={activeTab === 'review'} onclick={() => activeTab = 'review'}>
|
|
<div class="signal-topline">
|
|
<div class="signal-label">Needs review</div>
|
|
<div class="signal-value">{reviewCount}</div>
|
|
</div>
|
|
<div class="signal-note">Arrivals waiting on confirmation, cleanup, or sorting.</div>
|
|
</button>
|
|
<button class="signal-card" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadRecent(); }}>
|
|
<div class="signal-topline">
|
|
<div class="signal-label">Recent</div>
|
|
<div class="signal-value">{recentCount}</div>
|
|
</div>
|
|
<div class="signal-note">Fresh records for context beyond the active queues.</div>
|
|
</button>
|
|
</section>
|
|
|
|
<section class="inventory-layout">
|
|
<div class="inventory-main">
|
|
<div class="toolbar-card">
|
|
<div class="toolbar-head">
|
|
<div>
|
|
<div class="toolbar-label">Lookup</div>
|
|
<div class="toolbar-title">Search and open records</div>
|
|
</div>
|
|
<div class="toolbar-meta">
|
|
{#if searchQuery && searchResults !== null}
|
|
{activeCount} result{activeCount !== 1 ? 's' : ''}
|
|
{:else}
|
|
Showing {activeCount}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-wrap">
|
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
|
<input
|
|
type="text"
|
|
class="search-input"
|
|
placeholder="Search by item name, order number, serial number, SKU..."
|
|
bind:value={searchQuery}
|
|
oninput={onSearchInput}
|
|
/>
|
|
{#if searchQuery}
|
|
<button class="search-clear" onclick={() => { searchQuery = ''; searchResults = null; }}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if searchQuery && searchResults !== null}
|
|
<div class="search-results-label">{displayedItems().length} result{displayedItems().length !== 1 ? 's' : ''} for "{searchQuery}"</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="queue-header">
|
|
<div>
|
|
<div class="queue-label">Working queue</div>
|
|
<div class="queue-title">
|
|
{#if activeTab === 'issues'}
|
|
Items with blockers
|
|
{:else if activeTab === 'review'}
|
|
Items waiting on verification
|
|
{:else}
|
|
Recent inventory records
|
|
{/if}
|
|
</div>
|
|
<div class="queue-summary">
|
|
{#if activeTab === 'issues'}
|
|
Direct triage for damaged, mismatched, or unresolved items.
|
|
{:else if activeTab === 'review'}
|
|
Validate condition, fields, and photos before records settle.
|
|
{:else}
|
|
Recent records from the live inventory feed.
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="queue-freshness">{loading ? 'Syncing…' : 'Live from inventory API'}</div>
|
|
</div>
|
|
|
|
<div class="items-card">
|
|
{#each displayedItems() as item (item.id)}
|
|
<button class="item-row" class:has-issue={item.status === 'Issue'} class:has-review={item.status === 'Needs Review'} onclick={() => openDetail(item)}>
|
|
<div class="row-accent"></div>
|
|
<div class="item-info">
|
|
<div class="item-name">{item.name}</div>
|
|
<div class="item-meta">
|
|
<span>Order #{item.order || '—'}</span>
|
|
<span>SKU {item.sku || '—'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="item-tail">
|
|
<span class="status-badge {statusColor(item.status)}">{item.status}</span>
|
|
<svg class="row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
{#if displayedItems().length === 0}
|
|
<div class="empty">No items found</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="inventory-side reveal">
|
|
<div class="side-card accent-card">
|
|
<div class="section-label">Attention</div>
|
|
<div class="side-stat">
|
|
<div class="side-value">{issueCount + reviewCount}</div>
|
|
<div class="side-note">records still need a human pass before they can disappear into archive flow</div>
|
|
</div>
|
|
</div>
|
|
<div class="side-card">
|
|
<div class="section-label">Current mode</div>
|
|
<div class="mode-panel">
|
|
<div class="mode-title">
|
|
{#if activeTab === 'issues'}
|
|
Issue queue
|
|
{:else if activeTab === 'review'}
|
|
Review queue
|
|
{:else}
|
|
Recent records
|
|
{/if}
|
|
</div>
|
|
<div class="mode-copy">
|
|
{#if activeTab === 'issues'}
|
|
Use this view when something is damaged, missing, duplicated, or just wrong.
|
|
{:else if activeTab === 'review'}
|
|
Use this view for arrivals that still need validation and cleanup.
|
|
{:else}
|
|
Use this view when you need broad recency context beyond the active blockers.
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="side-card">
|
|
<div class="section-label">Field guide</div>
|
|
<div class="side-list">
|
|
<div>Search by item, order number, serial, or SKU.</div>
|
|
<div>Open a row to change status, edit fields, or attach photos.</div>
|
|
<div>Use the inspector as the source of truth while the queue stays stable behind it.</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
{#if detailOpen && selectedItem}
|
|
<div class="detail-overlay" onclick={closeDetail}>
|
|
<div class="detail-sheet" onclick={(e) => e.stopPropagation()}>
|
|
<div class="detail-header">
|
|
{#if editingField === 'Item'}
|
|
<input class="edit-input detail-title-edit" type="text" bind:value={editValue} onkeydown={handleEditKeydown} onblur={saveEdit} autofocus />
|
|
{:else}
|
|
<div
|
|
class="detail-title editable"
|
|
class:is-empty={!selectedItem.name}
|
|
onclick={() => startEdit('Item', rawField('Item'))}
|
|
>
|
|
{selectedItem.name || 'Add item title'}
|
|
</div>
|
|
{/if}
|
|
<button class="detail-close" onclick={closeDetail}>Close</button>
|
|
</div>
|
|
|
|
<div class="status-control">
|
|
{#each statusOptions as status}
|
|
<button class="status-seg" class:active={selectedItem.status === status} data-status={status} onclick={() => changeStatus(status)}>
|
|
{status}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="detail-hero">
|
|
<div class="detail-primary-photo">
|
|
{#if selectedItem.photoUrls.length > 0}
|
|
<img class="hero-photo-img" src={selectedItem.photoUrls[activePhoto]} alt="Item photo" loading="lazy" />
|
|
{:else}
|
|
<div class="photo-placeholder empty-photo"><span>No photos yet</span></div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if selectedItem.photoUrls.length > 1}
|
|
<div class="detail-photos">
|
|
{#each selectedItem.photoUrls as url, index}
|
|
<button class="photo-thumb" class:active={activePhoto === index} onclick={() => activePhoto = index}>
|
|
<img class="photo-img" src={url} alt="Item photo" loading="lazy" />
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="actions-group">
|
|
<div class="actions-row">
|
|
<input type="file" accept="image/*" multiple class="hidden-input" bind:this={fileInput} onchange={handleFileSelect} />
|
|
<button class="action-btn primary" onclick={() => uploadMenuOpen = !uploadMenuOpen} disabled={uploading}>{uploading ? 'Uploading...' : 'Upload photos'}</button>
|
|
{#if uploadMenuOpen}
|
|
<div class="upload-menu">
|
|
<button class="upload-option" onclick={() => { uploadMenuOpen = false; triggerUpload(); }}>From device</button>
|
|
<button class="upload-option" onclick={() => { uploadMenuOpen = false; immichOpen = true; }}>From Immich</button>
|
|
</div>
|
|
{/if}
|
|
<button class="action-btn" onclick={sendToPhone}>Phone</button>
|
|
<button class="action-btn sm" onclick={duplicateItem}>Duplicate</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-body">
|
|
<div class="detail-column">
|
|
<div class="section-group">
|
|
<div class="section-label">Purchase</div>
|
|
<div class="detail-fields">
|
|
{@render editableRow('Price Per Item', formatPrice(selectedItem.price), 'mono')}
|
|
{@render editableRow('Tax', formatPrice(selectedItem.tax), 'mono')}
|
|
{@render editableRow('Total', formatPrice(selectedItem.total), 'mono strong')}
|
|
{@render editableRow('QTY', String(selectedItem.qty), '')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-group">
|
|
<div class="section-label">Notes</div>
|
|
<div class="detail-fields">
|
|
{@render editableRow('Notes', selectedItem.notes || 'Add notes...', '')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-column">
|
|
<div class="section-group">
|
|
<div class="section-label">Item info</div>
|
|
<div class="detail-fields">
|
|
{@render editableRow('SKU', selectedItem.sku || '—', 'mono')}
|
|
{@render editableRow('Serial Numbers', selectedItem.serial || '—', 'mono')}
|
|
{@render editableRow('Order Number', selectedItem.order || '—', 'mono')}
|
|
{@render editableRow('Source', selectedItem.vendor || '—', '')}
|
|
{@render editableRow('Tracking Number', selectedItem.tracking || '—', 'mono')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<a class="action-btn ghost open-noco" href={nocodbUrl || '#'} target="_blank" rel="noopener">Open in NocoDB</a>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if immichOpen}
|
|
<ImmichPicker bind:open={immichOpen} onselect={handleImmichSelect} />
|
|
{/if}
|
|
|
|
<style>
|
|
.page {
|
|
padding-top: 0;
|
|
}
|
|
.app-surface {
|
|
max-width: 1360px;
|
|
}
|
|
.inventory-command {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: end;
|
|
gap: 24px;
|
|
padding: 8px 0 18px;
|
|
}
|
|
.command-copy {
|
|
max-width: 640px;
|
|
}
|
|
.command-label {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.14em;
|
|
color: #8c7b69;
|
|
margin-bottom: 8px;
|
|
}
|
|
.command-copy h1 {
|
|
margin: 0;
|
|
font-size: clamp(2.1rem, 4.1vw, 3.5rem);
|
|
line-height: 0.94;
|
|
letter-spacing: -0.065em;
|
|
color: #17120d;
|
|
}
|
|
.command-copy p {
|
|
margin: 12px 0 0;
|
|
max-width: 46ch;
|
|
color: #5c5046;
|
|
font-size: 1rem;
|
|
line-height: 1.55;
|
|
}
|
|
.command-actions {
|
|
min-width: 220px;
|
|
display: grid;
|
|
justify-items: end;
|
|
gap: 8px;
|
|
}
|
|
.command-kicker {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.14em;
|
|
color: #8c7b69;
|
|
}
|
|
.command-stats {
|
|
font-size: 1.15rem;
|
|
letter-spacing: -0.04em;
|
|
color: #221a13;
|
|
}
|
|
.intro-btn { background: #1e1812; border-color: #1e1812; color: white; box-shadow: none; }
|
|
.intro-btn:hover { filter: none; transform: none; box-shadow: none; opacity: 0.92; }
|
|
.signal-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; }
|
|
.signal-card, .inventory-main, .side-card, .toolbar-card {
|
|
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08); background: rgba(255,252,248,0.68); backdrop-filter: blur(14px);
|
|
}
|
|
.signal-card { padding: 18px; text-align: left; transition: transform 160ms ease, background 160ms ease, border-color 160ms ease; }
|
|
.signal-card:hover { transform: translateY(-2px); background: rgba(255,255,255,0.82); }
|
|
.signal-card.active {
|
|
border-color: rgba(179,92,50,0.2);
|
|
background: linear-gradient(145deg, rgba(255,248,242,0.94), rgba(246,237,227,0.72));
|
|
}
|
|
.signal-topline {
|
|
display: flex;
|
|
align-items: end;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.signal-label, .toolbar-label, .section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #7d6f61; }
|
|
.signal-value, .side-value { font-size: clamp(1.8rem, 4vw, 2.7rem); line-height: 0.95; letter-spacing: -0.05em; color: #1e1812; }
|
|
.signal-note, .side-note, .side-list, .toolbar-meta, .item-meta { color: #4f463d; line-height: 1.6; }
|
|
.inventory-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) 290px; gap: 18px; align-items: start; }
|
|
.inventory-main { padding: 16px; background: linear-gradient(180deg, rgba(255,252,248,0.84), rgba(244,237,229,0.74)); }
|
|
.toolbar-card { padding: 16px 16px 12px; margin-bottom: 8px; background: rgba(255,255,255,0.42); }
|
|
.toolbar-head { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
.toolbar-title { margin-top: 4px; font-size: 1.1rem; letter-spacing: -0.035em; color: #1e1812; }
|
|
.inventory-side { display: grid; gap: 12px; }
|
|
.side-card { padding: 20px; }
|
|
.accent-card {
|
|
background: linear-gradient(180deg, rgba(255,248,242,0.96), rgba(240,234,227,0.84));
|
|
}
|
|
.side-stat, .side-list { margin-top: 14px; }
|
|
.queue-header {
|
|
display: flex;
|
|
align-items: end;
|
|
justify-content: space-between;
|
|
gap: 14px;
|
|
padding: 8px 4px 14px;
|
|
}
|
|
.queue-label, .queue-freshness {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
color: #7d6f61;
|
|
}
|
|
.queue-title {
|
|
margin-top: 4px;
|
|
font-size: 1.35rem;
|
|
letter-spacing: -0.04em;
|
|
color: #1e1812;
|
|
}
|
|
.queue-summary {
|
|
margin-top: 6px;
|
|
color: #6a5d50;
|
|
font-size: 0.94rem;
|
|
line-height: 1.45;
|
|
}
|
|
.search-wrap { position: relative; margin-bottom: var(--sp-4); }
|
|
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
|
|
.search-input {
|
|
width: 100%;
|
|
padding: var(--sp-3) var(--sp-10) var(--sp-3) 42px;
|
|
border-radius: var(--radius);
|
|
border: 1.5px solid rgba(35,26,17,0.12);
|
|
background: rgba(255,255,255,0.92);
|
|
color: #1e1812;
|
|
font-size: var(--text-md);
|
|
font-family: var(--font);
|
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.5);
|
|
}
|
|
.search-input::placeholder { color: #8b7b6a; }
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: rgba(179,92,50,0.5);
|
|
box-shadow: 0 0 0 4px rgba(179,92,50,0.08);
|
|
}
|
|
.search-clear {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: none;
|
|
border: none;
|
|
color: #7f7365;
|
|
}
|
|
.search-clear:hover { color: #1e1812; }
|
|
.search-results-label { color: #5f564b; font-size: 13px; }
|
|
.items-card { background: rgba(255,255,255,0.82); border-radius: 24px; border: 1px solid rgba(35,26,17,0.06); overflow: hidden; box-shadow: 0 16px 44px rgba(42,30,19,0.05); }
|
|
.item-row { display: grid; grid-template-columns: 4px minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 18px 16px; width: 100%; background: none; border: none; text-align: left; transition: background 160ms ease, transform 160ms ease; }
|
|
.item-row:hover { background: rgba(255,248,242,0.88); transform: translateX(2px); }
|
|
.item-row + .item-row { border-top: 1px solid rgba(35,26,17,0.07); }
|
|
.row-accent {
|
|
align-self: stretch;
|
|
border-radius: 999px;
|
|
background: rgba(35,26,17,0.08);
|
|
}
|
|
.item-row.has-issue .row-accent { background: linear-gradient(180deg, #ff5d45, #ef3d2f); }
|
|
.item-row.has-review .row-accent { background: linear-gradient(180deg, #f6a33a, #e28615); }
|
|
.item-info { flex: 1; min-width: 0; }
|
|
.item-name {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: #1e1812;
|
|
line-height: 1.25;
|
|
}
|
|
.item-row:hover .item-name { color: #140f0b; }
|
|
.item-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px 12px;
|
|
margin-top: 6px;
|
|
color: #5d5248;
|
|
font-size: 0.88rem;
|
|
}
|
|
.item-tail {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-left: auto;
|
|
}
|
|
.status-badge.error { background: var(--error-dim); color: var(--error); }
|
|
.status-badge.success { background: var(--success-dim); color: var(--success); }
|
|
.status-badge.warning { background: var(--warning-bg); color: var(--warning); }
|
|
.status-badge.muted { background: var(--card-hover); color: var(--text-4); }
|
|
.status-badge { font-size: var(--text-xs); font-weight: 700; padding: 5px 10px; border-radius: 999px; flex-shrink: 0; }
|
|
.row-chevron { width: 14px; height: 14px; color: #7d6f61; flex-shrink: 0; opacity: 0.7; }
|
|
.empty { color: #5f564b; }
|
|
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); }
|
|
.detail-overlay { position: fixed; inset: 0; background: rgba(17,13,10,0.42); z-index: 60; display: flex; justify-content: flex-end; backdrop-filter: blur(12px); }
|
|
.detail-sheet { width: 680px; max-width: 100%; height: 100%; background: linear-gradient(180deg, #f8f1e8 0%, #f3eadc 100%); overflow-y: auto; padding: var(--sp-7); box-shadow: -20px 0 50px rgba(18,13,10,0.16); border-left: 1px solid rgba(35,26,17,0.08); color: #1e1812; animation: sheetIn 220ms ease; }
|
|
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); margin-bottom: 16px; }
|
|
.detail-title {
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
color: #1e1812;
|
|
line-height: 1.12;
|
|
letter-spacing: -0.04em;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.detail-title.is-empty {
|
|
color: #8a7a69;
|
|
font-weight: 600;
|
|
font-style: italic;
|
|
}
|
|
.status-control { display: flex; justify-content: center; gap: 0; margin: 0 8px 22px; background: rgba(255,255,255,0.72); border: 1px solid rgba(35,26,17,0.09); border-radius: var(--radius); padding: 3px; }
|
|
.status-seg { flex: 1; padding: 8px 0; font-size: var(--text-sm); background: none; border: none; border-radius: 10px; }
|
|
.status-seg.active[data-status="Issue"] { background: var(--error-dim); color: var(--error); }
|
|
.status-seg.active[data-status="Pending"] { background: var(--warning-bg); color: var(--warning); }
|
|
.status-seg.active[data-status="Received"] { background: var(--success-dim); color: var(--success); }
|
|
.status-seg.active[data-status="Needs Review"] { background: rgba(217,119,6,0.12); color: #9a5d09; }
|
|
.status-seg.active[data-status="Closed"] { background: rgba(78, 67, 58, 0.14); color: #4f453d; }
|
|
.detail-hero {
|
|
margin-bottom: var(--sp-5);
|
|
}
|
|
.detail-primary-photo {
|
|
width: 100%;
|
|
height: 280px;
|
|
border-radius: 18px;
|
|
overflow: hidden;
|
|
background: rgba(255,255,255,0.58);
|
|
border: 1px solid rgba(35,26,17,0.08);
|
|
}
|
|
.hero-photo-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
background: var(--card-hover);
|
|
}
|
|
.detail-photos { display: flex; gap: 10px; margin-top: 10px; overflow-x: auto; }
|
|
.photo-thumb {
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
opacity: 0.7;
|
|
transition: opacity 160ms ease, transform 160ms ease;
|
|
}
|
|
.photo-thumb.active,
|
|
.photo-thumb:hover {
|
|
opacity: 1;
|
|
transform: translateY(-1px);
|
|
}
|
|
.photo-img, .photo-placeholder { width: 116px; height: 88px; border-radius: 12px; flex-shrink: 0; }
|
|
.photo-img { object-fit: cover; background: var(--card-hover); display: block; }
|
|
.photo-placeholder { background: rgba(255,255,255,0.58); border: 1px dashed rgba(35,26,17,0.13); display: flex; align-items: center; justify-content: center; }
|
|
.empty-photo { width: 100%; height: 100%; }
|
|
.actions-group { display: flex; flex-direction: column; gap: var(--sp-2); margin-bottom: var(--sp-7); }
|
|
.actions-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-2); position: relative; }
|
|
.action-btn { display: flex; align-items: center; justify-content: center; gap: 5px; padding: 8px 14px; border-radius: var(--radius-md); background: rgba(255,255,255,0.8); border: 1px solid rgba(35,26,17,0.1); font-size: var(--text-sm); color: #1e1812; }
|
|
.action-btn.primary { background: #1e1812; border-color: #1e1812; color: white; }
|
|
.action-btn.ghost { background: none; color: #6b6256; }
|
|
.action-btn.sm { padding: 6px 11px; }
|
|
.open-noco { margin-top: 8px; }
|
|
.detail-body {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
|
gap: 14px;
|
|
}
|
|
.detail-column {
|
|
display: grid;
|
|
gap: 14px;
|
|
align-content: start;
|
|
}
|
|
.section-group {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.detail-fields { background: rgba(255,255,255,0.76); border-radius: var(--radius); border: 1px solid rgba(35,26,17,0.08); overflow: hidden; }
|
|
.detail-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; }
|
|
.detail-row + .detail-row { border-top: 1px solid rgba(35,26,17,0.08); }
|
|
.field-label { font-size: var(--text-sm); color: #6b5f53; opacity: 1; }
|
|
.field-value { font-size: var(--text-base); color: #1e1812; text-align: right; }
|
|
.detail-row.editable, .detail-title.editable { cursor: pointer; }
|
|
.detail-row.editable:hover { background: rgba(255,248,242,0.62); }
|
|
.detail-title.editable:hover { color: #17120d; }
|
|
.edit-input { width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: rgba(255,255,255,0.9); color: #1e1812; }
|
|
.detail-title-edit { font-size: var(--text-lg); font-weight: 600; text-align: left; flex: 1; min-width: 0; }
|
|
.upload-menu { position: absolute; left: 0; right: 0; top: 100%; margin-top: var(--sp-1); z-index: 10; background: rgba(255,250,244,0.96); border: 1px solid rgba(35,26,17,0.09); border-radius: 10px; overflow: hidden; box-shadow: 0 10px 30px rgba(18,13,10,0.1); }
|
|
.upload-option { display: flex; align-items: center; width: 100%; padding: 12px 16px; background: none; border: none; text-align: left; color: #1e1812; }
|
|
.upload-option + .upload-option { border-top: 1px solid rgba(35,26,17,0.08); }
|
|
.hidden-input { display: none; }
|
|
.open-noco { color: #5f564b; }
|
|
.open-noco:hover { color: #1e1812; }
|
|
.mode-panel {
|
|
margin-top: 14px;
|
|
}
|
|
.mode-title {
|
|
font-size: 1.15rem;
|
|
letter-spacing: -0.04em;
|
|
color: #20170f;
|
|
}
|
|
.mode-copy {
|
|
margin-top: 8px;
|
|
color: #5f564b;
|
|
line-height: 1.55;
|
|
}
|
|
.reveal {
|
|
animation: riseIn 280ms ease;
|
|
}
|
|
.stagger .signal-card:nth-child(1) { animation: riseIn 220ms ease; }
|
|
.stagger .signal-card:nth-child(2) { animation: riseIn 260ms ease; }
|
|
.stagger .signal-card:nth-child(3) { animation: riseIn 300ms ease; }
|
|
@keyframes riseIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
@keyframes sheetIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
@media (max-width: 768px) {
|
|
.inventory-command,
|
|
.signal-strip,
|
|
.inventory-layout { grid-template-columns: 1fr; }
|
|
.inventory-command {
|
|
display: grid;
|
|
align-items: start;
|
|
gap: 14px;
|
|
}
|
|
.command-actions {
|
|
justify-items: start;
|
|
}
|
|
.detail-sheet { width: 100%; padding: var(--sp-5); }
|
|
.queue-header,
|
|
.toolbar-head { flex-direction: column; align-items: flex-start; }
|
|
.item-row { align-items: flex-start; }
|
|
.item-tail { gap: 8px; }
|
|
.detail-primary-photo {
|
|
height: 220px;
|
|
}
|
|
.detail-body {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|