899 lines
29 KiB
Vue
899 lines
29 KiB
Vue
<template>
|
|
<main class="container page-padding">
|
|
<div v-if="loading" class="text-center">
|
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
|
<p>Loading list details...</p>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
|
<div class="alert-content">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-alert-triangle" />
|
|
</svg>
|
|
{{ error }}
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button>
|
|
</div>
|
|
|
|
<template v-else-if="list">
|
|
<div class="flex justify-between items-center flex-wrap mb-2">
|
|
<h1>{{ list.name }}</h1>
|
|
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
|
|
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
|
|
:class="{ 'feature-offline-disabled': !isOnline }"
|
|
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
|
|
<svg class="icon icon-sm">
|
|
<use xlink:href="#icon-clipboard" />
|
|
</svg>
|
|
Cost Summary
|
|
</button>
|
|
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
|
|
:class="{ 'feature-offline-disabled': !isOnline }"
|
|
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
|
|
<svg class="icon icon-sm">
|
|
<use xlink:href="#icon-plus" />
|
|
</svg>
|
|
Add via OCR
|
|
</button>
|
|
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
|
{{ list.is_complete ? 'Complete' : 'Active' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Item Form -->
|
|
<form @submit.prevent="onAddItem" class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="flex items-end flex-wrap" style="gap: 1rem;">
|
|
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
|
<label for="newItemName" class="form-label">Item Name</label>
|
|
<input type="text" id="newItemName" v-model="newItem.name" class="form-input" required
|
|
ref="itemNameInputRef" />
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0; min-width: 120px;">
|
|
<label for="newItemQuantity" class="form-label">Quantity</label>
|
|
<input type="number" id="newItemQuantity" v-model="newItem.quantity" class="form-input" min="1" />
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" :disabled="addingItem">
|
|
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
|
Add Item
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Items List -->
|
|
<div v-if="list.items.length === 0" class="card empty-state-card">
|
|
<svg class="icon icon-lg" aria-hidden="true">
|
|
<use xlink:href="#icon-clipboard" />
|
|
</svg>
|
|
<h3>No Items Yet!</h3>
|
|
<p>This list is empty. Add some items using the form above.</p>
|
|
</div>
|
|
|
|
<ul v-else class="item-list">
|
|
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
|
|
'completed': item.is_complete,
|
|
'is-swiped': item.swiped,
|
|
'offline-item': isItemPendingSync(item),
|
|
'synced': !isItemPendingSync(item)
|
|
}" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
|
|
<div class="list-item-content">
|
|
<div class="list-item-main">
|
|
<label class="checkbox-label mb-0 flex-shrink-0">
|
|
<input type="checkbox" :checked="item.is_complete"
|
|
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
|
|
:disabled="item.updating" :aria-label="item.name" />
|
|
<span class="checkmark"></span>
|
|
</label>
|
|
<div class="item-text flex-grow">
|
|
<span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span>
|
|
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small>
|
|
<div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;">
|
|
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
|
|
<input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
|
|
class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
|
|
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="list-item-actions">
|
|
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
|
|
:disabled="item.deleting" aria-label="Delete item">
|
|
<svg class="icon icon-sm">
|
|
<use xlink:href="#icon-trash"></use>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
|
|
<!-- OCR Dialog -->
|
|
<div v-if="showOcrDialogState" class="modal-backdrop open" @click.self="closeOcrDialog">
|
|
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
|
|
<div class="modal-header">
|
|
<h3>Add Items via OCR</h3>
|
|
<button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon">
|
|
<use xlink:href="#icon-close" />
|
|
</svg></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div v-if="ocrLoading" class="text-center">
|
|
<div class="spinner-dots"><span /><span /><span /></div>
|
|
<p>Processing image...</p>
|
|
</div>
|
|
<div v-else-if="ocrItems.length > 0">
|
|
<p class="mb-2">Review Extracted Items:</p>
|
|
<ul class="item-list">
|
|
<li v-for="(ocrItem, index) in ocrItems" :key="index" class="list-item">
|
|
<div class="list-item-content flex items-center" style="gap: 0.5rem;">
|
|
<input type="text" v-model="ocrItem.name" class="form-input flex-grow" required />
|
|
<button class="btn btn-danger btn-sm btn-icon-only" @click="ocrItems.splice(index, 1)">
|
|
<svg class="icon icon-sm">
|
|
<use xlink:href="#icon-trash" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div v-else class="form-group">
|
|
<label for="ocrFile" class="form-label">Upload Image</label>
|
|
<input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload"
|
|
ref="ocrFileInputRef" />
|
|
<p v-if="ocrError" class="form-error-text mt-1">{{ ocrError }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button>
|
|
<button v-if="ocrItems.length > 0" type="button" class="btn btn-primary ml-2" @click="addOcrItems"
|
|
:disabled="addingOcrItems">
|
|
<span v-if="addingOcrItems" class="spinner-dots-sm"><span /><span /><span /></span>
|
|
Add Items
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirmation Dialog -->
|
|
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
|
|
<div class="modal-container confirm-modal" ref="confirmModalRef">
|
|
<div class="modal-header">
|
|
<h3>Confirmation</h3>
|
|
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
|
|
<use xlink:href="#icon-close" />
|
|
</svg></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
|
|
<use xlink:href="#icon-alert-triangle" />
|
|
</svg>
|
|
<p>{{ confirmDialogMessage }}</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
|
|
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cost Summary Dialog -->
|
|
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
|
|
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
|
|
<div class="modal-header">
|
|
<h3>List Cost Summary</h3>
|
|
<button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon">
|
|
<use xlink:href="#icon-close" />
|
|
</svg></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div v-if="costSummaryLoading" class="text-center">
|
|
<div class="spinner-dots"><span /><span /><span /></div>
|
|
<p>Loading summary...</p>
|
|
</div>
|
|
<div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div>
|
|
<div v-else-if="listCostSummary">
|
|
<div class="mb-3 cost-overview">
|
|
<p><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
|
|
<p><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
|
|
<p><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</p>
|
|
</div>
|
|
<h4>User Balances</h4>
|
|
<div class="table-container mt-2">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th class="text-right">Items Added Value</th>
|
|
<th class="text-right">Amount Due</th>
|
|
<th class="text-right">Balance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="userShare in listCostSummary.user_balances" :key="userShare.user_id">
|
|
<td>{{ userShare.user_identifier }}</td>
|
|
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
|
|
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
|
|
<td class="text-right">
|
|
<span class="item-badge"
|
|
:class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
|
|
{{ formatCurrency(userShare.balance) }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<p v-else>No cost summary available.</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-primary" @click="showCostSummaryDialog = false">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
|
|
import { useRoute } from 'vue-router';
|
|
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
|
import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
|
|
import { useNotificationStore } from '@/stores/notifications';
|
|
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
|
|
|
interface Item {
|
|
id: number;
|
|
name: string;
|
|
quantity?: string | undefined | number; // Allow number for input binding
|
|
is_complete: boolean;
|
|
price?: number | null;
|
|
version: number;
|
|
updating?: boolean;
|
|
updated_at: string;
|
|
deleting?: boolean;
|
|
// For UI state
|
|
priceInput?: string | number | null; // Separate for input binding due to potential nulls/strings
|
|
swiped?: boolean; // For swipe UI
|
|
}
|
|
|
|
interface List {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
is_complete: boolean;
|
|
items: Item[];
|
|
version: number;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface UserCostShare {
|
|
user_id: number;
|
|
user_identifier: string;
|
|
items_added_value: string | number;
|
|
amount_due: string | number;
|
|
balance: string | number;
|
|
}
|
|
|
|
interface ListCostSummaryData {
|
|
list_id: number;
|
|
list_name: string;
|
|
total_list_cost: string | number;
|
|
num_participating_users: number;
|
|
equal_share_per_user: string | number;
|
|
user_balances: UserCostShare[];
|
|
}
|
|
|
|
const route = useRoute();
|
|
const { isOnline } = useNetwork();
|
|
const notificationStore = useNotificationStore();
|
|
const offlineStore = useOfflineStore();
|
|
const list = ref<List | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const addingItem = ref(false);
|
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
|
const lastListUpdate = ref<string | null>(null);
|
|
const lastItemUpdate = ref<string | null>(null);
|
|
|
|
const newItem = ref<{ name: string; quantity?: string | number }>({ name: '' });
|
|
const itemNameInputRef = ref<HTMLInputElement | null>(null);
|
|
|
|
// OCR
|
|
const showOcrDialogState = ref(false);
|
|
const ocrModalRef = ref<HTMLElement | null>(null);
|
|
const ocrLoading = ref(false);
|
|
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
|
|
const addingOcrItems = ref(false);
|
|
const ocrError = ref<string | null>(null);
|
|
const ocrFileInputRef = ref<HTMLInputElement | null>(null);
|
|
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
|
|
accept: 'image/*',
|
|
multiple: false,
|
|
});
|
|
|
|
|
|
// Confirmation Dialog
|
|
const showConfirmDialogState = ref(false);
|
|
const confirmModalRef = ref<HTMLElement | null>(null);
|
|
const confirmDialogMessage = ref('');
|
|
const pendingAction = ref<(() => Promise<void>) | null>(null);
|
|
|
|
// Cost Summary
|
|
const showCostSummaryDialog = ref(false);
|
|
const costSummaryModalRef = ref<HTMLElement | null>(null);
|
|
const listCostSummary = ref<ListCostSummaryData | null>(null);
|
|
const costSummaryLoading = ref(false);
|
|
const costSummaryError = ref<string | null>(null);
|
|
|
|
|
|
onClickOutside(ocrModalRef, () => { showOcrDialogState.value = false; });
|
|
onClickOutside(costSummaryModalRef, () => { showCostSummaryDialog.value = false; });
|
|
onClickOutside(confirmModalRef, () => { showConfirmDialogState.value = false; pendingAction.value = null; });
|
|
|
|
|
|
const formatCurrency = (value: string | number | undefined | null): string => {
|
|
if (value === undefined || value === null) return '$0.00';
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
|
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
|
|
};
|
|
|
|
const processListItems = (items: Item[]): Item[] => {
|
|
return items.map(item => ({
|
|
...item,
|
|
priceInput: item.price !== null && item.price !== undefined ? item.price : ''
|
|
}));
|
|
};
|
|
|
|
const fetchListDetails = async () => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(route.params.id)));
|
|
const rawList = response.data as List;
|
|
rawList.items = processListItems(rawList.items);
|
|
list.value = rawList;
|
|
|
|
lastListUpdate.value = rawList.updated_at;
|
|
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
|
|
return item.updated_at > latest ? item.updated_at : latest;
|
|
}, '');
|
|
|
|
if (showCostSummaryDialog.value) { // If dialog is open, refresh its data
|
|
await fetchListCostSummary();
|
|
}
|
|
|
|
} catch (err: unknown) {
|
|
error.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const checkForUpdates = async () => {
|
|
if (!list.value) return;
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
|
|
const { updated_at: newListUpdatedAt, items: newItems } = response.data as List;
|
|
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, '');
|
|
|
|
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
|
|
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
|
|
await fetchListDetails();
|
|
}
|
|
} catch (err) {
|
|
console.warn('Polling for updates failed:', err);
|
|
}
|
|
};
|
|
|
|
const startPolling = () => {
|
|
stopPolling();
|
|
pollingInterval.value = setInterval(() => checkForUpdates(), 15000);
|
|
};
|
|
const stopPolling = () => {
|
|
if (pollingInterval.value) clearInterval(pollingInterval.value);
|
|
};
|
|
|
|
const isItemPendingSync = (item: Item) => {
|
|
return offlineStore.pendingActions.some(action => {
|
|
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
|
|
const payload = action.payload as { listId: string; itemId: string };
|
|
return payload.itemId === String(item.id);
|
|
}
|
|
return false;
|
|
});
|
|
};
|
|
|
|
const onAddItem = async () => {
|
|
if (!list.value || !newItem.value.name.trim()) {
|
|
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
|
|
itemNameInputRef.value?.focus();
|
|
return;
|
|
}
|
|
addingItem.value = true;
|
|
|
|
if (!isOnline.value) {
|
|
// Add to offline queue
|
|
offlineStore.addAction({
|
|
type: 'create_list_item',
|
|
payload: {
|
|
listId: String(list.value.id),
|
|
itemData: {
|
|
name: newItem.value.name,
|
|
quantity: newItem.value.quantity?.toString()
|
|
}
|
|
}
|
|
});
|
|
// Optimistically add to UI
|
|
const optimisticItem: Item = {
|
|
id: Date.now(), // Temporary ID
|
|
name: newItem.value.name,
|
|
quantity: newItem.value.quantity,
|
|
is_complete: false,
|
|
version: 1,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
list.value.items.push(processListItems([optimisticItem])[0]);
|
|
newItem.value = { name: '' };
|
|
itemNameInputRef.value?.focus();
|
|
addingItem.value = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.post(
|
|
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
|
{ name: newItem.value.name, quantity: newItem.value.quantity?.toString() }
|
|
);
|
|
const addedItem = response.data as Item;
|
|
list.value.items.push(processListItems([addedItem])[0]);
|
|
newItem.value = { name: '' };
|
|
itemNameInputRef.value?.focus();
|
|
} catch (err) {
|
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add item.', type: 'error' });
|
|
} finally {
|
|
addingItem.value = false;
|
|
}
|
|
};
|
|
|
|
const updateItem = async (item: Item, newCompleteStatus: boolean) => {
|
|
if (!list.value) return;
|
|
item.updating = true;
|
|
const originalCompleteStatus = item.is_complete;
|
|
item.is_complete = newCompleteStatus; // Optimistic update
|
|
|
|
if (!isOnline.value) {
|
|
// Add to offline queue
|
|
offlineStore.addAction({
|
|
type: 'update_list_item',
|
|
payload: {
|
|
listId: String(list.value.id),
|
|
itemId: String(item.id),
|
|
data: {
|
|
completed: newCompleteStatus
|
|
},
|
|
version: item.version
|
|
}
|
|
});
|
|
item.updating = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiClient.put(
|
|
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
|
{ completed: newCompleteStatus, version: item.version }
|
|
);
|
|
item.version++;
|
|
} catch (err) {
|
|
item.is_complete = originalCompleteStatus; // Revert on error
|
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
|
|
} finally {
|
|
item.updating = false;
|
|
}
|
|
};
|
|
|
|
const updateItemPrice = async (item: Item) => {
|
|
if (!list.value || !item.is_complete) return;
|
|
|
|
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
|
|
if (item.price === newPrice) return;
|
|
|
|
item.updating = true;
|
|
const originalPrice = item.price;
|
|
const originalPriceInput = item.priceInput;
|
|
|
|
item.price = newPrice;
|
|
|
|
if (!isOnline.value) {
|
|
// Add to offline queue
|
|
offlineStore.addAction({
|
|
type: 'update_list_item',
|
|
payload: {
|
|
listId: String(list.value.id),
|
|
itemId: String(item.id),
|
|
data: {
|
|
price: newPrice,
|
|
completed: item.is_complete
|
|
},
|
|
version: item.version
|
|
}
|
|
});
|
|
item.updating = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiClient.put(
|
|
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
|
{ price: newPrice, completed: item.is_complete, version: item.version }
|
|
);
|
|
item.version++;
|
|
} catch (err) {
|
|
item.price = originalPrice;
|
|
item.priceInput = originalPriceInput;
|
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
|
|
} finally {
|
|
item.updating = false;
|
|
}
|
|
};
|
|
|
|
const deleteItem = async (item: Item) => {
|
|
if (!list.value) return;
|
|
item.deleting = true;
|
|
|
|
if (!isOnline.value) {
|
|
// Add to offline queue
|
|
offlineStore.addAction({
|
|
type: 'delete_list_item',
|
|
payload: {
|
|
listId: String(list.value.id),
|
|
itemId: String(item.id)
|
|
}
|
|
});
|
|
// Optimistically remove from UI
|
|
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
|
item.deleting = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
|
|
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
|
} catch (err) {
|
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
|
|
} finally {
|
|
item.deleting = false;
|
|
}
|
|
};
|
|
|
|
// Confirmation dialog logic
|
|
const confirmUpdateItem = (item: Item, newCompleteStatus: boolean) => {
|
|
confirmDialogMessage.value = `Mark "${item.name}" as ${newCompleteStatus ? 'complete' : 'incomplete'}?`;
|
|
pendingAction.value = () => updateItem(item, newCompleteStatus);
|
|
showConfirmDialogState.value = true;
|
|
};
|
|
|
|
const confirmDeleteItem = (item: Item) => {
|
|
confirmDialogMessage.value = `Delete "${item.name}"? This cannot be undone.`;
|
|
pendingAction.value = () => deleteItem(item);
|
|
showConfirmDialogState.value = true;
|
|
};
|
|
|
|
const handleConfirmedAction = async () => {
|
|
if (pendingAction.value) {
|
|
await pendingAction.value();
|
|
}
|
|
cancelConfirmation();
|
|
};
|
|
const cancelConfirmation = () => {
|
|
showConfirmDialogState.value = false;
|
|
pendingAction.value = null;
|
|
};
|
|
|
|
|
|
// OCR Functionality
|
|
const openOcrDialog = () => {
|
|
ocrItems.value = [];
|
|
ocrError.value = null;
|
|
resetOcrFileDialog(); // Clear previous file selection from useFileDialog
|
|
showOcrDialogState.value = true;
|
|
nextTick(() => {
|
|
if (ocrFileInputRef.value) {
|
|
ocrFileInputRef.value.value = ''; // Manually clear input type=file
|
|
}
|
|
});
|
|
};
|
|
const closeOcrDialog = () => {
|
|
showOcrDialogState.value = false;
|
|
ocrItems.value = [];
|
|
ocrError.value = null;
|
|
};
|
|
|
|
watch(ocrFiles, async (newFiles) => {
|
|
if (newFiles && newFiles.length > 0) {
|
|
const file = newFiles[0];
|
|
await handleOcrUpload(file);
|
|
}
|
|
});
|
|
|
|
const handleOcrFileUpload = (event: Event) => {
|
|
const target = event.target as HTMLInputElement;
|
|
if (target.files && target.files.length > 0) {
|
|
handleOcrUpload(target.files[0]);
|
|
}
|
|
};
|
|
|
|
const handleOcrUpload = async (file: File) => {
|
|
if (!file) return;
|
|
ocrLoading.value = true;
|
|
ocrError.value = null;
|
|
ocrItems.value = [];
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('image_file', file);
|
|
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
|
|
if (ocrItems.value.length === 0) {
|
|
ocrError.value = "No items extracted from the image.";
|
|
}
|
|
} catch (err) {
|
|
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
|
|
} finally {
|
|
ocrLoading.value = false;
|
|
if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; // Reset file input
|
|
}
|
|
};
|
|
|
|
const addOcrItems = async () => {
|
|
if (!list.value || !ocrItems.value.length) return;
|
|
addingOcrItems.value = true;
|
|
let successCount = 0;
|
|
try {
|
|
for (const item of ocrItems.value) {
|
|
if (!item.name.trim()) continue;
|
|
const response = await apiClient.post(
|
|
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
|
{ name: item.name, quantity: "1" } // Default quantity
|
|
);
|
|
const addedItem = response.data as Item;
|
|
list.value.items.push(processListItems([addedItem])[0]);
|
|
successCount++;
|
|
}
|
|
if (successCount > 0) notificationStore.addNotification({ message: `${successCount} item(s) added successfully from OCR.`, type: 'success' });
|
|
closeOcrDialog();
|
|
} catch (err) {
|
|
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add OCR items.', type: 'error' });
|
|
} finally {
|
|
addingOcrItems.value = false;
|
|
}
|
|
};
|
|
|
|
// Cost Summary
|
|
const fetchListCostSummary = async () => {
|
|
if (!list.value || list.value.id === 0) return;
|
|
costSummaryLoading.value = true;
|
|
costSummaryError.value = null;
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
|
|
listCostSummary.value = response.data;
|
|
} catch (err) {
|
|
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load cost summary.';
|
|
listCostSummary.value = null;
|
|
} finally {
|
|
costSummaryLoading.value = false;
|
|
}
|
|
};
|
|
watch(showCostSummaryDialog, (newVal) => {
|
|
if (newVal && (!listCostSummary.value || listCostSummary.value.list_id !== list.value?.id)) {
|
|
fetchListCostSummary();
|
|
}
|
|
});
|
|
|
|
|
|
// Keyboard shortcut
|
|
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
|
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
// Check if a modal is open or if focus is already in an input/textarea
|
|
const activeElement = document.activeElement;
|
|
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
|
|
return;
|
|
}
|
|
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
itemNameInputRef.value?.focus();
|
|
}
|
|
});
|
|
|
|
// Swipe detection (basic)
|
|
let touchStartX = 0;
|
|
const SWIPE_THRESHOLD = 50; // pixels
|
|
|
|
const handleTouchStart = (event: TouchEvent) => {
|
|
touchStartX = event.changedTouches[0].clientX;
|
|
// Add class for visual feedback during swipe if desired
|
|
};
|
|
|
|
const handleTouchMove = () => {
|
|
// Can be used for interactive swipe effect
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
// Not implemented: Valerie UI swipe example implies JS adds/removes 'is-swiped'
|
|
// For a simple demo, one might toggle it here based on a more complex gesture
|
|
// This would require more state per item and logic
|
|
// For now, swipe actions are not visually implemented
|
|
};
|
|
|
|
|
|
onMounted(() => {
|
|
if (!route.params.id) {
|
|
error.value = 'No list ID provided';
|
|
loading.value = false;
|
|
return;
|
|
}
|
|
fetchListDetails().then(() => {
|
|
startPolling();
|
|
});
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopPolling();
|
|
});
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-padding {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.mb-1 {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.mb-2 {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.mb-3 {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.mt-1 {
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.mt-2 {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.ml-1 {
|
|
margin-left: 0.25rem;
|
|
}
|
|
|
|
.ml-2 {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.text-right {
|
|
text-align: right;
|
|
}
|
|
|
|
.flex-grow {
|
|
flex-grow: 1;
|
|
}
|
|
|
|
.item-caption {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
opacity: 0.6;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.text-decoration-line-through {
|
|
text-decoration: line-through;
|
|
}
|
|
|
|
.form-input-sm {
|
|
/* For price input */
|
|
padding: 0.4rem 0.6rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.cost-overview p {
|
|
margin-bottom: 0.5rem;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.form-error-text {
|
|
color: var(--danger);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.list-item.completed .item-text {
|
|
/* text-decoration: line-through; is handled by span class */
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.list-item-actions {
|
|
margin-left: auto;
|
|
/* Pushes actions to the right */
|
|
padding-left: 1rem;
|
|
/* Space before actions */
|
|
}
|
|
|
|
.offline-item {
|
|
position: relative;
|
|
opacity: 0.8;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.offline-item::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0.5rem;
|
|
right: 0.5rem;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
|
|
background-size: contain;
|
|
background-repeat: no-repeat;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.offline-item.synced {
|
|
opacity: 1;
|
|
}
|
|
|
|
.offline-item.synced::after {
|
|
display: none;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.feature-offline-disabled {
|
|
position: relative;
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.feature-offline-disabled::before {
|
|
content: attr(data-tooltip);
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 0.5rem;
|
|
background-color: var(--bg-color-tooltip, #333);
|
|
color: white;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
white-space: nowrap;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: all 0.2s ease;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.feature-offline-disabled:hover::before {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
</style> |