feat: Add JWT auth and nomenclature sync to data-entry-app

Integrate shared JWT authentication into data-entry-app:
- Add Oracle pool initialization for auth service
- Add AuthenticationMiddleware to protect API routes
- Update all receipt endpoints to use CurrentUser from JWT
- Add shared auth router (/api/auth/login, /api/auth/refresh)

Add nomenclature synchronization feature:
- Create SQLite models for synced suppliers, local suppliers, and cash registers
- Add nomenclature router with sync triggers and CRUD endpoints
- Add sync service for Oracle → SQLite nomenclature data
- Update nomenclature_service to use synced SQLite data with fallbacks

Create shared frontend components:
- Add shared/frontend/ with LoginView.vue, auth store factory, login.css
- Integrate shared login and auth into data-entry-app frontend
- Add axios-based API service with token refresh interceptor

🤖 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-14 18:36:24 +02:00
parent 682a4b64b9
commit c5fde510a8
37 changed files with 28907 additions and 903 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="app-container">
<header class="app-header">
<header v-if="authStore.isAuthenticated" class="app-header">
<div class="header-content">
<h1 class="app-title">
<i class="pi pi-receipt"></i>
@@ -17,6 +17,19 @@
<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>
@@ -32,17 +45,28 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useReceiptsStore } from './stores/receiptsStore'
const router = useRouter()
const authStore = useAuthStore()
const receiptsStore = useReceiptsStore()
const pendingCount = ref(0)
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
onMounted(async () => {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
} catch (error) {
console.error('Failed to fetch stats:', error)
if (authStore.isAuthenticated) {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
} catch (error) {
console.error('Failed to fetch stats:', error)
}
}
})
</script>
@@ -83,6 +107,7 @@ onMounted(async () => {
.app-nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
.nav-link {
@@ -105,6 +130,33 @@ onMounted(async () => {
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;

View File

@@ -1,5 +1,28 @@
/* Global styles for Data Entry App */
:root {
/* Colors - Primary palette (matching reports-app) */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-light: #3b82f6;
/* Compatibility aliases */
--primary-color: var(--color-primary);
--text-color: #111827;
--text-color-secondary: #6b7280;
/* Surface colors for PrimeVue */
--surface-0: #ffffff;
--surface-50: #f8fafc;
--surface-100: #f1f5f9;
--surface-200: #e2e8f0;
/* Red color palette for errors */
--red-50: #fef2f2;
--red-200: #fecaca;
--red-800: #991b1b;
}
* {
box-sizing: border-box;
}

View File

@@ -11,6 +11,7 @@ import router from './router'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Password from 'primevue/password'
import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar'
import Textarea from 'primevue/textarea'
@@ -31,6 +32,7 @@ import ProgressSpinner from 'primevue/progressspinner'
import Badge from 'primevue/badge'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
import Tooltip from 'primevue/tooltip'
// PrimeVue styles
import 'primevue/resources/themes/lara-light-blue/theme.css'
@@ -57,6 +59,7 @@ app.use(ConfirmationService)
app.component('Button', Button)
app.component('InputText', InputText)
app.component('InputNumber', InputNumber)
app.component('Password', Password)
app.component('Dropdown', Dropdown)
app.component('Calendar', Calendar)
app.component('Textarea', Textarea)
@@ -78,4 +81,7 @@ app.component('Badge', Badge)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
// Register PrimeVue directives
app.directive('tooltip', Tooltip)
app.mount('#app')

View File

@@ -1,35 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue'),
meta: { title: 'Conectare', requiresAuth: false }
},
{
path: '/',
name: 'ReceiptsList',
component: () => import('../views/receipts/ReceiptsListView.vue'),
meta: { title: 'Lista Bonuri' }
meta: { title: 'Lista Bonuri', requiresAuth: true }
},
{
path: '/create',
name: 'ReceiptCreate',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Bon Nou' }
meta: { title: 'Bon Nou', requiresAuth: true }
},
{
path: '/receipt/:id',
name: 'ReceiptDetail',
component: () => import('../views/receipts/ReceiptDetailView.vue'),
meta: { title: 'Detalii Bon' }
meta: { title: 'Detalii Bon', requiresAuth: true }
},
{
path: '/receipt/:id/edit',
name: 'ReceiptEdit',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Editare Bon' }
meta: { title: 'Editare Bon', requiresAuth: true }
},
{
path: '/approval',
name: 'ReceiptApproval',
component: () => import('../views/receipts/ReceiptApprovalView.vue'),
meta: { title: 'Aprobare Bonuri' }
meta: { title: 'Aprobare Bonuri', requiresAuth: true }
}
]
@@ -38,12 +45,26 @@ const router = createRouter({
routes
})
// Update page title
// Authentication guard and page title
router.beforeEach((to, from, next) => {
// Update page title
document.title = to.meta.title
? `${to.meta.title} | Data Entry`
: 'Data Entry - Bonuri Fiscale'
next()
// Check authentication
const authStore = useAuthStore()
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth && !authStore.isAuthenticated) {
// Redirect to login if not authenticated
next({ name: 'Login', query: { redirect: to.fullPath } })
} else if (to.name === 'Login' && authStore.isAuthenticated) {
// Redirect to home if already authenticated
next({ name: 'ReceiptsList' })
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,82 @@
import axios from "axios";
// Create axios instance with base configuration
const apiService = axios.create({
baseURL: import.meta.env.BASE_URL + "api",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to add auth token
apiService.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response interceptor for handling errors and token refresh
apiService.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// Handle 401 Unauthorized errors
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const response = await axios.post(
import.meta.env.BASE_URL + "api/auth/refresh",
{ refresh_token: refreshToken },
);
const { access_token } = response.data;
localStorage.setItem("access_token", access_token);
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
originalRequest.headers["Authorization"] = `Bearer ${access_token}`;
return apiService(originalRequest);
}
} catch (refreshError) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
const loginPath = import.meta.env.BASE_URL + "login";
if (window.location.pathname !== loginPath) {
window.location.href = loginPath;
}
return Promise.reject(refreshError);
}
}
if (error.response) {
const message = error.response.data?.detail || error.response.data?.message || `Server error: ${error.response.status}`;
console.error("API Error:", { status: error.response.status, message, url: error.config.url });
} else if (error.request) {
console.error("Network Error:", error.message);
} else {
console.error("Request Error:", error.message);
}
return Promise.reject(error);
},
);
export { apiService };
export default apiService;

View File

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

View File

@@ -1,12 +1,13 @@
import { defineStore } from 'pinia'
import axios from 'axios'
import { apiService } from '../services/api'
const api = axios.create({
baseURL: '/api/receipts',
headers: {
'Content-Type': 'application/json',
},
})
// Create receipts-specific API wrapper
const api = {
get: (url, config) => apiService.get(`/receipts${url}`, config),
post: (url, data, config) => apiService.post(`/receipts${url}`, data, config),
put: (url, data, config) => apiService.put(`/receipts${url}`, data, config),
delete: (url, config) => apiService.delete(`/receipts${url}`, config),
}
export const useReceiptsStore = defineStore('receipts', {
state: () => ({
@@ -324,6 +325,33 @@ export const useReceiptsStore = defineStore('receipts', {
])
},
async searchSupplier(fiscalCode) {
try {
const response = await apiService.get('/nomenclature/suppliers/search', {
params: { fiscal_code: fiscalCode },
})
return response.data
} catch (error) {
console.error('Supplier search failed:', error)
return { found: false, source: 'error' }
}
},
async createLocalSupplier(data) {
try {
const response = await apiService.post('/nomenclature/suppliers/local', data)
// Add to local partners list
this.partners.push({
id: response.data.id,
name: response.data.name,
code: response.data.fiscal_code,
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to create supplier')
}
},
// ============ Stats ============
async fetchStats() {

View File

@@ -0,0 +1,16 @@
<template>
<SharedLoginView
app-title="Data Entry"
app-subtitle="Introducere Bonuri Fiscale"
app-icon="pi-file-edit"
redirect-path="/"
:auth-store="authStore"
/>
</template>
<script setup>
import SharedLoginView from "@shared/frontend/components/LoginView.vue";
import { useAuthStore } from "../stores/auth";
const authStore = useAuthStore();
</script>

View File

@@ -328,6 +328,42 @@
</div>
</form>
</div>
<!-- Create Supplier Dialog -->
<Dialog
v-model:visible="showCreateSupplierDialog"
header="Furnizor Negasit"
:modal="true"
:style="{ width: '450px' }"
>
<div class="dialog-content">
<p>
<i class="pi pi-exclamation-triangle" style="color: var(--orange-500);"></i>
Furnizorul cu CUI <strong>{{ pendingSupplierData?.fiscal_code }}</strong> nu a fost gasit in baza de date.
</p>
<p>Doriti sa creati un furnizor local cu datele extrase din bon?</p>
<div class="form-field" style="margin-top: 1rem;">
<label>Nume Furnizor</label>
<InputText v-model="pendingSupplierData.name" class="w-full" />
</div>
<div class="form-field">
<label>CUI</label>
<InputText v-model="pendingSupplierData.fiscal_code" class="w-full" disabled />
</div>
<div class="form-field">
<label>Adresa</label>
<InputText v-model="pendingSupplierData.address" class="w-full" />
</div>
</div>
<template #footer>
<Button label="Anuleaza" severity="secondary" @click="cancelCreateSupplier" />
<Button label="Creaza Furnizor" icon="pi pi-plus" @click="createLocalSupplier" />
</template>
</Dialog>
</div>
</template>
@@ -338,6 +374,7 @@ import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue'
import Dialog from 'primevue/dialog'
const route = useRoute()
const router = useRouter()
@@ -379,6 +416,10 @@ const ocrUploadZone = ref(null)
const ocrData = ref(null)
const ocrFile = ref(null)
// Supplier dialog refs
const showCreateSupplierDialog = ref(false)
const pendingSupplierData = ref(null)
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
const cashRegisters = computed(() => store.cashRegisters)
@@ -452,42 +493,21 @@ const onOCRError = (message) => {
})
}
const applyOCRData = (data) => {
// Apply OCR data to form
const applyOCRData = async (data) => {
// Apply basic OCR data to form
if (data.receipt_type) {
form.value.receipt_type = data.receipt_type
}
if (data.receipt_date) {
form.value.receipt_date = new Date(data.receipt_date)
}
if (data.amount) {
form.value.amount = parseFloat(data.amount)
}
if (data.receipt_number) {
form.value.receipt_number = data.receipt_number
}
// Try to find matching partner by name or CUI
if (data.partner_name || data.cui) {
const matchingPartner = partners.value.find(p => {
const nameMatch = data.partner_name &&
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
const cuiMatch = data.cui && p.cui === data.cui
return nameMatch || cuiMatch
})
if (matchingPartner) {
form.value.partner_id = matchingPartner.id
form.value.partner_name = matchingPartner.name
} else if (data.partner_name) {
// Store the extracted name even if no match
form.value.partner_name = data.partner_name
}
}
// Apply TVA entries
if (data.tva_entries?.length > 0) {
form.value.tva_breakdown = data.tva_entries.map(e => ({
@@ -496,14 +516,52 @@ const applyOCRData = (data) => {
amount: parseFloat(e.amount)
}))
}
if (data.tva_total) {
form.value.tva_total = parseFloat(data.tva_total)
}
if (data.items_count) {
form.value.items_count = data.items_count
}
if (data.address) {
form.value.vendor_address = data.address
if (data.tva_total) form.value.tva_total = parseFloat(data.tva_total)
if (data.items_count) form.value.items_count = data.items_count
if (data.address) form.value.vendor_address = data.address
// Auto-search supplier by CUI if available
if (data.cui) {
toast.add({
severity: 'info',
summary: 'Cautare furnizor',
detail: `Se cauta furnizor dupa CUI: ${data.cui}`,
life: 2000,
})
const result = await store.searchSupplier(data.cui)
if (result.found && result.supplier) {
// Found! Auto-select
form.value.partner_id = result.supplier.id
form.value.partner_name = result.supplier.name
toast.add({
severity: 'success',
summary: 'Furnizor gasit',
detail: `${result.supplier.name} (${result.source})`,
life: 3000,
})
} else {
// Not found - offer to create
pendingSupplierData.value = {
name: data.partner_name || '',
fiscal_code: data.cui,
address: data.address || '',
}
showCreateSupplierDialog.value = true
}
} else if (data.partner_name) {
// No CUI but have name - try name search
const matchingPartner = partners.value.find(p =>
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
)
if (matchingPartner) {
form.value.partner_id = matchingPartner.id
form.value.partner_name = matchingPartner.name
} else {
form.value.partner_name = data.partner_name
}
}
// Clear OCR preview
@@ -521,6 +579,40 @@ const dismissOCRData = () => {
ocrData.value = null
}
const createLocalSupplier = async () => {
if (!pendingSupplierData.value) return
try {
const supplier = await store.createLocalSupplier(pendingSupplierData.value)
// Auto-select the new supplier
form.value.partner_id = supplier.id
form.value.partner_name = supplier.name
toast.add({
severity: 'success',
summary: 'Furnizor creat',
detail: `${supplier.name} a fost adaugat`,
life: 3000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
showCreateSupplierDialog.value = false
pendingSupplierData.value = null
}
}
const cancelCreateSupplier = () => {
showCreateSupplierDialog.value = false
pendingSupplierData.value = null
}
const onPartnerChange = (event) => {
const partner = partners.value.find(p => p.id === event.value)
form.value.partner_name = partner?.name || null
@@ -853,4 +945,33 @@ const submitForReview = async () => {
font-weight: 600;
color: #0284c7;
}
/* Dialog content */
.dialog-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog-content p {
margin: 0;
line-height: 1.6;
}
.dialog-content p:first-child {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.dialog-content .form-field {
margin-bottom: 0.5rem;
}
.dialog-content .form-field label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: #334155;
}
</style>

View File

@@ -6,12 +6,17 @@ export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../../shared', import.meta.url))
}
},
server: {
port: 3010,
proxy: {
'/api/auth': {
target: 'http://localhost:8001',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,