feat(data-entry): Bulk Receipt Upload cu Mobile UX Android Nativ
## Funcționalități Principale ### Bulk Upload & Processing - Drag & drop pentru upload bonuri multiple oriunde pe pagină - Batch processing cu job queue și worker pool - Real-time updates via SSE (Server-Sent Events) cu fallback polling - Duplicate detection via SHA-256 file hash - Auto-retry pentru job-uri failed - Cancel individual jobs sau batch complet ### Mobile UX - Android Native Style - Top bar fixă cu hamburger, titlu centrat, acțiuni (search/filter) - Bottom navigation cu 4 tab-uri (Bonuri, Upload, Rapoarte, Setări) - FAB (Floating Action Button) cu hide/show on scroll - Filter chips orizontal scrollabile - Selecție multiplă prin long-press (500ms) - Select All + Bulk Delete cu confirmare - Layout Android pentru Create/Edit/View bon (Gmail compose style) ### Bug Fixes - Refresh individual via SSE în loc de refresh total pagină - Bonurile cu eroare OCR rămân vizibile pentru editare manuală - Afișare nume fișier original pentru toate bonurile - Upload stabil pe mobil (fix race condition File API) - Păstrare ordine bonuri la refresh (nu se reordonează) ### Backend - SSE endpoint pentru status updates real-time - Bulk delete endpoint cu partial success - Auto-cleanup bonuri failed după 7 zile - Batch model cu tracking complet ### Testing - E2E tests cu Playwright - Unit tests pentru bulk upload, auto-create, cleanup ## Commits Squashed: 43 user stories (US-001 → US-043) ## Branch: ralph/bulk-receipt-upload ## Timp dezvoltare: ~3 zile (Ralph autonomous) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,80 @@
|
||||
<template>
|
||||
<div class="receipt-unified-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<div class="receipt-unified-view" :class="{ 'mobile-compose-layout': isMobile }">
|
||||
<!-- US-041/US-042: Mobile Top Bar - Gmail Style -->
|
||||
<header v-if="isMobile" class="mobile-compose-top-bar">
|
||||
<div class="top-bar-left">
|
||||
<Button
|
||||
:icon="isViewMode ? 'pi pi-arrow-left' : 'pi pi-times'"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="goBack"
|
||||
aria-label="Înapoi"
|
||||
/>
|
||||
<span class="top-bar-title">{{ mobileTitle }}</span>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<!-- Create/Edit mode icons -->
|
||||
<Button
|
||||
v-if="!isViewMode"
|
||||
icon="pi pi-paperclip"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="triggerFileAttach"
|
||||
aria-label="Atașează fișier"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isViewMode"
|
||||
icon="pi pi-save"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
:loading="saving"
|
||||
@click="saveReceipt"
|
||||
aria-label="Salvează"
|
||||
/>
|
||||
<!-- US-042: View mode icons - edit, delete, more menu -->
|
||||
<Button
|
||||
v-if="isViewMode && (receipt?.status === 'draft' || receipt?.status === 'rejected')"
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="goToEdit"
|
||||
aria-label="Editează"
|
||||
/>
|
||||
<Button
|
||||
v-if="isViewMode && canDelete"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn top-bar-btn-danger"
|
||||
@click="confirmDelete"
|
||||
aria-label="Șterge"
|
||||
/>
|
||||
<Button
|
||||
v-if="isViewMode"
|
||||
icon="pi pi-ellipsis-v"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="toggleMoreMenu"
|
||||
aria-label="Mai multe opțiuni"
|
||||
aria-haspopup="true"
|
||||
aria-controls="more_menu"
|
||||
/>
|
||||
<Menu
|
||||
ref="moreMenuRef"
|
||||
id="more_menu"
|
||||
:model="moreMenuItems"
|
||||
:popup="true"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Desktop Header -->
|
||||
<div v-if="!isMobile" class="view-header">
|
||||
<div class="header-left">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
@@ -183,19 +256,103 @@
|
||||
<Button label="Creaza" icon="pi pi-plus" @click="createLocalSupplier" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- US-041: Mobile Bottom Action Bar - Gmail Compose Style -->
|
||||
<footer v-if="isMobile && !isViewMode" class="mobile-form-bottom-bar" :class="{ 'keyboard-visible': keyboardVisible }">
|
||||
<Button
|
||||
label="Salvează Ciornă"
|
||||
icon="pi pi-save"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="bottom-bar-btn"
|
||||
:loading="saving"
|
||||
@click="saveReceipt"
|
||||
/>
|
||||
<Button
|
||||
label="Trimite pentru Validare"
|
||||
icon="pi pi-send"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitForReviewMobile"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
<!-- US-042: Mobile View Mode Bottom Bar - Contextual buttons by status -->
|
||||
<footer v-if="isMobile && isViewMode && receipt" class="mobile-form-bottom-bar">
|
||||
<!-- DRAFT status: Editează / Trimite -->
|
||||
<Button
|
||||
v-if="receipt.status === 'draft'"
|
||||
label="Editează"
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="bottom-bar-btn"
|
||||
@click="goToEdit"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt.status === 'draft'"
|
||||
label="Trimite"
|
||||
icon="pi pi-send"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="submitting"
|
||||
@click="submitReceipt"
|
||||
/>
|
||||
<!-- PENDING status: Validează / Respinge -->
|
||||
<Button
|
||||
v-if="receipt.status === 'pending_review'"
|
||||
label="Validează"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="approving"
|
||||
@click="approveReceipt"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt.status === 'pending_review'"
|
||||
label="Respinge"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
class="bottom-bar-btn"
|
||||
@click="openRejectDialog"
|
||||
/>
|
||||
<!-- APPROVED status: Anulează -->
|
||||
<Button
|
||||
v-if="receipt.status === 'approved'"
|
||||
label="Anulează Validarea"
|
||||
icon="pi pi-replay"
|
||||
severity="warning"
|
||||
class="bottom-bar-btn"
|
||||
:loading="cancelling"
|
||||
@click="confirmCancelApproval"
|
||||
/>
|
||||
<!-- REJECTED status: Editează -->
|
||||
<Button
|
||||
v-if="receipt.status === 'rejected'"
|
||||
label="Editează"
|
||||
icon="pi pi-pencil"
|
||||
severity="primary"
|
||||
class="bottom-bar-btn"
|
||||
@click="goToEdit"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import Message from 'primevue/message'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
|
||||
import { useCompanyStore } from '@data-entry/stores/sharedStores'
|
||||
@@ -212,9 +369,33 @@ import {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const store = useReceiptsStore()
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
// US-041: Mobile detection and keyboard awareness
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const keyboardVisible = ref(false)
|
||||
const initialViewportHeight = ref(window.innerHeight)
|
||||
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
|
||||
// US-041: Detect virtual keyboard on mobile
|
||||
// When keyboard opens, viewport height decreases significantly
|
||||
if (isMobile.value) {
|
||||
const heightDiff = initialViewportHeight.value - window.visualViewport?.height || window.innerHeight
|
||||
keyboardVisible.value = heightDiff > 150 // Keyboard typically takes 150px+ of space
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisualViewportResize = () => {
|
||||
if (!isMobile.value) return
|
||||
const currentHeight = window.visualViewport?.height || window.innerHeight
|
||||
const heightDiff = initialViewportHeight.value - currentHeight
|
||||
keyboardVisible.value = heightDiff > 150
|
||||
}
|
||||
|
||||
// Mode detection
|
||||
const isViewMode = computed(() => !!route.params.id && !route.path.endsWith('/edit'))
|
||||
const isEditMode = computed(() => !!route.params.id && route.path.endsWith('/edit'))
|
||||
@@ -231,6 +412,62 @@ const modeIcon = computed(() => {
|
||||
return 'pi pi-plus-circle'
|
||||
})
|
||||
|
||||
// US-041: Mobile title - shorter for top bar
|
||||
const mobileTitle = computed(() => {
|
||||
if (isViewMode.value) return `Bon #${receipt.value?.id || ''}`
|
||||
if (isEditMode.value) return 'Editare Bon'
|
||||
return 'Bon Nou'
|
||||
})
|
||||
|
||||
// US-041: Can submit validation for mobile
|
||||
const canSubmit = computed(() => {
|
||||
return form.value.amount && form.value.amount > 0 && form.value.receipt_date
|
||||
})
|
||||
|
||||
// US-042: Can delete - allow delete for draft/rejected, not for approved/pending
|
||||
const canDelete = computed(() => {
|
||||
if (!receipt.value) return false
|
||||
return ['draft', 'rejected'].includes(receipt.value.status)
|
||||
})
|
||||
|
||||
// US-042: More menu items - contextual based on status
|
||||
const moreMenuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
if (!receipt.value) return items
|
||||
|
||||
// Always available: view attachments
|
||||
if (existingAttachments.value.length > 0) {
|
||||
items.push({
|
||||
label: 'Vizualizează Atașamente',
|
||||
icon: 'pi pi-images',
|
||||
command: () => scrollToAttachments()
|
||||
})
|
||||
}
|
||||
|
||||
// Share option
|
||||
items.push({
|
||||
label: 'Partajează',
|
||||
icon: 'pi pi-share-alt',
|
||||
command: () => shareReceipt()
|
||||
})
|
||||
|
||||
// Separator before dangerous actions
|
||||
items.push({ separator: true })
|
||||
|
||||
// Delete option (if allowed)
|
||||
if (canDelete.value) {
|
||||
items.push({
|
||||
label: 'Șterge Bonul',
|
||||
icon: 'pi pi-trash',
|
||||
class: 'p-menuitem-danger',
|
||||
command: () => confirmDelete()
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Form state
|
||||
const form = ref(getDefaultUnifiedFormState())
|
||||
const receipt = ref(null)
|
||||
@@ -248,8 +485,12 @@ const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const approving = ref(false)
|
||||
const rejecting = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const syncingSuppliers = ref(false)
|
||||
|
||||
// US-042: More menu ref
|
||||
const moreMenuRef = ref(null)
|
||||
|
||||
// Supplier state
|
||||
const supplierSource = ref(null)
|
||||
const supplierWarning = ref({ show: false, cui: '', name: '' })
|
||||
@@ -301,6 +542,13 @@ const getStatusSeverity = (status) => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// US-041: Mobile event listeners
|
||||
window.addEventListener('resize', handleResize)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleVisualViewportResize)
|
||||
}
|
||||
initialViewportHeight.value = window.innerHeight
|
||||
|
||||
// Load nomenclatures
|
||||
await store.fetchAllNomenclatures()
|
||||
|
||||
@@ -318,6 +566,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// US-041: Cleanup event listeners
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleVisualViewportResize)
|
||||
}
|
||||
})
|
||||
|
||||
// Load existing receipt
|
||||
const loadReceipt = async () => {
|
||||
try {
|
||||
@@ -768,6 +1024,219 @@ const formatDateTime = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString('ro-RO')
|
||||
}
|
||||
|
||||
// US-041: Mobile-specific functions
|
||||
const triggerFileAttach = () => {
|
||||
// Trigger the file input in the UnifiedReceiptForm component
|
||||
unifiedFormRef.value?.resetUpload?.()
|
||||
// Open file dialog via hidden input
|
||||
const fileInput = document.querySelector('.unified-receipt-form .hidden-input')
|
||||
if (fileInput) {
|
||||
fileInput.click()
|
||||
}
|
||||
}
|
||||
|
||||
const submitForReviewMobile = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
// For create mode, save first then submit
|
||||
if (isCreateMode.value) {
|
||||
saving.value = true
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
// Auto-create supplier if warning shown
|
||||
if (supplierWarning.value.show && form.value.cui) {
|
||||
try {
|
||||
await store.createLocalSupplier({
|
||||
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
|
||||
fiscal_code: form.value.cui,
|
||||
address: form.value.vendor_address || null,
|
||||
})
|
||||
supplierWarning.value = { show: false, cui: '', name: '' }
|
||||
supplierSource.value = 'local'
|
||||
} catch (e) {
|
||||
console.warn('Auto-create supplier failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = unifiedFormToApiPayload(form.value)
|
||||
const savedReceipt = await store.createReceipt(payload)
|
||||
|
||||
// Upload files
|
||||
for (const file of selectedFiles.value) {
|
||||
try {
|
||||
await store.uploadAttachment(savedReceipt.id, file)
|
||||
} catch (e) {
|
||||
console.warn(`Upload failed: ${file.name}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Now submit for review
|
||||
const result = await store.submitReceipt(savedReceipt.id)
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost trimis spre aprobare',
|
||||
life: 3000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: result.message,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut trimite bonul',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
} else {
|
||||
// Edit mode - use existing submitForReview
|
||||
await submitForReview()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
confirm.require({
|
||||
message: 'Sigur doriți să ștergeți acest bon?',
|
||||
header: 'Confirmare Ștergere',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await store.deleteReceipt(route.params.id)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost șters',
|
||||
life: 2000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut șterge bonul',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// US-042: More menu toggle
|
||||
const toggleMoreMenu = (event) => {
|
||||
moreMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// US-042: Helper functions for more menu
|
||||
const scrollToAttachments = () => {
|
||||
const attachmentsSection = document.querySelector('.attachments-section')
|
||||
if (attachmentsSection) {
|
||||
attachmentsSection.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const shareReceipt = async () => {
|
||||
if (navigator.share && receipt.value) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: `Bon #${receipt.value.id}`,
|
||||
text: `Bon fiscal - ${receipt.value.partner_name || 'Furnizor'} - ${receipt.value.amount} RON`,
|
||||
url: window.location.href
|
||||
})
|
||||
} catch (error) {
|
||||
// User cancelled sharing or share failed
|
||||
if (error.name !== 'AbortError') {
|
||||
// Fallback: copy URL to clipboard
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Link Copiat',
|
||||
detail: 'Link-ul a fost copiat în clipboard',
|
||||
life: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers without Web Share API
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Link Copiat',
|
||||
detail: 'Link-ul a fost copiat în clipboard',
|
||||
life: 2000,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-a putut copia link-ul',
|
||||
life: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// US-042: Cancel approval functionality
|
||||
const confirmCancelApproval = () => {
|
||||
confirm.require({
|
||||
message: 'Sigur doriți să anulați validarea acestui bon? Bonul va reveni la status "Ciornă".',
|
||||
header: 'Confirmare Anulare Validare',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-warning',
|
||||
acceptLabel: 'Da, Anulează',
|
||||
rejectLabel: 'Nu',
|
||||
accept: async () => {
|
||||
await cancelApproval()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const cancelApproval = async () => {
|
||||
cancelling.value = true
|
||||
try {
|
||||
// Use existing unapproveReceipt from store
|
||||
const result = await store.unapproveReceipt(route.params.id)
|
||||
if (result.success !== false) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Validarea a fost anulată. Bonul a revenit la status Ciornă.',
|
||||
life: 3000,
|
||||
})
|
||||
await loadReceipt()
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: result.message || 'Nu s-a putut anula validarea',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut anula validarea',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -868,4 +1337,205 @@ const formatDateTime = (dateStr) => {
|
||||
[data-theme="dark"] .view-header {
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* US-041: Mobile Gmail Compose Layout
|
||||
* Similar to Gmail's email compose interface
|
||||
* PRD Mobile Layout Tokens:
|
||||
* - topBarHeight: 56px
|
||||
* - bottomNavHeight: 56px
|
||||
* - touchTargetMin: 48px
|
||||
* ======================================== */
|
||||
|
||||
/* Mobile compose layout container */
|
||||
.receipt-unified-view.mobile-compose-layout {
|
||||
padding: 0;
|
||||
padding-top: 56px; /* Space for fixed top bar */
|
||||
padding-bottom: 80px; /* Space for fixed bottom action bar */
|
||||
max-width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
/* Hide desktop header on mobile */
|
||||
.receipt-unified-view.mobile-compose-layout .view-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile Top Bar - Gmail Compose Style */
|
||||
.mobile-compose-top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-xs);
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-left,
|
||||
.mobile-compose-top-bar .top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px; /* Touch target minimum */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-500);
|
||||
}
|
||||
|
||||
/* Mobile Bottom Action Bar - Gmail Compose Style */
|
||||
.mobile-form-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
/* Safe area for iOS notch/home indicator */
|
||||
padding-bottom: max(var(--space-sm), env(safe-area-inset-bottom));
|
||||
transition: transform var(--transition-normal), opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Keyboard-aware: move bar above keyboard */
|
||||
.mobile-form-bottom-bar.keyboard-visible {
|
||||
position: absolute;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
/* Buttons in bottom bar - equal width */
|
||||
.mobile-form-bottom-bar .bottom-bar-btn {
|
||||
flex: 1;
|
||||
min-height: 48px; /* Touch target minimum */
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Primary button emphasis */
|
||||
.mobile-form-bottom-bar .bottom-bar-btn.p-button-success {
|
||||
flex: 1.2; /* Slightly wider for primary action */
|
||||
}
|
||||
|
||||
/* Mobile content area adjustments */
|
||||
.receipt-unified-view.mobile-compose-layout .rejection-message {
|
||||
margin: var(--space-sm);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.receipt-unified-view.mobile-compose-layout .validation-banners {
|
||||
margin: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Form inside mobile layout - 100% width */
|
||||
.receipt-unified-view.mobile-compose-layout :deep(.unified-receipt-form) {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.receipt-unified-view.mobile-compose-layout :deep(.form-body) {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Dark Mode Support for Mobile Layout
|
||||
* ======================================== */
|
||||
[data-theme="dark"] .mobile-compose-top-bar {
|
||||
background: var(--surface-card);
|
||||
border-bottom-color: var(--surface-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-form-bottom-bar {
|
||||
background: var(--surface-card);
|
||||
border-top-color: var(--surface-border);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* System Preference Dark Mode Support
|
||||
* ======================================== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar {
|
||||
background: var(--surface-card);
|
||||
border-bottom-color: var(--surface-border);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-form-bottom-bar {
|
||||
background: var(--surface-card);
|
||||
border-top-color: var(--surface-border);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user