1252 lines
34 KiB
Vue
1252 lines
34 KiB
Vue
<template>
|
||
<main class="neo-container page-padding">
|
||
<div v-if="loading" class="neo-loading-state">
|
||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||
<p>Loading list...</p>
|
||
</div>
|
||
|
||
<div v-else-if="error" class="neo-error-state">
|
||
<svg class="icon" aria-hidden="true">
|
||
<use xlink:href="#icon-alert-triangle" />
|
||
</svg>
|
||
{{ error }}
|
||
<button class="neo-button" @click="fetchListDetails">Retry</button>
|
||
</div>
|
||
|
||
<template v-else-if="list">
|
||
<!-- Header -->
|
||
<div class="neo-list-header">
|
||
<h1 class="neo-title mb-3">{{ list.name }}</h1>
|
||
<div class="neo-header-actions">
|
||
<button class="neo-action-button" @click="showCostSummaryDialog = true"
|
||
:class="{ 'neo-disabled': !isOnline }">
|
||
<svg class="icon">
|
||
<use xlink:href="#icon-clipboard" />
|
||
</svg> Cost Summary
|
||
</button>
|
||
<button class="neo-action-button" @click="openOcrDialog" :class="{ 'neo-disabled': !isOnline }">
|
||
<svg class="icon">
|
||
<use xlink:href="#icon-plus" />
|
||
</svg> Add via OCR
|
||
</button>
|
||
<div class="neo-status" :class="list.is_complete ? 'neo-status-complete' : 'neo-status-active'">
|
||
<span v-if="list.group_id">Group List</span>
|
||
<span v-else>Personal List</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
|
||
|
||
<!-- Items List -->
|
||
<div v-if="list.items.length === 0" class="neo-empty-state">
|
||
<svg class="icon icon-lg" aria-hidden="true">
|
||
<use xlink:href="#icon-clipboard" />
|
||
</svg>
|
||
<h3>No Items Yet!</h3>
|
||
<p>Add some items using the form below.</p>
|
||
</div>
|
||
|
||
<div v-else class="neo-list-card">
|
||
<ul class="neo-item-list">
|
||
<li v-for="item in list.items" :key="item.id" class="neo-item"
|
||
:class="{ 'neo-item-complete': item.is_complete }">
|
||
<div class="neo-item-content">
|
||
<label class="neo-checkbox-label">
|
||
<input type="checkbox" :checked="item.is_complete"
|
||
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
|
||
:disabled="item.updating" :aria-label="item.name" />
|
||
<span class="neo-checkmark"></span>
|
||
</label>
|
||
<div class="neo-item-details">
|
||
<span class="neo-item-name">{{ item.name }}</span>
|
||
<span v-if="item.quantity" class="neo-item-quantity">× {{ item.quantity }}</span>
|
||
<div v-if="item.is_complete" class="neo-price-input">
|
||
<input type="number" v-model.number="item.priceInput" class="neo-number-input" placeholder="Price"
|
||
step="0.01" @blur="updateItemPrice(item)"
|
||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
||
</div>
|
||
</div>
|
||
<div class="neo-item-actions">
|
||
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
|
||
<svg class="icon">
|
||
<use xlink:href="#icon-edit"></use>
|
||
</svg>
|
||
</button>
|
||
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
|
||
:disabled="item.deleting" aria-label="Delete item">
|
||
<svg class="icon">
|
||
<use xlink:href="#icon-trash"></use>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
<li class="neo-item new-item-input">
|
||
<form @submit.prevent="onAddItem" class="neo-checkbox-label neo-new-item-form">
|
||
<input type="checkbox" disabled />
|
||
<input type="text" v-model="newItem.name" class="neo-new-item-input" placeholder="Add a new item" required
|
||
ref="itemNameInputRef" />
|
||
<input type="number" v-model="newItem.quantity" class="neo-quantity-input" placeholder="Qty" min="1" />
|
||
<button type="submit" class="neo-add-button" :disabled="addingItem">
|
||
<span v-if="addingItem" class="spinner-dots-sm"><span /><span /><span /></span>
|
||
<span v-else>Add</span>
|
||
</button>
|
||
</form>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</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; group_id?: number; }
|
||
|
||
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();
|
||
});
|
||
|
||
// Add after deleteItem function
|
||
const editItem = (item: Item) => {
|
||
// For now, just simulate editing by toggling name and adding "(Edited)" when clicked
|
||
// In a real implementation, you would show a modal or inline form
|
||
if (!item.name.includes('(Edited)')) {
|
||
item.name += ' (Edited)';
|
||
}
|
||
// Placeholder for future edit functionality
|
||
notificationStore.addNotification({
|
||
message: 'Edit functionality would show here (modal or inline form)',
|
||
type: 'info'
|
||
});
|
||
};
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.neo-container {
|
||
padding: 1rem;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-padding {
|
||
padding: 1rem;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.mb-3 {
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.neo-loading-state,
|
||
.neo-error-state,
|
||
.neo-empty-state {
|
||
text-align: center;
|
||
padding: 3rem 1rem;
|
||
margin: 2rem 0;
|
||
border: 3px solid #111;
|
||
border-radius: 18px;
|
||
background: #fff;
|
||
box-shadow: 6px 6px 0 #111;
|
||
}
|
||
|
||
.neo-error-state {
|
||
border-color: #e74c3c;
|
||
}
|
||
|
||
.neo-list-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.neo-title {
|
||
font-size: 2.5rem;
|
||
font-weight: 900;
|
||
margin: 0;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.neo-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.neo-description {
|
||
font-size: 1.2rem;
|
||
margin-bottom: 2rem;
|
||
color: #555;
|
||
}
|
||
|
||
.neo-status {
|
||
font-weight: 900;
|
||
font-size: 1rem;
|
||
padding: 0.4rem 1rem;
|
||
border: 3px solid #111;
|
||
border-radius: 50px;
|
||
background: var(--light);
|
||
box-shadow: 3px 3px 0 #111;
|
||
}
|
||
|
||
.neo-status-active {
|
||
background: #f7f7d4;
|
||
}
|
||
|
||
.neo-status-complete {
|
||
background: #d4f7dd;
|
||
}
|
||
|
||
.neo-list-card {
|
||
break-inside: avoid;
|
||
border-radius: 18px;
|
||
box-shadow: 6px 6px 0 #111;
|
||
width: 100%;
|
||
margin: 0 0 2rem 0;
|
||
background: var(--light);
|
||
display: flex;
|
||
flex-direction: column;
|
||
cursor: pointer;
|
||
border: 3px solid #111;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.neo-item-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0 0 2rem 0;
|
||
break-inside: avoid;
|
||
width: 100%;
|
||
background: var(--light);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.neo-item {
|
||
padding: 1.2rem;
|
||
margin-bottom: 0;
|
||
border-bottom: 1px solid #eee;
|
||
background: var(--light);
|
||
transition: background-color 0.1s ease-in-out;
|
||
}
|
||
|
||
.neo-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.neo-item:hover {
|
||
background-color: #f9f9f9;
|
||
}
|
||
|
||
.neo-item-complete {
|
||
background: #f9f9f9;
|
||
}
|
||
|
||
.neo-item-content {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.neo-checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.7em;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.neo-checkbox-label input[type="checkbox"] {
|
||
width: 1.2em;
|
||
height: 1.2em;
|
||
accent-color: #111;
|
||
border: 2px solid #111;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.neo-item-details {
|
||
flex-grow: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.neo-item-name {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.neo-item-complete .neo-item-name {
|
||
text-decoration: line-through;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.neo-item-quantity {
|
||
font-size: 0.9rem;
|
||
color: #555;
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
.neo-price-input {
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.neo-item-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.neo-icon-button {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0.5rem;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.neo-edit-button {
|
||
color: #3498db;
|
||
}
|
||
|
||
.neo-edit-button:hover {
|
||
background: #eef7fd;
|
||
}
|
||
|
||
.neo-delete-button {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: #e74c3c;
|
||
padding: 0.5rem;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-left: 0;
|
||
}
|
||
|
||
.neo-delete-button:hover {
|
||
background: #fee;
|
||
}
|
||
|
||
.neo-actions {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.neo-action-button {
|
||
background: #fff;
|
||
border: 3px solid #111;
|
||
border-radius: 8px;
|
||
padding: 0.6rem 1rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
box-shadow: 3px 3px 0 #111;
|
||
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
|
||
}
|
||
|
||
.neo-action-button:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 3px 5px 0 #111;
|
||
}
|
||
|
||
.neo-action-button .icon {
|
||
width: 1.2rem;
|
||
height: 1.2rem;
|
||
}
|
||
|
||
.neo-disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.neo-add-item-form {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 2rem;
|
||
border: 3px solid #111;
|
||
border-radius: 12px;
|
||
padding: 1rem;
|
||
background: #f9f9f9;
|
||
box-shadow: 4px 4px 0 #111;
|
||
}
|
||
|
||
.neo-new-item-form {
|
||
width: 100%;
|
||
gap: 10px;
|
||
}
|
||
|
||
.neo-text-input {
|
||
flex-grow: 1;
|
||
border: 2px solid #111;
|
||
border-radius: 8px;
|
||
padding: 0.8rem;
|
||
font-size: 1.1rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.neo-new-item-input {
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
all: unset;
|
||
width: 100%;
|
||
font-size: 1.1rem;
|
||
font-weight: 500;
|
||
color: #444;
|
||
flex-grow: 1;
|
||
}
|
||
|
||
.neo-new-item-input::placeholder {
|
||
color: #999;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.neo-quantity-input {
|
||
width: 80px;
|
||
border: 2px solid #111;
|
||
border-radius: 8px;
|
||
padding: 0.4rem;
|
||
font-size: 1rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.neo-number-input {
|
||
border: 2px solid #111;
|
||
border-radius: 6px;
|
||
padding: 0.5rem;
|
||
font-size: 1rem;
|
||
width: 100px;
|
||
}
|
||
|
||
.neo-add-button {
|
||
background: #111;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0 1rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
min-width: 60px;
|
||
height: 2rem;
|
||
}
|
||
|
||
.neo-button {
|
||
background: #111;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0.8rem 1.5rem;
|
||
font-weight: 700;
|
||
margin-top: 1rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.new-item-input {
|
||
margin-top: 0.5rem;
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
/* Responsive adjustments */
|
||
@media (max-width: 900px) {
|
||
.neo-container {
|
||
padding: 0.8rem;
|
||
}
|
||
|
||
.neo-title {
|
||
font-size: 1.8rem;
|
||
}
|
||
|
||
.neo-item {
|
||
padding: 1rem;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.neo-container {
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
.neo-header-actions {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.neo-title {
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
.neo-item-name {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.neo-add-item-form {
|
||
flex-direction: column;
|
||
padding: 0.8rem;
|
||
}
|
||
|
||
.neo-quantity-input {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.modal-backdrop {
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal-container {
|
||
background: white;
|
||
border-radius: 18px;
|
||
border: 3px solid #111;
|
||
box-shadow: 6px 6px 0 #111;
|
||
width: 90%;
|
||
max-width: 500px;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
padding: 0;
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 1.5rem;
|
||
border-bottom: 1px solid #eee;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 1.5rem;
|
||
border-top: 1px solid #eee;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.close-button {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: #666;
|
||
}
|
||
|
||
.item-badge {
|
||
display: inline-block;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 16px;
|
||
font-weight: 700;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.badge-settled {
|
||
background-color: #d4f7dd;
|
||
color: #2c784c;
|
||
}
|
||
|
||
.badge-pending {
|
||
background-color: #ffe1d6;
|
||
color: #c64600;
|
||
}
|
||
|
||
.text-right {
|
||
text-align: right;
|
||
}
|
||
|
||
.text-center {
|
||
text-align: center;
|
||
}
|
||
|
||
.spinner-dots {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.3rem;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.spinner-dots span {
|
||
width: 8px;
|
||
height: 8px;
|
||
background-color: #555;
|
||
border-radius: 50%;
|
||
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||
}
|
||
|
||
.spinner-dots-sm {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
}
|
||
|
||
.spinner-dots-sm span {
|
||
width: 4px;
|
||
height: 4px;
|
||
background-color: white;
|
||
border-radius: 50%;
|
||
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||
}
|
||
|
||
.spinner-dots span:nth-child(1),
|
||
.spinner-dots-sm span:nth-child(1) {
|
||
animation-delay: -0.32s;
|
||
}
|
||
|
||
.spinner-dots span:nth-child(2),
|
||
.spinner-dots-sm span:nth-child(2) {
|
||
animation-delay: -0.16s;
|
||
}
|
||
|
||
@keyframes dot-pulse {
|
||
|
||
0%,
|
||
80%,
|
||
100% {
|
||
transform: scale(0);
|
||
}
|
||
|
||
40% {
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
</style> |