Add CreateListModal and ListDetailPage components; enhance ListsPage with loading/error states and group filtering
This commit is contained in:
parent
7b2c5c9ebd
commit
5186892df6
102
fe/src/components/CreateListModal.vue
Normal file
102
fe/src/components/CreateListModal.vue
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<q-dialog v-model="isOpen" persistent>
|
||||||
|
<q-card style="min-width: 350px">
|
||||||
|
<q-card-section class="row items-center">
|
||||||
|
<div class="text-h6">Create New List</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
v-model="listName"
|
||||||
|
label="List Name"
|
||||||
|
:rules="[(val) => !!val || 'Name is required']"
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input v-model="description" label="Description" type="textarea" outlined />
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-model="selectedGroup"
|
||||||
|
:options="groups"
|
||||||
|
label="Associate with Group (Optional)"
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="row justify-end q-mt-md">
|
||||||
|
<q-btn label="Cancel" color="grey" flat v-close-popup />
|
||||||
|
<q-btn label="Create" type="submit" color="primary" class="q-ml-sm" />
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { api } from 'src/boot/axios';
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
groups?: Array<{ label: string; value: number }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void;
|
||||||
|
(e: 'created'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOpen = ref(props.modelValue);
|
||||||
|
const listName = ref('');
|
||||||
|
const description = ref('');
|
||||||
|
const selectedGroup = ref(null);
|
||||||
|
|
||||||
|
// Watch for modelValue changes
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
isOpen.value = newVal;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watch for isOpen changes
|
||||||
|
watch(isOpen, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/v1/lists', {
|
||||||
|
name: listName.value,
|
||||||
|
description: description.value,
|
||||||
|
groupId: selectedGroup.value?.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'List created successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
listName.value = '';
|
||||||
|
description.value = '';
|
||||||
|
selectedGroup.value = null;
|
||||||
|
|
||||||
|
// Close modal and emit created event
|
||||||
|
isOpen.value = false;
|
||||||
|
emit('created');
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Failed to create list',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -67,16 +67,10 @@ const fetchGroupDetails = async () => {
|
|||||||
if (!groupId.value) return;
|
if (!groupId.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// console.log(
|
|
||||||
// `TODO: Implement API call to fetch group details for group ID: ${groupId.value} from /api/v1/groups/{group_id}`,
|
|
||||||
// );
|
|
||||||
const response = await api.get(`/api/v1/groups/${groupId.value}`, {
|
const response = await api.get(`/api/v1/groups/${groupId.value}`, {
|
||||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
});
|
});
|
||||||
group.value = response.data;
|
group.value = response.data;
|
||||||
|
|
||||||
// Mock data for now
|
|
||||||
// group.value = { id: groupId.value, name: `Sample Group ${groupId.value}` };
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error fetching group details:', error);
|
console.error('Error fetching group details:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
@ -84,7 +78,6 @@ const fetchGroupDetails = async () => {
|
|||||||
message: error.response?.data?.detail || 'Failed to fetch group details.',
|
message: error.response?.data?.detail || 'Failed to fetch group details.',
|
||||||
icon: 'report_problem',
|
icon: 'report_problem',
|
||||||
});
|
});
|
||||||
// Handle error (e.g., show notification, redirect)
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -93,9 +86,8 @@ const fetchGroupDetails = async () => {
|
|||||||
const generateInviteCode = async () => {
|
const generateInviteCode = async () => {
|
||||||
if (!groupId.value) return;
|
if (!groupId.value) return;
|
||||||
generatingInvite.value = true;
|
generatingInvite.value = true;
|
||||||
inviteCode.value = null; // Reset previous code
|
inviteCode.value = null;
|
||||||
try {
|
try {
|
||||||
// console.log(`TODO: Implement API call to POST /api/v1/groups/${groupId.value}/invites`);
|
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`/api/v1/groups/${groupId.value}/invites`,
|
`/api/v1/groups/${groupId.value}/invites`,
|
||||||
{},
|
{},
|
||||||
@ -103,10 +95,7 @@ const generateInviteCode = async () => {
|
|||||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
inviteCode.value = response.data.invite_code; // Assuming API returns { invite_code: 'XXXXX' }
|
inviteCode.value = response.data.invite_code;
|
||||||
|
|
||||||
// Mock data for now
|
|
||||||
// inviteCode.value = `INVITE_${groupId.value}_${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
message: 'Invite code generated successfully!',
|
message: 'Invite code generated successfully!',
|
||||||
@ -119,7 +108,6 @@ const generateInviteCode = async () => {
|
|||||||
message: error.response?.data?.detail || 'Failed to generate invite code.',
|
message: error.response?.data?.detail || 'Failed to generate invite code.',
|
||||||
icon: 'report_problem',
|
icon: 'report_problem',
|
||||||
});
|
});
|
||||||
// Handle error
|
|
||||||
} finally {
|
} finally {
|
||||||
generatingInvite.value = false;
|
generatingInvite.value = false;
|
||||||
}
|
}
|
||||||
@ -130,11 +118,10 @@ const copyInviteCode = () => {
|
|||||||
copyToClipboard(inviteCode.value)
|
copyToClipboard(inviteCode.value)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
copySuccess.value = true;
|
copySuccess.value = true;
|
||||||
setTimeout(() => (copySuccess.value = false), 2000); // Hide message after 2s
|
setTimeout(() => (copySuccess.value = false), 2000);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.error('Failed to copy invite code');
|
console.error('Failed to copy invite code');
|
||||||
// Handle copy error (e.g., show a notification)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -89,7 +89,7 @@ import { ref, onMounted } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { QInput, useQuasar } from 'quasar'; // Import QInput for type reference
|
import { QInput, useQuasar } from 'quasar'; // Import QInput for type reference
|
||||||
import { api } from 'boot/axios'; // Assuming you have an axios instance set up
|
import { api } from 'boot/axios'; // Assuming you have an axios instance set up
|
||||||
import { useAuthStore } from 'stores/auth'; // If needed for auth token
|
import { useAuthStore } from 'stores/auth';
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
id: string; // or number, depending on your API
|
id: string; // or number, depending on your API
|
||||||
@ -98,7 +98,7 @@ interface Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore(); // If needed
|
const authStore = useAuthStore();
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
|
|
||||||
const groups = ref<Group[]>([]);
|
const groups = ref<Group[]>([]);
|
||||||
@ -106,27 +106,19 @@ const loading = ref(false);
|
|||||||
const showCreateGroupModal = ref(false);
|
const showCreateGroupModal = ref(false);
|
||||||
const newGroupName = ref('');
|
const newGroupName = ref('');
|
||||||
const creatingGroup = ref(false);
|
const creatingGroup = ref(false);
|
||||||
const newGroupNameInput = ref<any>(null); // For focusing and validation
|
const newGroupNameInput = ref<any>(null);
|
||||||
|
|
||||||
// For Join Group
|
|
||||||
const inviteCodeToJoin = ref('');
|
const inviteCodeToJoin = ref('');
|
||||||
const joiningGroup = ref(false);
|
const joiningGroup = ref(false);
|
||||||
const joinInviteCodeInput = ref<QInput | null>(null);
|
const joinInviteCodeInput = ref<QInput | null>(null);
|
||||||
|
|
||||||
// Fetch groups from API
|
|
||||||
const fetchGroups = async () => {
|
const fetchGroups = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/v1/groups', {
|
const response = await api.get('/api/v1/groups', {
|
||||||
headers: { Authorization: `Bearer ${authStore.token}` }, // If auth is needed
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
});
|
});
|
||||||
groups.value = response.data;
|
groups.value = response.data;
|
||||||
// console.log('TODO: Implement API call to fetch groups /api/v1/groups');
|
|
||||||
// Mock data for now:
|
|
||||||
// groups.value = [
|
|
||||||
// { id: '1', name: 'First Group' },
|
|
||||||
// { id: '2', name: 'Second Group' },
|
|
||||||
// ];
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error fetching groups:', error);
|
console.error('Error fetching groups:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
@ -149,12 +141,9 @@ const handleCreateGroup = async () => {
|
|||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
'/api/v1/groups',
|
'/api/v1/groups',
|
||||||
{ name: newGroupName.value },
|
{ name: newGroupName.value },
|
||||||
{ headers: { Authorization: `Bearer ${authStore.token}` } }, // If auth is needed
|
{ headers: { Authorization: `Bearer ${authStore.token}` } },
|
||||||
);
|
);
|
||||||
groups.value.push(response.data); // Add new group to the list
|
groups.value.push(response.data); // Add new group to the list
|
||||||
// console.log('TODO: Implement API call to POST /api/v1/groups with name:', newGroupName.value);
|
|
||||||
// Mock adding group
|
|
||||||
// groups.value.push({ id: String(Date.now()), name: newGroupName.value });
|
|
||||||
showCreateGroupModal.value = false;
|
showCreateGroupModal.value = false;
|
||||||
newGroupName.value = '';
|
newGroupName.value = '';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
@ -181,16 +170,11 @@ const handleJoinGroup = async () => {
|
|||||||
}
|
}
|
||||||
joiningGroup.value = true;
|
joiningGroup.value = true;
|
||||||
try {
|
try {
|
||||||
// console.log(
|
|
||||||
// 'TODO: Implement API call to POST /api/v1/invites/accept with code:',
|
|
||||||
// inviteCodeToJoin.value,
|
|
||||||
// );
|
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
'/api/v1/invites/accept',
|
'/api/v1/invites/accept',
|
||||||
{ invite_code: inviteCodeToJoin.value }, // Ensure schema matches backend (invite_code vs code)
|
{ invite_code: inviteCodeToJoin.value },
|
||||||
{ headers: { Authorization: `Bearer ${authStore.token}` } }, // If auth is needed
|
{ headers: { Authorization: `Bearer ${authStore.token}` } },
|
||||||
);
|
);
|
||||||
// On success, refresh the list of groups and clear input
|
|
||||||
await fetchGroups();
|
await fetchGroups();
|
||||||
inviteCodeToJoin.value = '';
|
inviteCodeToJoin.value = '';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
@ -198,9 +182,6 @@ const handleJoinGroup = async () => {
|
|||||||
message: response.data.detail || 'Successfully joined group!',
|
message: response.data.detail || 'Successfully joined group!',
|
||||||
icon: 'check_circle',
|
icon: 'check_circle',
|
||||||
});
|
});
|
||||||
// console.log('Successfully joined group (mock). Refreshing groups...');
|
|
||||||
// await fetchGroups(); // Refresh groups after mock join
|
|
||||||
// inviteCodeToJoin.value = '';
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error joining group:', error);
|
console.error('Error joining group:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
@ -215,9 +196,7 @@ const handleJoinGroup = async () => {
|
|||||||
|
|
||||||
const selectGroup = (group: Group) => {
|
const selectGroup = (group: Group) => {
|
||||||
console.log('Selected group:', group);
|
console.log('Selected group:', group);
|
||||||
// For MVP, just displaying the group name and having it as context for lists is enough.
|
router.push(`/groups/${group.id}`);
|
||||||
router.push(`/groups/${group.id}`); // Navigate to group detail page
|
|
||||||
// console.log('TODO: Implement navigation to group detail page /groups/:id or handle selection');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
399
fe/src/pages/ListDetailPage.vue
Normal file
399
fe/src/pages/ListDetailPage.vue
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
<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, useRouter } from 'vue-router';
|
||||||
|
import { api } from 'boot/axios';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
quantity?: number;
|
||||||
|
is_complete: boolean;
|
||||||
|
version: number;
|
||||||
|
updating?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 router = useRouter();
|
||||||
|
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 | null>(null);
|
||||||
|
const lastListUpdate = ref<string>('');
|
||||||
|
const lastItemUpdate = ref<string>('');
|
||||||
|
|
||||||
|
const newItem = ref({
|
||||||
|
name: '',
|
||||||
|
quantity: undefined as number | 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 fetchListDetails = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await api.get<List>(`/api/v1/lists/${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: any) {
|
||||||
|
console.error('Failed to fetch list details:', err);
|
||||||
|
error.value =
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
err.message ||
|
||||||
|
'An unexpected error occurred while fetching list details.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<ListStatus>(`/api/v1/lists/${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 (
|
||||||
|
list_updated_at !== lastListUpdate.value ||
|
||||||
|
latest_item_updated_at !== lastItemUpdate.value
|
||||||
|
) {
|
||||||
|
console.log('Changes detected, refreshing list data...');
|
||||||
|
await fetchListDetails();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
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(checkForUpdates, 15000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollingInterval.value) {
|
||||||
|
clearInterval(pollingInterval.value);
|
||||||
|
pollingInterval.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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`, {
|
||||||
|
name: newItem.value.name,
|
||||||
|
quantity: newItem.value.quantity,
|
||||||
|
});
|
||||||
|
list.value.items.push(response.data);
|
||||||
|
newItem.value = { name: '', quantity: undefined };
|
||||||
|
} catch (err: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: err.response?.data?.detail || '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/items/${item.id}`, {
|
||||||
|
is_complete: item.is_complete,
|
||||||
|
version: item.version,
|
||||||
|
});
|
||||||
|
Object.assign(item, response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.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.response?.data?.detail || '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;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await api.post<{ items: string[] }>('/api/v1/ocr/extract-items', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ocrItems.value = response.data.items.map((name) => ({ name }));
|
||||||
|
} catch (err: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: err.response?.data?.detail || 'Failed to process image',
|
||||||
|
});
|
||||||
|
ocrFile.value = null;
|
||||||
|
} 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,
|
||||||
|
});
|
||||||
|
list.value.items.push(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Items added successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
showOcrDialog.value = false;
|
||||||
|
ocrItems.value = [];
|
||||||
|
ocrFile.value = null;
|
||||||
|
} catch (err: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: err.response?.data?.detail || 'Failed to add items',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
addingOcrItems.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchListDetails().then(() => {
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-strike {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,10 +1,156 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<h1 class="text-h4 q-mb-md">Lists</h1>
|
<h1 class="text-h4 q-mb-md">{{ pageTitle }}</h1>
|
||||||
<p>Your lists will appear here.</p>
|
|
||||||
|
<div v-if="loading" class="text-center">
|
||||||
|
<q-spinner-dots color="primary" size="2em" />
|
||||||
|
<p>Loading lists...</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="fetchLists" />
|
||||||
|
</template>
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<div v-else-if="filteredLists.length === 0">
|
||||||
|
<p>{{ noListsMessage }}</p>
|
||||||
|
<!-- TODO: Add a button to create a new list -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-list v-else bordered separator>
|
||||||
|
<q-item
|
||||||
|
v-for="list in filteredLists"
|
||||||
|
:key="list.id"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
:to="`/lists/${list.id}`"
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ list.name }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ list.description || 'No description' }}</q-item-label>
|
||||||
|
<q-item-label caption v-if="!list.group_id && !props.groupId">
|
||||||
|
<q-icon name="person" /> Personal List
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption v-if="list.group_id && !props.groupId">
|
||||||
|
<q-icon name="group" /> Group List (ID: {{ list.group_id }})
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side top>
|
||||||
|
<q-badge
|
||||||
|
:color="list.is_complete ? 'green' : 'orange'"
|
||||||
|
:label="list.is_complete ? 'Complete' : 'Active'"
|
||||||
|
/>
|
||||||
|
<q-item-label caption class="q-mt-xs">
|
||||||
|
Updated: {{ new Date(list.updated_at).toLocaleDateString() }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<!-- TODO: Add FAB for creating a new list if props.groupId is defined or it's personal lists view -->
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Component logic will go here
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router'; // To potentially get groupId from route params
|
||||||
|
import { api } from 'boot/axios';
|
||||||
|
import {
|
||||||
|
QSpinnerDots,
|
||||||
|
QBanner,
|
||||||
|
QIcon,
|
||||||
|
QList,
|
||||||
|
QItem,
|
||||||
|
QItemSection,
|
||||||
|
QItemLabel,
|
||||||
|
QBadge,
|
||||||
|
QBtn,
|
||||||
|
} from 'quasar'; // Explicitly import Quasar components
|
||||||
|
|
||||||
|
// Define the structure of a List based on ListPublic schema
|
||||||
|
interface List {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
created_by_id: number;
|
||||||
|
group_id?: number | null;
|
||||||
|
is_complete: boolean;
|
||||||
|
created_at: string; // Assuming datetime is serialized as string
|
||||||
|
updated_at: string; // Assuming datetime is serialized as string
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
groupId?: number | string; // Can be passed as prop, or we might use route.params
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute(); // Access route if needed
|
||||||
|
|
||||||
|
const lists = ref<List[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Determine the actual groupId to use (from prop or route param)
|
||||||
|
const currentGroupId = computed(() => {
|
||||||
|
if (props.groupId) {
|
||||||
|
return typeof props.groupId === 'string' ? parseInt(props.groupId, 10) : props.groupId;
|
||||||
|
}
|
||||||
|
if (route.params.groupId) {
|
||||||
|
return parseInt(route.params.groupId as string, 10);
|
||||||
|
}
|
||||||
|
return null; // No specific group selected, show personal lists or all accessible
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
if (currentGroupId.value) {
|
||||||
|
// TODO: Fetch group name if we want to display "Lists for Group X"
|
||||||
|
return `Lists for Group ${currentGroupId.value}`;
|
||||||
|
}
|
||||||
|
return 'All My Lists'; // Changed from 'My Lists' to be more descriptive
|
||||||
|
});
|
||||||
|
|
||||||
|
const noListsMessage = computed(() => {
|
||||||
|
if (currentGroupId.value) {
|
||||||
|
return 'No lists found for this group.';
|
||||||
|
}
|
||||||
|
return 'You have no lists yet. Create a personal list or join a group to see shared lists.';
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchLists = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await api.get<List[]>('/lists'); // API returns all accessible lists
|
||||||
|
lists.value = response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch lists:', err);
|
||||||
|
error.value =
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
err.message ||
|
||||||
|
'An unexpected error occurred while fetching lists.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(fetchLists);
|
||||||
|
|
||||||
|
const filteredLists = computed(() => {
|
||||||
|
if (currentGroupId.value) {
|
||||||
|
return lists.value.filter((list) => list.group_id === currentGroupId.value);
|
||||||
|
}
|
||||||
|
// Show all accessible lists when no groupId is specified
|
||||||
|
return lists.value;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.q-item__label--caption .q-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -6,10 +6,27 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('layouts/MainLayout.vue'),
|
component: () => import('layouts/MainLayout.vue'),
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirect: '/lists' },
|
{ path: '', redirect: '/lists' },
|
||||||
{ path: 'lists', component: () => import('pages/ListsPage.vue') },
|
{ path: 'lists', name: 'PersonalLists', component: () => import('pages/ListsPage.vue') },
|
||||||
{ path: 'groups', component: () => import('pages/GroupsPage.vue') },
|
{
|
||||||
{ path: 'groups/:id', component: () => import('pages/GroupDetailPage.vue'), props: true },
|
path: 'lists/:id',
|
||||||
{ path: 'account', component: () => import('pages/AccountPage.vue') },
|
name: 'ListDetail',
|
||||||
|
component: () => import('pages/ListDetailPage.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{ path: 'groups', name: 'GroupsList', component: () => import('pages/GroupsPage.vue') },
|
||||||
|
{
|
||||||
|
path: 'groups/:id',
|
||||||
|
name: 'GroupDetail',
|
||||||
|
component: () => import('pages/GroupDetailPage.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'groups/:groupId/lists',
|
||||||
|
name: 'GroupLists',
|
||||||
|
component: () => import('pages/ListsPage.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{ path: 'account', name: 'Account', component: () => import('pages/AccountPage.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user