
This commit introduces several new components to enhance the list detail functionality: - **CostSummaryDialog.vue**: A modal for displaying cost summaries, including total costs, user balances, and a detailed breakdown of expenses. - **ExpenseSection.vue**: A section for managing and displaying expenses, featuring loading states, error handling, and collapsible item details. - **ItemsList.vue**: A component for rendering and managing a list of items with drag-and-drop functionality, including a new item input field. - **ListItem.vue**: A detailed item component that supports editing, deleting, and displaying item statuses. - **OcrDialog.vue**: A modal for handling OCR file uploads and displaying extracted items. - **SettleShareModal.vue**: A modal for settling shares among users, allowing input of settlement amounts. - **Error handling utility**: A new utility function for extracting user-friendly error messages from API responses. These additions aim to improve user interaction and streamline the management of costs and expenses within the application.
114 lines
4.3 KiB
Vue
114 lines
4.3 KiB
Vue
<template>
|
|
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.ocr.title')"
|
|
@update:modelValue="$emit('update:modelValue', $event)">
|
|
<template #default>
|
|
<div v-if="ocrLoading" class="text-center">
|
|
<VSpinner :label="$t('listDetailPage.loading.ocrProcessing')" />
|
|
</div>
|
|
<VList v-else-if="ocrItems.length > 0">
|
|
<VListItem v-for="(ocrItem, index) in ocrItems" :key="index">
|
|
<div class="flex items-center gap-2">
|
|
<VInput type="text" v-model="ocrItem.name" class="flex-grow" required />
|
|
<VButton variant="danger" size="sm" :icon-only="true" iconLeft="trash"
|
|
@click="ocrItems.splice(index, 1)" />
|
|
</div>
|
|
</VListItem>
|
|
</VList>
|
|
<VFormField v-else :label="$t('listDetailPage.modals.ocr.uploadLabel')"
|
|
:error-message="ocrError || undefined">
|
|
<VInput type="file" id="ocrFile" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"
|
|
:model-value="''" />
|
|
</VFormField>
|
|
</template>
|
|
<template #footer>
|
|
<VButton variant="neutral" @click="$emit('update:modelValue', false)">{{ $t('listDetailPage.buttons.cancel')
|
|
}}</VButton>
|
|
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="confirmAddItems"
|
|
:disabled="isAdding">
|
|
<VSpinner v-if="isAdding" size="sm" />
|
|
{{ $t('listDetailPage.buttons.addItems') }}
|
|
</VButton>
|
|
</template>
|
|
</VModal>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, defineProps, defineEmits } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
|
import { getApiErrorMessage } from '@/utils/errors';
|
|
import VModal from '@/components/valerie/VModal.vue';
|
|
import VSpinner from '@/components/valerie/VSpinner.vue';
|
|
import VList from '@/components/valerie/VList.vue';
|
|
import VListItem from '@/components/valerie/VListItem.vue';
|
|
import VInput from '@/components/valerie/VInput.vue';
|
|
import VButton from '@/components/valerie/VButton.vue';
|
|
import VFormField from '@/components/valerie/VFormField.vue';
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: Boolean,
|
|
required: true,
|
|
},
|
|
isAdding: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue', 'add-items']);
|
|
|
|
const { t } = useI18n();
|
|
|
|
const ocrLoading = ref(false);
|
|
const ocrItems = ref<{ name: string }[]>([]);
|
|
const ocrError = ref<string | null>(null);
|
|
const ocrFileInputRef = ref<InstanceType<typeof VInput> | null>(null);
|
|
|
|
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 = t('listDetailPage.errors.ocrNoItems');
|
|
}
|
|
} catch (err) {
|
|
ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed', t);
|
|
} finally {
|
|
ocrLoading.value = false;
|
|
// Reset file input
|
|
if (ocrFileInputRef.value?.$el) {
|
|
const input = ocrFileInputRef.value.$el.querySelector ? ocrFileInputRef.value.$el.querySelector('input') : ocrFileInputRef.value.$el;
|
|
if (input) input.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const confirmAddItems = () => {
|
|
emit('add-items', ocrItems.value);
|
|
};
|
|
|
|
watch(() => props.modelValue, (newVal) => {
|
|
if (newVal) {
|
|
ocrItems.value = [];
|
|
ocrError.value = null;
|
|
}
|
|
});
|
|
</script> |