feat: Add shared components, refactor stores, improve data-entry workflow

Shared Components:
- Add CompanySelector.vue and PeriodSelector.vue components
- Add AppHeader.vue and SlideMenu.vue layout components
- Add shared stores factories (companies.js, accountingPeriod.js)
- Add shared routes factories (companies.py, calendar.py)
- Add shared models (company.py, calendar.py)
- Add shared layout styles (header.css, navigation.css)

Data Entry App:
- Update CLAUDE.md with prod/test server documentation
- Improve nomenclature sync service with better error handling
- Update receipts router and CRUD operations
- Add company/period stores using shared factories
- Update App.vue layout with shared components
- Fix OCRUploadZone file handling

Reports App:
- Refactor stores to use shared factories
- Update App.vue to use shared layout components

Infrastructure:
- Replace start-data-entry.sh with separate dev/test scripts
- Add .claude/rules for authentication, backend patterns, etc.
- Add implementation plan for OCR receipt improvements
- Clean up old documentation files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 15:00:45 +02:00
parent c5fde510a8
commit 1a6e9b17d2
47 changed files with 4079 additions and 2595 deletions

View File

@@ -1,38 +1,33 @@
<template>
<div class="app-container">
<header v-if="authStore.isAuthenticated" class="app-header">
<div class="header-content">
<h1 class="app-title">
<i class="pi pi-receipt"></i>
Data Entry - Bonuri Fiscale
</h1>
<nav class="app-nav">
<router-link to="/" class="nav-link">
<i class="pi pi-list"></i> Lista Bonuri
</router-link>
<router-link to="/create" class="nav-link">
<i class="pi pi-plus"></i> Bon Nou
</router-link>
<router-link to="/approval" class="nav-link">
<i class="pi pi-check-circle"></i> Aprobare
<Badge v-if="pendingCount > 0" :value="pendingCount" severity="danger" />
</router-link>
<div class="user-menu">
<span class="user-name">
<i class="pi pi-user"></i>
{{ authStore.currentUser?.username || 'User' }}
</span>
<Button
icon="pi pi-sign-out"
label="Ieșire"
class="logout-button"
@click="handleLogout"
text
/>
</div>
</nav>
</div>
</header>
<AppHeader
v-if="authStore.isAuthenticated"
title="Data Entry"
brand-link="/"
header-class="header-container--gradient"
:menu-open="menuOpen"
:companies-store="companyStore"
:period-store="periodStore"
:current-user="authStore.currentUser"
:show-user="false"
@menu-toggle="menuOpen = !menuOpen"
@company-changed="onCompanyChanged"
@period-changed="onPeriodChanged"
>
<template #brand>
<i class="pi pi-receipt"></i>
<span>Data Entry</span>
</template>
</AppHeader>
<SlideMenu
v-if="authStore.isAuthenticated"
:is-open="menuOpen"
:menu-items="dataEntryMenuItems"
:current-user="authStore.currentUser"
@close="menuOpen = false"
@logout="handleLogout"
/>
<main class="app-main">
<router-view />
@@ -44,23 +39,74 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useCompanyStore } from './stores/companies'
import { useAccountingPeriodStore } from './stores/accountingPeriod'
import { useReceiptsStore } from './stores/receiptsStore'
import apiService from './services/api'
import AppHeader from '../../../shared/frontend/components/layout/AppHeader.vue'
import SlideMenu from '../../../shared/frontend/components/layout/SlideMenu.vue'
const router = useRouter()
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
const receiptsStore = useReceiptsStore()
const menuOpen = ref(false)
const pendingCount = ref(0)
// Menu items configuration
const dataEntryMenuItems = computed(() => [
{
title: 'Navigare',
items: [
{ to: '/', icon: 'pi pi-list', label: 'Lista Bonuri' },
{ to: '/create', icon: 'pi pi-plus', label: 'Bon Nou' },
{
to: '/approval',
icon: 'pi pi-check-circle',
label: 'Aprobare',
badge: pendingCount.value > 0 ? pendingCount.value : null
},
]
}
])
const handleLogout = () => {
authStore.logout()
companyStore.reset()
periodStore.reset()
router.push('/login')
}
onMounted(async () => {
if (authStore.isAuthenticated) {
const onCompanyChanged = async (company) => {
console.log('[App] Company changed:', company?.name)
// Trigger nomenclature sync for the selected company (non-blocking)
if (company?.id_firma) {
apiService.post('/nomenclature/sync/all', null, {
headers: { 'X-Selected-Company': company.id_firma }
}).then(() => {
console.log('[App] Nomenclature sync completed for company:', company.name)
}).catch(e => {
console.warn('[App] Nomenclature sync failed:', e.message || e)
})
}
// Refresh stats when company changes
await refreshStats()
}
const onPeriodChanged = (period) => {
console.log('[App] Period changed:', period?.display_name)
// Refresh data when period changes
refreshStats()
}
const refreshStats = async () => {
if (authStore.isAuthenticated && companyStore.selectedCompany) {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
@@ -68,7 +114,38 @@ onMounted(async () => {
console.error('Failed to fetch stats:', error)
}
}
}
onMounted(async () => {
if (authStore.isAuthenticated) {
// Load companies first
await companyStore.loadCompanies()
// If company is selected, trigger initial sync and load stats
if (companyStore.selectedCompany) {
// Sync nomenclatures for current company (background, non-blocking)
apiService.post('/nomenclature/sync/all', null, {
headers: { 'X-Selected-Company': companyStore.selectedCompany.id_firma }
}).then(() => {
console.log('[App] Initial nomenclature sync completed')
}).catch(e => {
console.warn('[App] Initial nomenclature sync skipped:', e.message || e)
})
await refreshStats()
}
}
})
// Watch for company selection to refresh stats
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await refreshStats()
}
}
)
</script>
<style scoped>
@@ -78,85 +155,6 @@ onMounted(async () => {
flex-direction: column;
}
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.app-title {
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.app-nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
font-weight: 500;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.nav-link.router-link-active {
background-color: rgba(255, 255, 255, 0.3);
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
margin-left: 1rem;
padding-left: 1rem;
border-left: 1px solid rgba(255, 255, 255, 0.3);
}
.user-name {
display: flex;
align-items: center;
gap: 0.5rem;
color: white;
font-weight: 500;
font-size: 0.9rem;
}
.logout-button {
color: white !important;
padding: 0.5rem 1rem;
}
.logout-button:hover {
background-color: rgba(255, 255, 255, 0.2) !important;
}
.app-main {
flex: 1;
padding: 2rem;
@@ -164,16 +162,6 @@ onMounted(async () => {
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
}
.app-nav {
flex-wrap: wrap;
justify-content: center;
}
.app-main {
padding: 1rem;
}

View File

@@ -1,6 +1,42 @@
/* Global styles for Data Entry App */
/* Import shared layout styles */
@import '../../../../../shared/frontend/styles/layout/header.css';
@import '../../../../../shared/frontend/styles/layout/navigation.css';
:root {
/* Layout variables */
--header-height: 60px;
--sidebar-width: 280px;
--z-header: 100;
--z-modal: 1000;
--z-modal-backdrop: 999;
/* Shadows */
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
/* Typography */
--font-semibold: 600;
--font-medium: 500;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
/* Radius */
--radius-md: 6px;
--radius-full: 9999px;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 24px;
/* Colors - Primary palette (matching reports-app) */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;

View File

@@ -71,7 +71,7 @@
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import api from '@/services/api'
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
@@ -137,7 +137,7 @@ const processOCR = async () => {
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await axios.post('/api/ocr/extract', formData, {
const response = await api.post('/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60 second timeout for OCR
})

View File

@@ -33,6 +33,7 @@ import Badge from 'primevue/badge'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
import Tooltip from 'primevue/tooltip'
import Message from 'primevue/message'
// PrimeVue styles
import 'primevue/resources/themes/lara-light-blue/theme.css'
@@ -80,6 +81,7 @@ app.component('ProgressSpinner', ProgressSpinner)
app.component('Badge', Badge)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
app.component('Message', Message)
// Register PrimeVue directives
app.directive('tooltip', Tooltip)

View File

@@ -9,13 +9,31 @@ const apiService = axios.create({
},
});
// Request interceptor to add auth token
// Request interceptor to add auth token and selected company
apiService.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add X-Selected-Company header from localStorage
// The company store saves the selected company per user
const user = JSON.parse(localStorage.getItem("user") || "null");
if (user?.username) {
const savedCompany = localStorage.getItem(`selected_company_${user.username}`);
if (savedCompany) {
try {
const company = JSON.parse(savedCompany);
if (company?.id_firma) {
config.headers["X-Selected-Company"] = company.id_firma;
}
} catch (e) {
// Invalid JSON, ignore
}
}
}
return config;
},
(error) => {

View File

@@ -0,0 +1,17 @@
/**
* Accounting Period Store for Data Entry App
*
* Uses the shared accounting period store factory from shared/frontend/stores/accountingPeriod.js
* Configured with the data-entry API service (port 8003)
*/
import { createAccountingPeriodStore } from "../../../../shared/frontend/stores/accountingPeriod";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
import { useCompanyStore } from "./companies";
export const useAccountingPeriodStore = createAccountingPeriodStore(
apiService,
useAuthStore,
useCompanyStore
);

View File

@@ -0,0 +1,12 @@
/**
* Companies Store for Data Entry App
*
* Uses the shared companies store factory from shared/frontend/stores/companies.js
* Configured with the data-entry API service (port 8003)
*/
import { createCompaniesStore } from "../../../../shared/frontend/stores/companies";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);

View File

@@ -372,6 +372,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import { useCompanyStore } from '../../stores/companies'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue'
import Dialog from 'primevue/dialog'
@@ -380,11 +381,17 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const companyStore = useCompanyStore()
const isEditMode = computed(() => !!route.params.id)
const receiptId = computed(() => route.params.id)
const receipt = ref(null)
// Get selected company ID from store
const getSelectedCompanyId = () => {
return companyStore.selectedCompanyId || 1
}
const form = ref({
receipt_type: 'bon_fiscal',
direction: 'cheltuiala',
@@ -398,7 +405,7 @@ const form = ref({
cash_register_account: null,
receipt_number: '',
description: '',
company_id: 1, // Default company for Phase 1
company_id: getSelectedCompanyId(),
// TVA info (multiple entries support)
tva_breakdown: [], // Array of {code, percent, amount}
tva_total: null,
@@ -429,6 +436,9 @@ onMounted(async () => {
if (isEditMode.value) {
await loadReceipt()
} else {
// For new receipts, ensure company_id is set from the current selected company
form.value.company_id = companyStore.selectedCompanyId || 1
}
})