<!-- src/lib/components/ItemDisplay.svelte --> <script lang="ts"> import { createEventDispatcher, onMount } from 'svelte'; import { apiClient, ApiClientError } from '$lib/apiClient'; import type { ItemPublic, ItemUpdate } from '$lib/schemas/item'; import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db'; import { processSyncQueue } from '$lib/syncService'; import { browser } from '$app/environment'; import { authStore } from '$lib/stores/authStore'; import { get } from 'svelte/store'; export let item: ItemPublic; const dispatch = createEventDispatcher<{ itemUpdated: ItemPublic; itemDeleted: number; updateError: string; }>(); // --- Component State --- let isEditing = false; let isToggling = false; let isDeleting = false; let isSavingEdit = false; let isSavingPrice = false; // State for edit form let editName = ''; let editQuantity = ''; let editPrice = ''; // Initialize editPrice when item prop changes $: if (item) { editPrice = item.price?.toString() ?? ''; if (!isEditing) { editName = item.name; editQuantity = item.quantity ?? ''; } } // --- Edit Mode --- function startEdit() { if (isEditing) return; editName = item.name; editQuantity = item.quantity ?? ''; isEditing = true; dispatch('updateError', ''); } function cancelEdit() { isEditing = false; editPrice = item.price?.toString() ?? ''; dispatch('updateError', ''); } // --- API Interactions --- async function handleToggleComplete() { if (isToggling || isEditing) return; isToggling = true; dispatch('updateError', ''); const newStatus = !item.is_complete; const updateData: ItemUpdate = { is_complete: newStatus }; const currentUserId = get(authStore).user?.id; // Optimistic DB/UI Update const optimisticItem = { ...item, is_complete: newStatus, completed_by_id: newStatus ? (currentUserId ?? item.completed_by_id ?? null) : null, updated_at: new Date().toISOString() }; try { await putItemToDb(optimisticItem); dispatch('itemUpdated', optimisticItem); } catch (dbError) { isToggling = false; return; } // Queue or Send API Call console.log(`Toggling item ${item.id} to ${newStatus}`); try { if (browser && !navigator.onLine) { console.log(`Offline: Queuing update for item ${item.id}`); await addSyncAction({ type: 'update_item', payload: { id: item.id, data: updateData }, timestamp: Date.now() }); } else { const updatedItemFromServer = await apiClient.put<ItemPublic>( `/v1/items/${item.id}`, updateData ); await putItemToDb(updatedItemFromServer); dispatch('itemUpdated', updatedItemFromServer); } if (browser && navigator.onLine) processSyncQueue(); } catch (err) { // Handle error } finally { isToggling = false; } } async function handleSaveEdit() { if (isSavingEdit) return; isSavingEdit = true; dispatch('updateError', ''); const updateData: ItemUpdate = { name: editName.trim(), quantity: editQuantity.trim() || undefined }; // Optimistic DB/UI Update const optimisticItem = { ...item, ...updateData, updated_at: new Date().toISOString() }; try { await putItemToDb(optimisticItem as any); dispatch('itemUpdated', optimisticItem as any); } catch (dbError) { isSavingEdit = false; return; } // Queue or Send API Call console.log(`Saving edits for item ${item.id}`, updateData); try { if (browser && !navigator.onLine) { await addSyncAction({ type: 'update_item', payload: { id: item.id, data: updateData }, timestamp: Date.now() }); } else { const updatedItemFromServer = await apiClient.put<ItemPublic>( `/v1/items/${item.id}`, updateData ); await putItemToDb(updatedItemFromServer); dispatch('itemUpdated', updatedItemFromServer); } if (browser && navigator.onLine) processSyncQueue(); isEditing = false; } catch (err) { // Handle error } finally { isSavingEdit = false; } } // --- Save Price Logic --- async function handleSavePrice() { if (isSavingPrice || isEditing || !item.is_complete) return; isSavingPrice = true; dispatch('updateError', ''); let newPrice: number | null = null; try { const trimmedPrice = editPrice.trim(); if (trimmedPrice === '') { newPrice = null; } else { const parsed = parseFloat(trimmedPrice); if (isNaN(parsed) || parsed < 0) { throw new Error('Invalid price: Must be a non-negative number.'); } newPrice = parseFloat(parsed.toFixed(2)); } } catch (parseError: any) { dispatch('updateError', parseError.message || 'Invalid price format.'); isSavingPrice = false; return; } if (newPrice === (item.price ?? null)) { console.log('Price unchanged, skipping save.'); isSavingPrice = false; return; } const updateData: ItemUpdate = { price: newPrice }; // Optimistic DB/UI Update const optimisticItem = { ...item, price: newPrice, updated_at: new Date().toISOString() }; try { await putItemToDb(optimisticItem); dispatch('itemUpdated', optimisticItem); } catch (dbError) { isSavingPrice = false; return; } // Queue or Send API Call console.log(`Saving price for item ${item.id}: ${newPrice}`); try { if (browser && !navigator.onLine) { console.log(`Offline: Queuing price update for item ${item.id}`); await addSyncAction({ type: 'update_item', payload: { id: item.id, data: updateData }, timestamp: Date.now() }); } else { const updatedItemFromServer = await apiClient.put<ItemPublic>( `/v1/items/${item.id}`, updateData ); await putItemToDb(updatedItemFromServer); dispatch('itemUpdated', updatedItemFromServer); editPrice = updatedItemFromServer.price?.toString() ?? ''; } if (browser && navigator.onLine) processSyncQueue(); } catch (err) { console.error(`Save price for item ${item.id} failed:`, err); const errorMsg = err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Save price failed'; dispatch('updateError', errorMsg); } finally { isSavingPrice = false; } } async function handleDelete() { // Existing delete logic } </script> <li class="flex flex-col gap-2 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50 sm:flex-row sm:items-center sm:justify-between" class:border-gray-200={!isEditing} class:border-blue-400={isEditing} class:opacity-60={item.is_complete && !isEditing} > {#if isEditing} <!-- Edit Mode Form --> <form on:submit|preventDefault={handleSaveEdit} class="flex w-full flex-grow items-center gap-2" > <!-- Name/Qty inputs, Save/Cancel buttons --> </form> {:else} <!-- Display Mode --> <div class="flex flex-grow items-center gap-3 overflow-hidden"> <input type="checkbox" checked={item.is_complete} disabled={isToggling || isDeleting} on:change={handleToggleComplete} aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}" class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50" /> <div class="flex-grow overflow-hidden"> <span class="block truncate font-medium text-gray-800" class:line-through={item.is_complete} class:text-gray-500={item.is_complete} title={item.name} > {item.name} </span> {#if item.quantity} <span class="block truncate text-sm text-gray-500" class:line-through={item.is_complete} title={item.quantity} > Qty: {item.quantity} </span> {/if} {#if item.is_complete && item.price != null} <span class="mt-1 block text-xs font-semibold text-green-700"> ${item.price.toFixed(2)} </span> {/if} </div> </div> <!-- Action Buttons & Price Input Area --> <div class="flex flex-shrink-0 items-center space-x-2"> {#if item.is_complete} <div class="flex items-center space-x-1"> <label for="price-{item.id}" class="text-sm text-gray-600">$</label> <input type="number" id="price-{item.id}" step="0.01" min="0" placeholder="Price" bind:value={editPrice} on:blur={handleSavePrice} on:keydown={(e) => { if (e.key === 'Enter') handleSavePrice(); }} class="w-24 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" disabled={isSavingPrice} aria-label="Item price" /> {#if isSavingPrice} <span class="animate-pulse text-xs text-gray-500">...</span> {/if} </div> {/if} <button on:click={startEdit} class="..." title="Edit Item" disabled={isToggling || isDeleting} > ✏️ </button> <button on:click={handleDelete} class="..." title="Delete Item" disabled={isToggling || isDeleting} > {#if isDeleting}⏳{:else}🗑️{/if} </button> </div> {/if} </li>