mitlist/fe/src/lib/components/ItemDisplay.svelte
2025-04-03 01:24:23 +02:00

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>