mitlist/fe/src/components/list-detail/ListItem.vue
mohamad 7ffeae1476 feat: Add new components for cost summary, expenses, and item management
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.
2025-06-09 22:55:37 +02:00

444 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<li class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync }">
<div class="neo-item-content">
<!-- Drag Handle -->
<div class="drag-handle" v-if="isOnline">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<!-- Content when NOT editing -->
<template v-if="!item.isEditing">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete"
@change="$emit('checkbox-change', item, ($event.target as HTMLInputElement).checked)" />
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="onPriceInput"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
step="0.01" @blur="$emit('update-price', item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="$emit('start-edit', item)"
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
<!-- Content WHEN editing -->
<template v-else>
<div class="inline-edit-form flex-grow flex items-center gap-2">
<VInput type="text" :model-value="item.editName ?? ''"
@update:modelValue="$emit('update:editName', $event)" required class="flex-grow" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VInput type="number" :model-value="item.editQuantity || ''"
@update:modelValue="$emit('update:editQuantity', $event)" min="1" class="w-20" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VSelect :model-value="categoryModel" @update:modelValue="categoryModel = $event"
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-save-button" @click.stop="$emit('save-edit', item)"
:aria-label="$t('listDetailPage.buttons.saveChanges')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="neo-icon-button neo-cancel-button" @click.stop="$emit('cancel-edit', item)"
:aria-label="$t('listDetailPage.buttons.cancel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
</div>
</li>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Item } from '@/types/item';
import VInput from '@/components/valerie/VInput.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import { useOfflineStore } from '@/stores/offline';
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
isEditing?: boolean;
editName?: string;
editQuantity?: number | string | null;
editCategoryId?: number | null;
showFirework?: boolean;
}
const props = defineProps({
item: {
type: Object as PropType<ItemWithUI>,
required: true,
},
isOnline: {
type: Boolean,
required: true,
},
categoryOptions: {
type: Array as PropType<{ label: string; value: number | null }[]>,
required: true,
},
});
const emit = defineEmits([
'delete-item',
'checkbox-change',
'update-price',
'start-edit',
'save-edit',
'cancel-edit',
'update:editName',
'update:editQuantity',
'update:editCategoryId',
'update:priceInput'
]);
const { t } = useI18n();
const offlineStore = useOfflineStore();
const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
...opt,
value: opt.value === null ? '' : opt.value
})));
const categoryModel = computed({
get: () => props.item.editCategoryId === null || props.item.editCategoryId === undefined ? '' : props.item.editCategoryId,
set: (value) => {
emit('update:editCategoryId', value === '' ? null : value);
}
});
const isItemPendingSync = computed(() => {
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(props.item.id);
}
return false;
});
});
const onPriceInput = (value: string | number) => {
emit('update:priceInput', value);
}
</script>
<style scoped>
.neo-list-item {
padding: 1rem 0;
border-bottom: 1px solid #eee;
transition: background-color 0.2s ease;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
@media (max-width: 600px) {
.neo-list-item {
padding: 0.75rem 1rem;
}
}
.item-pending-sync {
/* You can add specific styling for pending items, e.g., a subtle glow or background */
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
.inline-edit-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-grow: 1;
}
.neo-icon-button {
padding: 0.5rem;
border-radius: 4px;
color: #666;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
}
.neo-icon-button:hover {
background: #f0f0f0;
color: #333;
}
.neo-edit-button {
color: #3b82f6;
}
.neo-edit-button:hover {
background: #eef7fd;
color: #2563eb;
}
.neo-delete-button {
color: #ef4444;
}
.neo-delete-button:hover {
background: #fee2e2;
color: #dc2626;
}
.neo-save-button {
color: #22c55e;
}
.neo-save-button:hover {
background: #dcfce7;
color: #16a34a;
}
.neo-cancel-button {
color: #ef4444;
}
.neo-cancel-button:hover {
background: #fee2e2;
color: #dc2626;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
}
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
.neo-price-input {
display: inline-flex;
align-items: center;
margin-left: 0.5rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.neo-list-item:hover .neo-price-input {
opacity: 1;
}
.drag-handle {
cursor: grab;
padding: 0.5rem;
color: #666;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.neo-list-item:hover .drag-handle {
opacity: 0.5;
}
.drag-handle:hover {
opacity: 1 !important;
color: #333;
}
.drag-handle:active {
cursor: grabbing;
}
</style>