mitlist/fe/src/pages/ListDetailPage.vue

899 lines
29 KiB
Vue

<template>
<main class="container page-padding">
<div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading list details...</p>
</div>
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-alert-triangle" />
</svg>
{{ error }}
</div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button>
</div>
<template v-else-if="list">
<div class="flex justify-between items-center flex-wrap mb-2">
<h1>{{ list.name }}</h1>
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
:class="{ 'feature-offline-disabled': !isOnline }"
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
<svg class="icon icon-sm">
<use xlink:href="#icon-clipboard" />
</svg>
Cost Summary
</button>
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
:class="{ 'feature-offline-disabled': !isOnline }"
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
<svg class="icon icon-sm">
<use xlink:href="#icon-plus" />
</svg>
Add via OCR
</button>
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
{{ list.is_complete ? 'Complete' : 'Active' }}
</span>
</div>
</div>
<!-- Add Item Form -->
<form @submit.prevent="onAddItem" class="card mb-3">
<div class="card-body">
<div class="flex items-end flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow" style="margin-bottom: 0;">
<label for="newItemName" class="form-label">Item Name</label>
<input type="text" id="newItemName" v-model="newItem.name" class="form-input" required
ref="itemNameInputRef" />
</div>
<div class="form-group" style="margin-bottom: 0; min-width: 120px;">
<label for="newItemQuantity" class="form-label">Quantity</label>
<input type="number" id="newItemQuantity" v-model="newItem.quantity" class="form-input" min="1" />
</div>
<button type="submit" class="btn btn-primary" :disabled="addingItem">
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Add Item
</button>
</div>
</div>
</form>
<!-- Items List -->
<div v-if="list.items.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Items Yet!</h3>
<p>This list is empty. Add some items using the form above.</p>
</div>
<ul v-else class="item-list">
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
'completed': item.is_complete,
'is-swiped': item.swiped,
'offline-item': isItemPendingSync(item),
'synced': !isItemPendingSync(item)
}" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
<div class="list-item-content">
<div class="list-item-main">
<label class="checkbox-label mb-0 flex-shrink-0">
<input type="checkbox" :checked="item.is_complete"
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
:disabled="item.updating" :aria-label="item.name" />
<span class="checkmark"></span>
</label>
<div class="item-text flex-grow">
<span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span>
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small>
<div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;">
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
<input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</div>
<div class="list-item-actions">
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" aria-label="Delete item">
<svg class="icon icon-sm">
<use xlink:href="#icon-trash"></use>
</svg>
</button>
</div>
</div>
</li>
</ul>
</template>
<!-- OCR Dialog -->
<div v-if="showOcrDialogState" class="modal-backdrop open" @click.self="closeOcrDialog">
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
<div class="modal-header">
<h3>Add Items via OCR</h3>
<button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<div v-if="ocrLoading" class="text-center">
<div class="spinner-dots"><span /><span /><span /></div>
<p>Processing image...</p>
</div>
<div v-else-if="ocrItems.length > 0">
<p class="mb-2">Review Extracted Items:</p>
<ul class="item-list">
<li v-for="(ocrItem, index) in ocrItems" :key="index" class="list-item">
<div class="list-item-content flex items-center" style="gap: 0.5rem;">
<input type="text" v-model="ocrItem.name" class="form-input flex-grow" required />
<button class="btn btn-danger btn-sm btn-icon-only" @click="ocrItems.splice(index, 1)">
<svg class="icon icon-sm">
<use xlink:href="#icon-trash" />
</svg>
</button>
</div>
</li>
</ul>
</div>
<div v-else class="form-group">
<label for="ocrFile" class="form-label">Upload Image</label>
<input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload"
ref="ocrFileInputRef" />
<p v-if="ocrError" class="form-error-text mt-1">{{ ocrError }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button>
<button v-if="ocrItems.length > 0" type="button" class="btn btn-primary ml-2" @click="addOcrItems"
:disabled="addingOcrItems">
<span v-if="addingOcrItems" class="spinner-dots-sm"><span /><span /><span /></span>
Add Items
</button>
</div>
</div>
</div>
<!-- Confirmation Dialog -->
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
<div class="modal-container confirm-modal" ref="confirmModalRef">
<div class="modal-header">
<h3>Confirmation</h3>
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
<use xlink:href="#icon-alert-triangle" />
</svg>
<p>{{ confirmDialogMessage }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
</div>
</div>
</div>
<!-- Cost Summary Dialog -->
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
<div class="modal-header">
<h3>List Cost Summary</h3>
<button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<div v-if="costSummaryLoading" class="text-center">
<div class="spinner-dots"><span /><span /><span /></div>
<p>Loading summary...</p>
</div>
<div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div>
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</p>
</div>
<h4>User Balances</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>User</th>
<th class="text-right">Items Added Value</th>
<th class="text-right">Amount Due</th>
<th class="text-right">Balance</th>
</tr>
</thead>
<tbody>
<tr v-for="userShare in listCostSummary.user_balances" :key="userShare.user_id">
<td>{{ userShare.user_identifier }}</td>
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right">
<span class="item-badge"
:class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
{{ formatCurrency(userShare.balance) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-else>No cost summary available.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @click="showCostSummaryDialog = false">Close</button>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
interface Item {
id: number;
name: string;
quantity?: string | undefined | number; // Allow number for input binding
is_complete: boolean;
price?: number | null;
version: number;
updating?: boolean;
updated_at: string;
deleting?: boolean;
// For UI state
priceInput?: string | number | null; // Separate for input binding due to potential nulls/strings
swiped?: boolean; // For swipe UI
}
interface List {
id: number;
name: string;
description?: string;
is_complete: boolean;
items: Item[];
version: number;
updated_at: string;
}
interface UserCostShare {
user_id: number;
user_identifier: string;
items_added_value: string | number;
amount_due: string | number;
balance: string | number;
}
interface ListCostSummaryData {
list_id: number;
list_name: string;
total_list_cost: string | number;
num_participating_users: number;
equal_share_per_user: string | number;
user_balances: UserCostShare[];
}
const route = useRoute();
const { isOnline } = useNetwork();
const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore();
const list = ref<List | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const addingItem = ref(false);
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null);
const newItem = ref<{ name: string; quantity?: string | number }>({ name: '' });
const itemNameInputRef = ref<HTMLInputElement | null>(null);
// OCR
const showOcrDialogState = ref(false);
const ocrModalRef = ref<HTMLElement | null>(null);
const ocrLoading = ref(false);
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
const addingOcrItems = ref(false);
const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<HTMLInputElement | null>(null);
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
accept: 'image/*',
multiple: false,
});
// Confirmation Dialog
const showConfirmDialogState = ref(false);
const confirmModalRef = ref<HTMLElement | null>(null);
const confirmDialogMessage = ref('');
const pendingAction = ref<(() => Promise<void>) | null>(null);
// Cost Summary
const showCostSummaryDialog = ref(false);
const costSummaryModalRef = ref<HTMLElement | null>(null);
const listCostSummary = ref<ListCostSummaryData | null>(null);
const costSummaryLoading = ref(false);
const costSummaryError = ref<string | null>(null);
onClickOutside(ocrModalRef, () => { showOcrDialogState.value = false; });
onClickOutside(costSummaryModalRef, () => { showCostSummaryDialog.value = false; });
onClickOutside(confirmModalRef, () => { showConfirmDialogState.value = false; pendingAction.value = null; });
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
const numValue = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
};
const processListItems = (items: Item[]): Item[] => {
return items.map(item => ({
...item,
priceInput: item.price !== null && item.price !== undefined ? item.price : ''
}));
};
const fetchListDetails = async () => {
loading.value = true;
error.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(route.params.id)));
const rawList = response.data as List;
rawList.items = processListItems(rawList.items);
list.value = rawList;
lastListUpdate.value = rawList.updated_at;
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest;
}, '');
if (showCostSummaryDialog.value) { // If dialog is open, refresh its data
await fetchListCostSummary();
}
} catch (err: unknown) {
error.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
} finally {
loading.value = false;
}
};
const checkForUpdates = async () => {
if (!list.value) return;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
const { updated_at: newListUpdatedAt, items: newItems } = response.data as List;
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, '');
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
await fetchListDetails();
}
} catch (err) {
console.warn('Polling for updates failed:', err);
}
};
const startPolling = () => {
stopPolling();
pollingInterval.value = setInterval(() => checkForUpdates(), 15000);
};
const stopPolling = () => {
if (pollingInterval.value) clearInterval(pollingInterval.value);
};
const isItemPendingSync = (item: Item) => {
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(item.id);
}
return false;
});
};
const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
itemNameInputRef.value?.focus();
return;
}
addingItem.value = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'create_list_item',
payload: {
listId: String(list.value.id),
itemData: {
name: newItem.value.name,
quantity: newItem.value.quantity?.toString()
}
}
});
// Optimistically add to UI
const optimisticItem: Item = {
id: Date.now(), // Temporary ID
name: newItem.value.name,
quantity: newItem.value.quantity,
is_complete: false,
version: 1,
updated_at: new Date().toISOString()
};
list.value.items.push(processListItems([optimisticItem])[0]);
newItem.value = { name: '' };
itemNameInputRef.value?.focus();
addingItem.value = false;
return;
}
try {
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: newItem.value.name, quantity: newItem.value.quantity?.toString() }
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
newItem.value = { name: '' };
itemNameInputRef.value?.focus();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add item.', type: 'error' });
} finally {
addingItem.value = false;
}
};
const updateItem = async (item: Item, newCompleteStatus: boolean) => {
if (!list.value) return;
item.updating = true;
const originalCompleteStatus = item.is_complete;
item.is_complete = newCompleteStatus; // Optimistic update
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
completed: newCompleteStatus
},
version: item.version
}
});
item.updating = false;
return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
} catch (err) {
item.is_complete = originalCompleteStatus; // Revert on error
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
} finally {
item.updating = false;
}
};
const updateItemPrice = async (item: Item) => {
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice) return;
item.updating = true;
const originalPrice = item.price;
const originalPriceInput = item.priceInput;
item.price = newPrice;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice,
completed: item.is_complete
},
version: item.version
}
});
item.updating = false;
return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ price: newPrice, completed: item.is_complete, version: item.version }
);
item.version++;
} catch (err) {
item.price = originalPrice;
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
} finally {
item.updating = false;
}
};
const deleteItem = async (item: Item) => {
if (!list.value) return;
item.deleting = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'delete_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id)
}
});
// Optimistically remove from UI
list.value.items = list.value.items.filter(i => i.id !== item.id);
item.deleting = false;
return;
}
try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id);
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
} finally {
item.deleting = false;
}
};
// Confirmation dialog logic
const confirmUpdateItem = (item: Item, newCompleteStatus: boolean) => {
confirmDialogMessage.value = `Mark "${item.name}" as ${newCompleteStatus ? 'complete' : 'incomplete'}?`;
pendingAction.value = () => updateItem(item, newCompleteStatus);
showConfirmDialogState.value = true;
};
const confirmDeleteItem = (item: Item) => {
confirmDialogMessage.value = `Delete "${item.name}"? This cannot be undone.`;
pendingAction.value = () => deleteItem(item);
showConfirmDialogState.value = true;
};
const handleConfirmedAction = async () => {
if (pendingAction.value) {
await pendingAction.value();
}
cancelConfirmation();
};
const cancelConfirmation = () => {
showConfirmDialogState.value = false;
pendingAction.value = null;
};
// OCR Functionality
const openOcrDialog = () => {
ocrItems.value = [];
ocrError.value = null;
resetOcrFileDialog(); // Clear previous file selection from useFileDialog
showOcrDialogState.value = true;
nextTick(() => {
if (ocrFileInputRef.value) {
ocrFileInputRef.value.value = ''; // Manually clear input type=file
}
});
};
const closeOcrDialog = () => {
showOcrDialogState.value = false;
ocrItems.value = [];
ocrError.value = null;
};
watch(ocrFiles, async (newFiles) => {
if (newFiles && newFiles.length > 0) {
const file = newFiles[0];
await handleOcrUpload(file);
}
});
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 = "No items extracted from the image.";
}
} catch (err) {
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
} finally {
ocrLoading.value = false;
if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; // Reset file input
}
};
const addOcrItems = async () => {
if (!list.value || !ocrItems.value.length) return;
addingOcrItems.value = true;
let successCount = 0;
try {
for (const item of ocrItems.value) {
if (!item.name.trim()) continue;
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: item.name, quantity: "1" } // Default quantity
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
successCount++;
}
if (successCount > 0) notificationStore.addNotification({ message: `${successCount} item(s) added successfully from OCR.`, type: 'success' });
closeOcrDialog();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add OCR items.', type: 'error' });
} finally {
addingOcrItems.value = false;
}
};
// Cost Summary
const fetchListCostSummary = async () => {
if (!list.value || list.value.id === 0) return;
costSummaryLoading.value = true;
costSummaryError.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
listCostSummary.value = response.data;
} catch (err) {
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load cost summary.';
listCostSummary.value = null;
} finally {
costSummaryLoading.value = false;
}
};
watch(showCostSummaryDialog, (newVal) => {
if (newVal && (!listCostSummary.value || listCostSummary.value.list_id !== list.value?.id)) {
fetchListCostSummary();
}
});
// Keyboard shortcut
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
// Check if a modal is open or if focus is already in an input/textarea
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return;
}
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
return;
}
event.preventDefault();
itemNameInputRef.value?.focus();
}
});
// Swipe detection (basic)
let touchStartX = 0;
const SWIPE_THRESHOLD = 50; // pixels
const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX;
// Add class for visual feedback during swipe if desired
};
const handleTouchMove = () => {
// Can be used for interactive swipe effect
};
const handleTouchEnd = () => {
// Not implemented: Valerie UI swipe example implies JS adds/removes 'is-swiped'
// For a simple demo, one might toggle it here based on a more complex gesture
// This would require more state per item and logic
// For now, swipe actions are not visually implemented
};
onMounted(() => {
if (!route.params.id) {
error.value = 'No list ID provided';
loading.value = false;
return;
}
fetchListDetails().then(() => {
startPolling();
});
});
onUnmounted(() => {
stopPolling();
});
</script>
<style scoped>
.page-padding {
padding: 1rem;
}
.mb-1 {
margin-bottom: 0.5rem;
}
.mb-2 {
margin-bottom: 1rem;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.mt-1 {
margin-top: 0.5rem;
}
.mt-2 {
margin-top: 1rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.text-right {
text-align: right;
}
.flex-grow {
flex-grow: 1;
}
.item-caption {
display: block;
font-size: 0.8rem;
opacity: 0.6;
margin-top: 0.25rem;
}
.text-decoration-line-through {
text-decoration: line-through;
}
.form-input-sm {
/* For price input */
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
.cost-overview p {
margin-bottom: 0.5rem;
font-size: 1.05rem;
}
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
}
.list-item.completed .item-text {
/* text-decoration: line-through; is handled by span class */
opacity: 0.7;
}
.list-item-actions {
margin-left: auto;
/* Pushes actions to the right */
padding-left: 1rem;
/* Space before actions */
}
.offline-item {
position: relative;
opacity: 0.8;
transition: opacity 0.3s ease;
}
.offline-item::after {
content: '';
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
animation: spin 1s linear infinite;
}
.offline-item.synced {
opacity: 1;
}
.offline-item.synced::after {
display: none;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.feature-offline-disabled {
position: relative;
cursor: not-allowed;
opacity: 0.6;
}
.feature-offline-disabled::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem;
background-color: var(--bg-color-tooltip, #333);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
}
.feature-offline-disabled:hover::before {
opacity: 1;
visibility: visible;
}
</style>