560 lines
16 KiB
Vue
560 lines
16 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<div v-if="loading" class="text-center">
|
|
<q-spinner-dots color="primary" size="2em" />
|
|
<p>Loading list details...</p>
|
|
</div>
|
|
|
|
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md">
|
|
<template v-slot:avatar>
|
|
<q-icon name="warning" />
|
|
</template>
|
|
{{ error }}
|
|
<template v-slot:action>
|
|
<q-btn flat color="white" label="Retry" @click="fetchListDetails" />
|
|
</template>
|
|
</q-banner>
|
|
|
|
<template v-else>
|
|
<div class="row items-center q-mb-md">
|
|
<h1 class="text-h4 q-mb-none">{{ list.name }}</h1>
|
|
<q-space />
|
|
<q-btn
|
|
color="secondary"
|
|
icon="camera_alt"
|
|
label="Add via OCR"
|
|
class="q-mr-sm"
|
|
@click="showOcrDialog = true"
|
|
/>
|
|
<q-badge
|
|
:color="list.is_complete ? 'green' : 'orange'"
|
|
:label="list.is_complete ? 'Complete' : 'Active'"
|
|
/>
|
|
</div>
|
|
|
|
<!-- OCR Dialog -->
|
|
<q-dialog v-model="showOcrDialog">
|
|
<q-card style="min-width: 350px">
|
|
<q-card-section>
|
|
<div class="text-h6">Add Items via OCR</div>
|
|
</q-card-section>
|
|
|
|
<q-card-section v-if="!ocrItems.length">
|
|
<q-file
|
|
v-model="ocrFile"
|
|
label="Upload Image"
|
|
accept="image/*"
|
|
outlined
|
|
@update:model-value="handleOcrUpload"
|
|
>
|
|
<template v-slot:prepend>
|
|
<q-icon name="attach_file" />
|
|
</template>
|
|
</q-file>
|
|
<q-inner-loading :showing="ocrLoading">
|
|
<q-spinner-dots size="50px" color="primary" />
|
|
</q-inner-loading>
|
|
</q-card-section>
|
|
|
|
<q-card-section v-else>
|
|
<div class="text-subtitle2 q-mb-sm">Review Extracted Items</div>
|
|
<q-list bordered separator>
|
|
<q-item v-for="(item, index) in ocrItems" :key="index">
|
|
<q-item-section>
|
|
<q-input
|
|
v-model="item.name"
|
|
outlined
|
|
dense
|
|
:rules="[(val) => !!val || 'Name is required']"
|
|
/>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<q-btn
|
|
flat
|
|
round
|
|
dense
|
|
icon="delete"
|
|
color="negative"
|
|
@click="ocrItems.splice(index, 1)"
|
|
/>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right">
|
|
<q-btn flat label="Cancel" color="primary" v-close-popup />
|
|
<q-btn
|
|
v-if="ocrItems.length"
|
|
flat
|
|
label="Add Items"
|
|
color="primary"
|
|
@click="addOcrItems"
|
|
:loading="addingOcrItems"
|
|
/>
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Add Item Form -->
|
|
<q-form @submit="onAddItem" class="q-mb-lg">
|
|
<div class="row q-col-gutter-md">
|
|
<div class="col-12 col-md-6">
|
|
<q-input
|
|
v-model="newItem.name"
|
|
label="Item Name"
|
|
:rules="[(val) => !!val || 'Name is required']"
|
|
outlined
|
|
ref="itemNameInput"
|
|
@keydown.enter.prevent="onAddItem"
|
|
/>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<q-input
|
|
v-model.number="newItem.quantity"
|
|
type="number"
|
|
label="Quantity (optional)"
|
|
outlined
|
|
min="1"
|
|
@keydown.enter.prevent="onAddItem"
|
|
/>
|
|
</div>
|
|
<div class="col-12 col-md-2">
|
|
<q-btn
|
|
type="submit"
|
|
color="primary"
|
|
label="Add"
|
|
class="full-width"
|
|
:loading="addingItem"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</q-form>
|
|
|
|
<!-- Items List -->
|
|
<div v-if="list.items.length === 0" class="text-center q-pa-md">
|
|
<p>No items in this list yet. Add some items above!</p>
|
|
</div>
|
|
|
|
<q-list v-else bordered separator>
|
|
<q-item
|
|
v-for="item in list.items"
|
|
:key="item.id"
|
|
:class="{ 'text-strike': item.is_complete }"
|
|
>
|
|
<q-item-section avatar>
|
|
<q-checkbox
|
|
v-model="item.is_complete"
|
|
@update:model-value="confirmUpdateItem(item)"
|
|
:loading="item.updating"
|
|
:disable="item.updating"
|
|
/>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ item.name }}</q-item-label>
|
|
<q-item-label caption v-if="item.quantity">
|
|
Quantity: {{ item.quantity }}
|
|
</q-item-label>
|
|
<!-- Add Price Input Field when item is complete -->
|
|
<q-input
|
|
v-if="item.is_complete"
|
|
v-model.number="item.price"
|
|
type="number"
|
|
label="Price"
|
|
dense
|
|
outlined
|
|
step="0.01"
|
|
prefix="$"
|
|
class="q-mt-sm"
|
|
style="max-width: 150px"
|
|
@keydown.enter.prevent="($event.target as HTMLInputElement)?.blur()"
|
|
/>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<q-btn
|
|
flat
|
|
round
|
|
dense
|
|
icon="delete"
|
|
color="negative"
|
|
@click="confirmDeleteItem(item)"
|
|
:loading="item.deleting"
|
|
:disable="item.deleting"
|
|
/>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</template>
|
|
|
|
<!-- Confirmation Dialog -->
|
|
<q-dialog v-model="showConfirmDialog" persistent>
|
|
<q-card>
|
|
<q-card-section class="row items-center">
|
|
<q-avatar icon="warning" color="warning" text-color="white" />
|
|
<span class="q-ml-sm">{{ confirmDialogMessage }}</span>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right">
|
|
<q-btn flat label="Cancel" color="primary" v-close-popup />
|
|
<q-btn flat label="Confirm" color="primary" @click="handleConfirmedAction" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from 'vue';
|
|
import { useRoute } from 'vue-router';
|
|
import { apiClient, API_ENDPOINTS } from 'src/config/api';
|
|
import { useQuasar, QFile } from 'quasar';
|
|
|
|
interface Item {
|
|
id: number;
|
|
name: string;
|
|
quantity?: string | undefined;
|
|
is_complete: boolean;
|
|
price?: number | null;
|
|
version: number;
|
|
updating?: boolean;
|
|
updated_at: string;
|
|
deleting?: boolean;
|
|
}
|
|
|
|
interface List {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
is_complete: boolean;
|
|
items: Item[];
|
|
version: number;
|
|
updated_at: string;
|
|
}
|
|
|
|
const route = useRoute();
|
|
const $q = useQuasar();
|
|
|
|
const list = ref<List>({
|
|
id: 0,
|
|
name: '',
|
|
items: [],
|
|
is_complete: false,
|
|
version: 0,
|
|
updated_at: '',
|
|
});
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const addingItem = ref(false);
|
|
const pollingInterval = ref<NodeJS.Timeout | null>(null);
|
|
const lastListUpdate = ref<string | null>(null);
|
|
const lastItemUpdate = ref<string | null>(null);
|
|
|
|
const newItem = ref<{ name: string; quantity?: string }>({ name: '' });
|
|
|
|
// OCR related state
|
|
const showOcrDialog = ref(false);
|
|
const ocrFile = ref<File | null>(null);
|
|
const ocrLoading = ref(false);
|
|
const ocrItems = ref<{ name: string }[]>([]);
|
|
const addingOcrItems = ref(false);
|
|
const ocrError = ref<string | null>(null);
|
|
|
|
// Add new refs for confirmation dialog
|
|
const showConfirmDialog = ref(false);
|
|
const confirmDialogMessage = ref('');
|
|
const pendingAction = ref<(() => Promise<void>) | null>(null);
|
|
|
|
// Add ref for item name input
|
|
const itemNameInput = ref<{ focus: () => void } | null>(null);
|
|
|
|
const fetchListDetails = async () => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
console.log('Fetching list details for ID:', route.params.id);
|
|
try {
|
|
const response = await apiClient.get(
|
|
API_ENDPOINTS.LISTS.BY_ID(String(route.params.id))
|
|
);
|
|
console.log('List details response:', response.data);
|
|
list.value = response.data as List;
|
|
lastListUpdate.value = response.data.updated_at;
|
|
// Find the latest item update time
|
|
lastItemUpdate.value = response.data.items.reduce((latest: string, item: Item) => {
|
|
return item.updated_at > latest ? item.updated_at : latest;
|
|
}, '');
|
|
} catch (err: unknown) {
|
|
console.error('Failed to fetch list details:', err);
|
|
error.value =
|
|
(err as Error).message ||
|
|
'Failed to load list details. Please try again.';
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: error.value,
|
|
});
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const checkForUpdates = async () => {
|
|
try {
|
|
const response = await apiClient.get(
|
|
API_ENDPOINTS.LISTS.BY_ID(String(route.params.id))
|
|
);
|
|
const { updated_at: list_updated_at } = response.data as List;
|
|
const latest_item_updated_at = response.data.items.reduce((latest: string, item: Item) => {
|
|
return item.updated_at > latest ? item.updated_at : latest;
|
|
}, '');
|
|
|
|
// If either the list or any item has been updated, refresh the data
|
|
if (
|
|
(lastListUpdate.value && list_updated_at > lastListUpdate.value) ||
|
|
(lastItemUpdate.value && latest_item_updated_at > lastItemUpdate.value)
|
|
) {
|
|
await fetchListDetails();
|
|
}
|
|
} catch (err: unknown) {
|
|
console.error('Failed to check for updates:', err);
|
|
// Don't show error to user for polling failures
|
|
}
|
|
};
|
|
|
|
const startPolling = () => {
|
|
// Poll every 15 seconds
|
|
pollingInterval.value = setInterval(() => void checkForUpdates(), 15000);
|
|
};
|
|
|
|
const stopPolling = () => {
|
|
if (pollingInterval.value) {
|
|
clearInterval(pollingInterval.value);
|
|
pollingInterval.value = null;
|
|
}
|
|
};
|
|
|
|
const onAddItem = async () => {
|
|
if (!newItem.value.name) {
|
|
$q.notify({
|
|
type: 'warning',
|
|
message: 'Please enter an item name',
|
|
});
|
|
return;
|
|
}
|
|
|
|
addingItem.value = true;
|
|
try {
|
|
const response = await apiClient.post(
|
|
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
|
{
|
|
name: newItem.value.name,
|
|
quantity: newItem.value.quantity?.toString()
|
|
}
|
|
);
|
|
list.value.items.push(response.data as Item);
|
|
newItem.value = { name: '' };
|
|
// Focus the input for the next item
|
|
itemNameInput.value?.focus();
|
|
} catch (err: unknown) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (err as Error).message || 'Failed to add item',
|
|
});
|
|
} finally {
|
|
addingItem.value = false;
|
|
}
|
|
};
|
|
|
|
const updateItem = async (item: Item) => {
|
|
item.updating = true;
|
|
try {
|
|
// Use the new endpoint for updating a specific item
|
|
const response = await apiClient.put(
|
|
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)),
|
|
{
|
|
// Send only relevant fields for this update action
|
|
is_complete: item.is_complete,
|
|
// Ensure price is a number or null. If item.price is undefined, treat as null.
|
|
price: (item.price === undefined || item.price === null) ? null : Number(item.price),
|
|
version: item.version,
|
|
}
|
|
);
|
|
Object.assign(item, response.data as Item);
|
|
// Ensure the price is a number locally after update, or null
|
|
if (response.data.price !== null && response.data.price !== undefined) {
|
|
item.price = Number(response.data.price);
|
|
} else {
|
|
item.price = null;
|
|
}
|
|
} catch (err: unknown) {
|
|
if ((err as { response?: { status?: number } }).response?.status === 409) {
|
|
$q.notify({
|
|
type: 'warning',
|
|
message: 'This item was modified elsewhere. Please refresh the page.',
|
|
});
|
|
// Revert the checkbox state
|
|
item.is_complete = !item.is_complete;
|
|
} else {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (err as Error).message || 'Failed to update item',
|
|
});
|
|
// Revert the checkbox state
|
|
item.is_complete = !item.is_complete;
|
|
}
|
|
} finally {
|
|
item.updating = false;
|
|
}
|
|
};
|
|
|
|
const handleOcrUpload = async (file: File | null) => {
|
|
if (!file) return;
|
|
|
|
ocrLoading.value = true;
|
|
ocrError.value = null;
|
|
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'
|
|
}
|
|
}
|
|
);
|
|
// Transform the array of strings into an array of objects { name: string }
|
|
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr }));
|
|
showOcrDialog.value = true;
|
|
} catch (err: unknown) {
|
|
ocrError.value = (err as Error).message || 'Failed to process image';
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: ocrError.value,
|
|
});
|
|
} finally {
|
|
ocrLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const addOcrItems = async () => {
|
|
if (!ocrItems.value.length) return;
|
|
|
|
addingOcrItems.value = true;
|
|
try {
|
|
for (const item of ocrItems.value) {
|
|
if (!item.name) continue;
|
|
|
|
const response = await apiClient.post(
|
|
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
|
{ name: item.name, quantity: "1" }
|
|
);
|
|
list.value.items.push(response.data as Item);
|
|
}
|
|
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: 'Items added successfully',
|
|
});
|
|
|
|
showOcrDialog.value = false;
|
|
ocrItems.value = [];
|
|
ocrFile.value = null;
|
|
} catch (err: unknown) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (err as { response?: { data?: { detail?: string } } }).response?.data?.detail || 'Failed to add items',
|
|
});
|
|
} finally {
|
|
addingOcrItems.value = false;
|
|
}
|
|
};
|
|
|
|
// Add keyboard shortcut handler
|
|
const handleKeyPress = (event: KeyboardEvent) => {
|
|
// Focus item name input when pressing 'n'
|
|
if (event.key === 'n' && !event.ctrlKey && !event.metaKey) {
|
|
event.preventDefault();
|
|
itemNameInput.value?.focus();
|
|
}
|
|
};
|
|
|
|
// Add confirmation dialog handlers
|
|
const confirmUpdateItem = (item: Item) => {
|
|
confirmDialogMessage.value = `Are you sure you want to mark "${item.name}" as ${item.is_complete ? 'complete' : 'incomplete'}?`;
|
|
pendingAction.value = () => updateItem(item);
|
|
showConfirmDialog.value = true;
|
|
};
|
|
|
|
const confirmDeleteItem = (item: Item) => {
|
|
confirmDialogMessage.value = `Are you sure you want to delete "${item.name}"?`;
|
|
pendingAction.value = () => deleteItem(item);
|
|
showConfirmDialog.value = true;
|
|
};
|
|
|
|
const handleConfirmedAction = async () => {
|
|
if (pendingAction.value) {
|
|
await pendingAction.value();
|
|
pendingAction.value = null;
|
|
}
|
|
showConfirmDialog.value = false;
|
|
};
|
|
|
|
// Add delete item function
|
|
const deleteItem = async (item: Item) => {
|
|
item.deleting = true;
|
|
try {
|
|
await apiClient.delete(
|
|
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id))
|
|
);
|
|
const index = list.value.items.findIndex((i) => i.id === item.id);
|
|
if (index !== -1) {
|
|
list.value.items.splice(index, 1);
|
|
}
|
|
} catch (err: unknown) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (err as Error).message || 'Failed to delete item',
|
|
});
|
|
} finally {
|
|
item.deleting = false;
|
|
}
|
|
};
|
|
|
|
// Add keyboard event listeners
|
|
onMounted(() => {
|
|
console.log('Component mounted, route params:', route.params);
|
|
if (!route.params.id) {
|
|
console.error('No list ID found in route params');
|
|
error.value = 'No list ID provided';
|
|
return;
|
|
}
|
|
void fetchListDetails().then(() => {
|
|
startPolling();
|
|
});
|
|
window.addEventListener('keydown', handleKeyPress);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopPolling();
|
|
window.removeEventListener('keydown', handleKeyPress);
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.text-strike {
|
|
text-decoration: line-through;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* Add transition for item updates */
|
|
.q-item {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.q-item.updating {
|
|
opacity: 0.7;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|