Files
platform/frontend-v2/src/lib/pages/inventory/AtelierInventoryPage.svelte
Yusuf Suleman 2072c359aa feat: wire brain service to platform gateway
- 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>
2026-04-01 16:32:53 -05:00

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>