mitlist/fe/src/components/expenses/ExpenseCreationSheet.vue
mohamad d6c5e6fcfd chore: Remove package-lock.json and enhance financials API with user summaries
This commit includes the following changes:

- Deleted the `package-lock.json` file to streamline dependency management.
- Updated the `financials.py` endpoint to return a comprehensive user financial summary, including net balance, total group spending, debts, and credits.
- Enhanced the `expense.py` CRUD operations to handle enum values and improve error handling during expense deletion.
- Introduced new schemas in `financials.py` for user financial summaries and debt/credit tracking.
- Refactored the costs service to improve group balance summary calculations.

These changes aim to improve the application's financial tracking capabilities and maintain cleaner dependency management.
2025-06-28 21:37:26 +02:00

1090 lines
38 KiB
Vue

<template>
<Dialog v-model="open" class="z-50">
<div class="expense-creation-sheet">
<!-- Enhanced Header -->
<div class="sheet-header">
<div class="header-content">
<div class="header-text">
<Heading :level="2" class="sheet-title">
{{ isEditing ? 'Edit Expense' : 'Add New Expense' }}
</Heading>
<p class="sheet-subtitle">
{{ isEditing ? 'Update expense details' : 'Split costs fairly with your household' }}
</p>
</div>
<Button variant="ghost" size="sm" @click="open = false" class="close-button">
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
</Button>
</div>
<!-- Enhanced Smart Actions Bar -->
<div class="smart-actions-bar">
<Button variant="soft" color="primary" size="sm" @click="showReceiptScanner = true"
:disabled="!isCameraAvailable" class="smart-action">
<template #icon-left>
<BaseIcon name="heroicons:camera-20-solid" />
</template>
<span class="action-text">Scan Receipt</span>
</Button>
<Button variant="soft" color="success" size="sm" @click="useSmartDefaults" class="smart-action">
<template #icon-left>
<BaseIcon name="heroicons:sparkles-20-solid" />
</template>
<span class="action-text">Smart Fill</span>
</Button>
<Button variant="soft" color="neutral" size="sm" @click="toggleEqualSplit" class="smart-action">
<template #icon-left>
<BaseIcon name="heroicons:arrows-right-left-20-solid" />
</template>
<span class="action-text">Equal Split</span>
</Button>
</div>
</div>
<!-- Progress Indicator -->
<div class="progress-indicator">
<div class="progress-track">
<div class="progress-fill" :style="{ width: `${progressPercentage}%` }"></div>
</div>
<span class="progress-text">Step {{ currentStep }} of {{ totalSteps }}</span>
</div>
<!-- Main Form -->
<form @submit.prevent="handleSubmit" class="sheet-form">
<!-- Step 1: Basic Details -->
<div v-if="currentStep === 1" class="form-step">
<div class="step-header">
<div class="step-icon">
<BaseIcon name="heroicons:document-text-20-solid" />
</div>
<div class="step-content">
<h3 class="step-title">What did you spend on?</h3>
<p class="step-description">Add a clear description to help others understand the expense
</p>
</div>
</div>
<div class="form-section">
<Input v-model="form.description" label="Description"
placeholder="e.g., Weekly groceries, Dinner at Olive Garden..."
:suggestions="descriptionSuggestions" @focus="loadDescriptionSuggestions" required size="lg"
class="description-input" />
<!-- Quick Suggestions -->
<div v-if="commonExpenses.length > 0" class="quick-suggestions">
<h4 class="suggestions-title">Common expenses</h4>
<div class="suggestion-chips">
<button v-for="expense in commonExpenses" :key="expense.name" type="button"
@click="selectCommonExpense(expense)" class="suggestion-chip">
<BaseIcon :name="expense.icon" class="chip-icon" />
<span>{{ expense.name }}</span>
</button>
</div>
</div>
</div>
</div>
<!-- Step 2: Amount & Currency -->
<div v-if="currentStep === 2" class="form-step">
<div class="step-header">
<div class="step-icon">
<BaseIcon name="heroicons:banknotes-20-solid" />
</div>
<div class="step-content">
<h3 class="step-title">How much did it cost?</h3>
<p class="step-description">Enter the total amount you paid</p>
</div>
</div>
<div class="form-section">
<div class="amount-input-group">
<div class="amount-field">
<Input type="number" step="0.01" min="0.01" v-model="form.total_amount"
label="Total Amount" placeholder="0.00" :loading="isCalculatingTotal" size="xl"
required class="amount-input">
<template #prefix>
<span class="currency-symbol">{{ currencySymbol }}</span>
</template>
</Input>
</div>
<div class="currency-field">
<Listbox v-model="form.currency" class="currency-selector">
<label class="field-label">Currency</label>
<ListboxButton class="currency-button">
<span class="currency-code">{{ form.currency }}</span>
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="chevron-icon" />
</ListboxButton>
<ListboxOptions class="currency-options">
<ListboxOption v-for="currency in supportedCurrencies" :key="currency.code"
:value="currency.code" class="currency-option"
v-slot="{ active, selected }">
<div class="currency-option-content">
<div class="currency-main">
<span class="currency-code">{{ currency.code }}</span>
<span class="currency-symbol">{{ currency.symbol }}</span>
</div>
<span class="currency-name">{{ currency.name }}</span>
<BaseIcon v-if="selected" name="heroicons:check-20-solid"
class="selected-icon" />
</div>
</ListboxOption>
</ListboxOptions>
</Listbox>
</div>
</div>
<!-- Amount Suggestions -->
<div v-if="suggestedAmounts.length > 0" class="amount-suggestions">
<h4 class="suggestions-title">Recent amounts</h4>
<div class="suggestion-amounts">
<button v-for="amount in suggestedAmounts" :key="amount" type="button"
@click="form.total_amount = amount" class="amount-suggestion">
{{ currencySymbol }}{{ amount }}
</button>
</div>
</div>
</div>
</div>
<!-- Step 3: Who Paid -->
<div v-if="currentStep === 3" class="form-step">
<div class="step-header">
<div class="step-icon">
<BaseIcon name="heroicons:user-20-solid" />
</div>
<div class="step-content">
<h3 class="step-title">Who paid for this?</h3>
<p class="step-description">Select the person who made the payment</p>
</div>
</div>
<div class="form-section">
<div class="user-selection-grid">
<button v-for="user in availableUsers" :key="user.id" type="button"
@click="form.paid_by_user_id = user.id" :class="[
'user-selection-card',
{ 'selected': form.paid_by_user_id === user.id }
]">
<div class="user-avatar">
{{ user.email.charAt(0).toUpperCase() }}
</div>
<div class="user-details">
<span class="user-name">{{ user.full_name || user.email }}</span>
<span v-if="user.id === currentUserId" class="user-badge">You</span>
</div>
<div class="selection-indicator">
<BaseIcon v-if="form.paid_by_user_id === user.id"
name="heroicons:check-circle-20-solid" class="selected-icon" />
<div v-else class="unselected-circle"></div>
</div>
</button>
</div>
</div>
</div>
<!-- Step 4: Split Configuration -->
<div v-if="currentStep === 4" class="form-step">
<div class="step-header">
<div class="step-icon">
<BaseIcon name="heroicons:calculator-20-solid" />
</div>
<div class="step-content">
<h3 class="step-title">How should this be split?</h3>
<p class="step-description">Choose how to divide the expense among household members</p>
</div>
</div>
<div class="form-section">
<div class="split-type-grid">
<Card v-for="splitType in splitTypes" :key="splitType.value"
:variant="form.split_type === splitType.value ? 'elevated' : 'outlined'"
:color="form.split_type === splitType.value ? 'primary' : 'neutral'" padding="lg"
:interactive="true" class="split-type-card"
:class="{ 'selected': form.split_type === splitType.value }"
@click="selectSplitType(splitType.value)">
<div class="split-type-content">
<div class="split-type-header">
<div class="split-type-icon">
<BaseIcon :name="`heroicons:${splitType.icon}-20-solid`" />
</div>
<div class="split-type-text">
<h5 class="split-type-title">{{ splitType.title }}</h5>
<p class="split-type-description">{{ splitType.description }}</p>
</div>
</div>
<div v-if="splitType.recommended" class="split-type-footer">
<span class="recommended-badge">
<BaseIcon name="heroicons:star-20-solid" class="star-icon" />
Recommended
</span>
</div>
</div>
</Card>
</div>
<!-- Split Preview -->
<div v-if="splitPreview" class="split-preview">
<h4 class="preview-title">Split preview</h4>
<div class="preview-content">
<p class="preview-text">{{ splitPreview }}</p>
<div v-if="showAdvancedSplit" class="preview-breakdown">
<div v-for="split in form.splits_in" :key="split.user_id"
class="split-item-preview">
<span class="split-user">{{ getUserById(split.user_id)?.full_name ||
getUserById(split.user_id)?.email }}</span>
<span class="split-amount">{{ currencySymbol }}{{ split.amount }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Split Configuration (if needed) -->
<div v-if="showAdvancedSplit && currentStep === 4" class="advanced-split-section">
<Card variant="soft" color="neutral" padding="lg" class="split-config-card">
<div class="config-header">
<h4 class="config-title">Customize split amounts</h4>
<p class="config-description">Adjust individual amounts as needed</p>
</div>
<div class="splits-list">
<div v-for="(split, index) in form.splits_in" :key="index" class="split-item">
<div class="split-user-info">
<div class="user-avatar-mini">
{{ getUserById(split.user_id)?.email.charAt(0).toUpperCase() }}
</div>
<span class="user-name-mini">{{ getUserById(split.user_id)?.full_name ||
getUserById(split.user_id)?.email }}</span>
</div>
<div class="split-amount-field">
<Input type="number" step="0.01" min="0" v-model="split.amount"
:placeholder="equalSplitAmount.toFixed(2)" size="sm" class="amount-input-mini">
<template #prefix>
<span class="currency-symbol-mini">{{ currencySymbol }}</span>
</template>
</Input>
</div>
</div>
</div>
<div class="split-validation">
<div :class="['validation-status', { 'valid': isSplitValid, 'invalid': !isSplitValid }]">
<BaseIcon
:name="isSplitValid ? 'heroicons:check-circle-20-solid' : 'heroicons:exclamation-triangle-20-solid'" />
<span>{{ splitValidationMessage }}</span>
</div>
</div>
</Card>
</div>
<!-- Navigation Buttons -->
<div class="form-navigation">
<Button v-if="currentStep > 1" variant="outline" color="neutral" size="lg" @click="previousStep"
class="nav-button">
<template #icon-left>
<BaseIcon name="heroicons:arrow-left-20-solid" />
</template>
Back
</Button>
<div class="nav-spacer"></div>
<Button v-if="currentStep < totalSteps" variant="solid" color="primary" size="lg" @click="nextStep"
:disabled="!canProceedToNextStep" class="nav-button">
Continue
<template #icon-right>
<BaseIcon name="heroicons:arrow-right-20-solid" />
</template>
</Button>
<Button v-else type="submit" variant="solid" color="success" size="lg" :loading="isSubmitting"
:disabled="!canSubmit" class="nav-button">
<template #icon-left>
<BaseIcon name="heroicons:check-20-solid" />
</template>
{{ isEditing ? 'Update Expense' : 'Create Expense' }}
</Button>
</div>
</form>
<!-- Receipt Scanner Modal -->
<Dialog v-model="showReceiptScanner">
<Card variant="elevated" padding="xl" class="receipt-scanner-modal">
<div class="scanner-header">
<h3 class="scanner-title">Scan Your Receipt</h3>
<Button variant="ghost" size="sm" @click="showReceiptScanner = false">
<BaseIcon name="heroicons:x-mark-20-solid" />
</Button>
</div>
<div class="scanner-content">
<div class="scanner-preview">
<div class="camera-placeholder">
<BaseIcon name="heroicons:camera-20-solid" class="camera-icon" />
<p>Camera preview will appear here</p>
</div>
</div>
<div class="scanner-actions">
<Button variant="solid" color="primary" size="lg" class="capture-button">
<template #icon-left>
<BaseIcon name="heroicons:camera-20-solid" />
</template>
Capture Receipt
</Button>
<p class="scanner-help">Position your receipt clearly in the camera view</p>
</div>
</div>
</Card>
</Dialog>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch, computed, onMounted } from 'vue'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/vue'
import { Dialog, Heading, Input, Button, Card, Switch } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
import { useExpenses } from '@/composables/useExpenses'
import { useAuthStore } from '@/stores/auth'
import type { Expense, ExpenseSplitCreate } from '@/types/expense'
import type { UserPublic } from '@/types/user'
interface ExpenseForm {
description: string
total_amount: string
currency: string
split_type: string
paid_by_user_id: number
isRecurring: boolean
recurrence_pattern: {
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: string
}
splits_in: ExpenseSplitCreate[]
}
const open = defineModel<boolean>('modelValue', { default: false })
// Props
const props = defineProps<{ expense?: Expense | null }>()
const authStore = useAuthStore()
const { createExpense, updateExpense } = useExpenses()
// State
const submitting = ref(false)
const showReceiptScanner = ref(false)
const isCalculatingTotal = ref(false)
const availableUsers = ref<UserPublic[]>([])
const descriptionSuggestions = ref<string[]>([])
const form = reactive<ExpenseForm>({
description: '',
total_amount: '',
currency: 'USD',
split_type: 'EQUAL',
paid_by_user_id: authStore.user?.id || 0,
isRecurring: false,
recurrence_pattern: {
type: 'monthly',
interval: '1'
},
splits_in: []
})
// Computed
const isEditing = computed(() => !!props.expense)
const currentUserId = computed(() => authStore.user?.id)
const supportedCurrencies = computed(() => [
{ code: 'USD', name: 'US Dollar', symbol: '$' },
{ code: 'EUR', name: 'Euro', symbol: '€' },
{ code: 'GBP', name: 'British Pound', symbol: '£' },
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }
])
const currencySymbol = computed(() => {
return supportedCurrencies.value.find(c => c.code === form.currency)?.symbol || '$'
})
const splitTypes = computed(() => [
{
value: 'EQUAL',
title: 'Equal Split',
description: 'Split equally among all participants',
icon: 'pie_chart',
recommended: true
},
{
value: 'EXACT_AMOUNTS',
title: 'Exact Amounts',
description: 'Specify exact amount for each person',
icon: 'calculate',
recommended: false
},
{
value: 'PERCENTAGE',
title: 'Percentage',
description: 'Split by percentage for each person',
icon: 'percent',
recommended: false
},
{
value: 'SHARES',
title: 'Shares',
description: 'Split by number of shares',
icon: 'share',
recommended: false
}
])
const showAdvancedSplit = computed(() =>
['EXACT_AMOUNTS', 'PERCENTAGE', 'SHARES'].includes(form.split_type)
)
const selectedPayer = computed(() =>
availableUsers.value.find(user => user.id === form.paid_by_user_id)
)
const recurrenceOptions = computed(() => [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' }
])
const splitPreview = computed(() => {
if (form.split_type === 'EQUAL') {
return `Split equally among ${availableUsers.value.length} people`
}
if (form.splits_in.length === 0) return 'No splits configured'
const validSplits = form.splits_in.filter(s => s.user_id > 0)
return `${validSplits.length} people configured`
})
const splitValidationError = computed(() => {
if (!showAdvancedSplit.value) return null
const totalAmount = parseFloat(form.total_amount) || 0
if (form.split_type === 'EXACT_AMOUNTS') {
const totalSplit = form.splits_in.reduce((sum, split) => sum + (parseFloat(split.owed_amount as string) || 0), 0)
if (Math.abs(totalSplit - totalAmount) > 0.01) {
return `Split amounts ($${totalSplit.toFixed(2)}) must equal total expense ($${totalAmount.toFixed(2)})`
}
}
if (form.split_type === 'PERCENTAGE') {
const totalPercentage = form.splits_in.reduce((sum, split) => sum + (parseFloat(split.share_percentage as string) || 0), 0)
if (Math.abs(totalPercentage - 100) > 0.1) {
return `Percentages must total 100% (currently ${totalPercentage.toFixed(1)}%)`
}
}
return null
})
const isFormValid = computed(() => {
return form.description.trim() &&
form.total_amount &&
parseFloat(form.total_amount) > 0 &&
form.paid_by_user_id > 0 &&
!splitValidationError.value
})
const isCameraAvailable = computed(() => {
return 'mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices
})
// Methods
const getUserById = (id: number) => {
return availableUsers.value.find(user => user.id === id)
}
const getRecurrenceLabel = (type: string) => {
return recurrenceOptions.value.find(option => option.value === type)?.label || type
}
const loadDescriptionSuggestions = () => {
// Mock suggestions - would be replaced with real data
descriptionSuggestions.value = [
'Grocery shopping',
'Dinner at restaurant',
'Gas for car',
'Utility bill',
'Internet bill',
'Rent payment'
]
}
const useSmartDefaults = () => {
// Smart defaults based on context
if (!form.description) {
const hour = new Date().getHours()
if (hour >= 6 && hour < 11) {
form.description = 'Breakfast'
} else if (hour >= 11 && hour < 16) {
form.description = 'Lunch'
} else if (hour >= 16 && hour < 22) {
form.description = 'Dinner'
} else {
form.description = 'Late night snacks'
}
}
// Set default split to equal if not already configured
if (!form.split_type || form.split_type === 'EQUAL') {
form.split_type = 'EQUAL'
}
// Set default currency based on user locale
if (!form.currency) {
form.currency = 'USD' // Could be detected from locale
}
}
const addSplit = () => {
form.splits_in.push({
user_id: 0,
owed_amount: form.split_type === 'EXACT_AMOUNTS' ? 0 : undefined,
share_percentage: form.split_type === 'PERCENTAGE' ? 0 : undefined,
share_units: form.split_type === 'SHARES' ? 0 : undefined
})
}
const removeSplit = (index: number) => {
if (form.splits_in.length > 1) {
form.splits_in.splice(index, 1)
}
}
const initializeSplits = () => {
if (showAdvancedSplit.value && form.splits_in.length === 0) {
addSplit()
}
}
// Watch for split type changes
watch(() => form.split_type, () => {
form.splits_in = []
if (showAdvancedSplit.value) {
initializeSplits()
}
})
// Sync form when editing
watch(
() => props.expense,
(val) => {
if (val) {
form.description = val.description
form.total_amount = val.total_amount
form.currency = val.currency
form.split_type = val.split_type
form.paid_by_user_id = val.paid_by_user_id
form.isRecurring = val.isRecurring || false
// Initialize splits if needed
if (showAdvancedSplit.value) {
initializeSplits()
}
} else {
// Reset form
form.description = ''
form.total_amount = ''
form.currency = 'USD'
form.split_type = 'EQUAL'
form.paid_by_user_id = authStore.user?.id || 0
form.isRecurring = false
form.splits_in = []
}
},
{ immediate: true }
)
const handleSubmit = async () => {
if (!isFormValid.value || submitting.value) return
submitting.value = true
try {
const expenseData = {
...form,
total_amount: parseFloat(form.total_amount)
}
if (isEditing.value && props.expense) {
await updateExpense(props.expense.id, {
...expenseData,
version: props.expense.version
})
} else {
await createExpense(expenseData as any)
}
open.value = false
} catch (e) {
console.error('Failed to save expense', e)
} finally {
submitting.value = false
}
}
// Mock function to load available users
const loadAvailableUsers = async () => {
// This would be replaced with actual API call
availableUsers.value = [
{ id: 1, email: 'user1@example.com', full_name: 'John Doe' },
{ id: 2, email: 'user2@example.com', full_name: 'Jane Smith' }
]
if (authStore.user) {
const currentUserExists = availableUsers.value.some(user => user.id === authStore.user?.id)
if (!currentUserExists) {
availableUsers.value.unshift(authStore.user)
}
}
}
onMounted(() => {
loadAvailableUsers()
loadDescriptionSuggestions()
})
</script>
<style scoped>
.expense-creation-sheet {
@apply bg-surface-primary rounded-radius-lg shadow-elevation-high w-full max-w-2xl max-h-[90vh] overflow-hidden;
}
.sheet-header {
@apply p-spacing-lg border-b border-border-secondary space-y-spacing-md;
}
.header-content {
@apply flex items-center justify-between;
}
.header-text {
@apply space-y-spacing-xs;
}
.sheet-title {
@apply text-heading-lg font-semibold text-text-primary;
}
.sheet-subtitle {
@apply text-body-sm text-text-secondary;
}
.close-button {
@apply flex-shrink-0;
}
.smart-actions-bar {
@apply flex items-center gap-spacing-sm;
}
.smart-action {
@apply transition-all duration-micro hover:scale-105;
}
.sheet-form {
@apply p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-200px)];
}
.form-section {
@apply space-y-3;
}
.section-header {
@apply space-y-2;
}
.section-title {
@apply font-semibold text-neutral-900 dark:text-neutral-100;
}
.section-description {
@apply text-sm text-neutral-500 dark:text-neutral-400;
}
.field-label {
@apply block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2;
}
.amount-input-group {
@apply flex gap-3;
}
.amount-field {
@apply flex-1;
}
.currency-field {
@apply w-24;
}
.currency-selector {
@apply relative;
}
.currency-button {
@apply flex items-center justify-between w-full px-3 py-2 bg-neutral-100 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
@apply text-sm font-medium text-neutral-700 dark:text-neutral-300;
@apply hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-micro;
}
.currency-options {
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
@apply max-h-48 overflow-y-auto;
}
.currency-option {
@apply flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-micro;
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
}
.currency-option-content {
@apply flex items-center gap-2;
}
.currency-code {
@apply font-medium text-neutral-900 dark:text-neutral-100;
}
.currency-name {
@apply text-xs text-neutral-500 dark:text-neutral-400;
}
.selected-icon {
@apply w-4 h-4 text-primary-500;
}
.paid-by-selector {
@apply relative;
}
.paid-by-button {
@apply flex items-center justify-between w-full px-4 py-3 bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
@apply hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-micro;
}
.selected-user {
@apply flex items-center gap-3;
}
.user-info {
@apply flex items-center gap-3;
}
.user-avatar {
@apply w-8 h-8 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded-full flex items-center justify-center;
@apply text-sm font-semibold;
}
.user-name {
@apply font-medium text-neutral-900 dark:text-neutral-100;
}
.placeholder {
@apply text-neutral-500 dark:text-neutral-400;
}
.paid-by-options {
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
@apply max-h-64 overflow-y-auto;
}
.user-option {
@apply flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors duration-micro;
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
}
.user-option-content {
@apply flex items-center gap-2;
}
.user-details {
@apply flex items-center gap-2;
}
.user-badge {
@apply px-2 py-0.5 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded-full text-xs font-medium;
}
.split-type-grid {
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
}
.split-type-card {
@apply p-4 border-2 border-neutral-200 dark:border-neutral-700 rounded-lg cursor-pointer transition-all duration-micro;
@apply hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-soft;
}
.split-type-card.selected {
@apply border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-950;
}
.split-type-content {
@apply space-y-1;
}
.split-type-header {
@apply flex items-center gap-3;
}
.split-type-icon {
@apply w-10 h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg flex items-center justify-center mb-3;
}
.split-type-text {
@apply space-y-1;
}
.split-type-title {
@apply font-semibold text-neutral-900 dark:text-neutral-100;
}
.split-type-description {
@apply text-sm text-neutral-600 dark:text-neutral-400;
}
.split-type-footer {
@apply flex items-center gap-2;
}
.recommended-badge {
@apply inline-block px-2 py-1 bg-success-100 dark:bg-success-900 text-success-800 dark:text-success-200 rounded-full text-xs font-medium mt-2;
}
.split-config-card {
@apply space-y-4;
}
.splits-list {
@apply space-y-3;
}
.split-item {
@apply flex items-center gap-3;
}
.split-user-field {
@apply flex-1;
}
.split-user-button {
@apply flex items-center justify-between w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
@apply text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-micro;
}
.selected-user-mini {
@apply flex items-center gap-2;
}
.user-avatar-mini {
@apply w-6 h-6 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded-full flex items-center justify-center;
@apply text-xs font-semibold;
}
.user-name-mini {
@apply font-medium text-neutral-900 dark:text-neutral-100;
}
.split-user-options {
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
}
.user-option-mini {
@apply flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors duration-micro;
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
}
.user-option-mini-content {
@apply flex items-center gap-2;
}
.split-amount-field {
@apply w-32;
}
.split-amount-input {
@apply w-full;
}
.remove-split-button {
@apply flex-shrink-0;
}
.add-split-button {
@apply w-full;
}
.split-validation-error {
@apply flex items-center gap-2 p-3 bg-error-50 dark:bg-error-950 border border-error-200 dark:border-error-800 rounded-lg;
@apply text-sm text-error-800 dark:text-error-200;
}
.recurring-toggle {
@apply space-y-2;
}
.recurring-config {
@apply space-y-4 mt-4;
}
.recurring-options {
@apply grid grid-cols-1 sm:grid-cols-2 gap-4;
}
.recurrence-field {
@apply relative;
}
.recurrence-button {
@apply flex items-center justify-between w-full px-3 py-2 bg-neutral-100 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
@apply text-sm font-medium text-neutral-700 dark:text-neutral-300;
@apply hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-micro;
}
.recurrence-options {
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
}
.recurrence-option {
@apply px-3 py-2 cursor-pointer transition-colors duration-micro;
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
}
.interval-field {
@apply w-full;
}
.interval-input {
@apply w-full;
}
.form-actions {
@apply flex items-center justify-end gap-3 pt-6 border-t border-neutral-200 dark:border-neutral-700;
}
.receipt-scanner-modal {
@apply z-60;
}
.scanner-card {
@apply bg-white dark:bg-neutral-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden;
}
.scanner-header {
@apply p-6 border-b border-neutral-200 dark:border-neutral-700 space-y-4;
}
.scanner-title {
@apply text-xl font-semibold text-neutral-900 dark:text-neutral-100;
}
.scanner-content {
@apply p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-200px)];
}
.scanner-placeholder {
@apply text-center py-12 space-y-4;
}
.scanner-icon {
@apply w-16 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto;
}
.scanner-placeholder-title {
@apply text-2xl font-semibold text-neutral-900 dark:text-neutral-100;
}
.scanner-placeholder-description {
@apply text-sm text-neutral-600 dark:text-neutral-400;
}
.scanner-features {
@apply flex items-center justify-center gap-4;
}
.scanner-feature {
@apply flex items-center gap-2;
}
.feature-icon {
@apply w-4 h-4 text-neutral-500 dark:text-neutral-400;
}
.scanner-actions {
@apply flex justify-end mt-6;
}
.currency-symbol,
.percentage-symbol {
@apply text-neutral-500 dark:text-neutral-400 font-medium;
}
.progress-indicator {
@apply flex items-center gap-4;
}
.progress-track {
@apply h-2 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden;
}
.progress-fill {
@apply h-2 bg-primary-500 rounded-full;
}
.progress-text {
@apply text-sm text-neutral-500 dark:text-neutral-400;
}
.receipt-scanner-modal {
@apply z-60;
}
.scanner-preview {
@apply flex items-center justify-center;
}
.camera-placeholder {
@apply text-center py-12 space-y-4;
}
.capture-button {
@apply bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors duration-micro;
}
.scanner-help {
@apply text-sm text-neutral-500 dark:text-neutral-400;
}
.receipt-scanner-modal {
@apply z-60;
}
.scanner-preview {
@apply flex items-center justify-center;
}
.camera-placeholder {
@apply text-center py-12 space-y-4;
}
.capture-button {
@apply bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors duration-micro;
}
.scanner-help {
@apply text-sm text-neutral-500 dark:text-neutral-400;
}
</style>