mitlist/fe/src/pages/ListDetailPage.vue

414 lines
11 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
/>
</div>
<div class="col-12 col-md-4">
<q-input
v-model.number="newItem.quantity"
type="number"
label="Quantity (optional)"
outlined
min="1"
/>
</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="updateItem(item)"
:loading="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>
</q-item-section>
</q-item>
</q-list>
</template>
</q-page>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from 'boot/axios';
import { useQuasar, QFile } from 'quasar';
interface Item {
id: number;
name: string;
quantity?: number | undefined;
is_complete: boolean;
version: number;
updating?: boolean;
updated_at: string;
}
interface List {
id: number;
name: string;
description?: string;
is_complete: boolean;
items: Item[];
version: number;
updated_at: string;
}
interface ListStatus {
list_updated_at: string;
latest_item_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<number | undefined>(undefined);
const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null);
const newItem = ref<{ name: string; quantity?: number }>({ name: '' });
const editingItemName = ref('');
const editingItemQuantity = ref<number | undefined>(undefined);
// 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);
const fetchListDetails = async () => {
loading.value = true;
error.value = null;
try {
const response = await api.get<List>(
`/api/v1/lists/${String(route.params.id)}`
);
list.value = response.data;
lastListUpdate.value = response.data.updated_at;
// Find the latest item update time
lastItemUpdate.value = response.data.items.reduce((latest, 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 api.get<ListStatus>(
`/api/v1/lists/${String(route.params.id)}/status`
);
const { list_updated_at, latest_item_updated_at } = response.data;
// 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 = window.setInterval(() => { void checkForUpdates(); }, 15000);
};
const stopPolling = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = undefined;
}
};
const onAddItem = async () => {
if (!newItem.value.name) return;
addingItem.value = true;
try {
const response = await api.post<Item>(
`/api/v1/lists/${list.value.id}/items`,
newItem.value
);
list.value.items.push(response.data);
newItem.value = { name: '' };
} 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 {
const response = await api.put<Item>(
`/api/v1/lists/${list.value.id}/items/${item.id}`,
{
name: editingItemName.value,
quantity: editingItemQuantity.value,
completed: item.is_complete,
version: item.version,
}
);
Object.assign(item, response.data);
} 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('file', file);
const response = await api.post<{ items: string[] }>(
`/api/v1/lists/${list.value.id}/ocr`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
ocrItems.value = response.data.items.map((name) => ({ name }));
} catch (err: unknown) {
$q.notify({
type: 'negative',
message: (err as { response?: { data?: { detail?: string } } }).response?.data?.detail || 'Failed to process image',
});
ocrError.value = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail || 'Failed to process image';
} 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 api.post<Item>(
`/api/v1/lists/${list.value.id}/items`,
{ name: item.name, quantity: 1 }
);
list.value.items.push(response.data);
}
$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;
}
};
onMounted(() => {
void fetchListDetails().then(() => {
startPolling();
});
});
onUnmounted(() => {
stopPolling();
});
</script>
<style scoped>
.text-strike {
text-decoration: line-through;
opacity: 0.7;
}
</style>