339 lines
8.9 KiB
Svelte
339 lines
8.9 KiB
Svelte
<!-- 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>
|