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,25 +1,79 @@
<template>
<div id="app">
<!-- New Navigation System -->
<DashboardHeader
<!-- Shared Header Component -->
<AppHeader
v-if="authStore.isAuthenticated"
title="ROA2WEB"
brand-link="/dashboard"
:menu-open="menuOpen"
@menu-toggle="handleMenuToggle"
:companies-store="companyStore"
:period-store="periodStore"
:current-user="authStore.currentUser"
:show-user="true"
@menu-toggle="menuOpen = !menuOpen"
@company-changed="handleCompanyChanged"
/>
@period-changed="handlePeriodChanged"
@user-menu-toggle="handleUserMenuToggle"
>
<template #user-menu>
<div class="user-menu-container mobile-hide">
<div class="header-user" @click="toggleUserMenu">
<i class="pi pi-user"></i>
<span class="desktop-only">{{ authStore.currentUser?.username || "User" }}</span>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': userMenuOpen }"></i>
</div>
<!-- Hamburger Menu -->
<HamburgerMenu
<!-- User Dropdown Menu -->
<div v-if="userMenuOpen" class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-info">
<div class="user-name">{{ authStore.currentUser?.username || "User" }}</div>
<div class="user-email">{{ authStore.currentUser?.email || "" }}</div>
</div>
</div>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="navigateToTelegram">
<i class="pi pi-telegram"></i>
<span>Telegram Bot</span>
</button>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="handleLogout">
<i class="pi pi-sign-out"></i>
<span>Logout</span>
</button>
</div>
</div>
</template>
</AppHeader>
<!-- Shared Slide Menu -->
<SlideMenu
v-if="authStore.isAuthenticated"
:is-open="menuOpen"
@close="handleMenuClose"
/>
:menu-items="reportsMenuItems"
:current-user="authStore.currentUser"
@close="menuOpen = false"
@logout="handleLogout"
>
<template #profile-items>
<li class="menu-item">
<router-link
to="/telegram"
class="menu-link"
@click="menuOpen = false"
>
<i class="menu-icon pi pi-telegram"></i>
<span>Telegram Bot</span>
</router-link>
</li>
</template>
</SlideMenu>
<!-- User Menu Overlay -->
<div v-if="userMenuOpen" class="user-menu-overlay" @click="closeUserMenu"></div>
<!-- Main Content -->
<main
class="main-content"
:class="{ 'with-navbar': authStore.isAuthenticated }"
>
<main class="main-content" :class="{ 'with-navbar': authStore.isAuthenticated }">
<router-view />
</main>
@@ -36,24 +90,49 @@ import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useCompanyStore } from "./stores/companies";
import DashboardHeader from "./components/layout/DashboardHeader.vue";
import HamburgerMenu from "./components/layout/HamburgerMenu.vue";
import { useAccountingPeriodStore } from "./stores/accountingPeriod";
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();
// Menu state
const menuOpen = ref(false);
const userMenuOpen = ref(false);
// Handle menu toggle
const handleMenuToggle = () => {
menuOpen.value = !menuOpen.value;
// Menu items configuration for reports-app
const reportsMenuItems = [
{
title: 'Navigare',
items: [
{ to: '/dashboard', icon: 'pi pi-home', label: 'Dashboard' },
{ to: '/invoices', icon: 'pi pi-file', label: 'Facturi' },
{ to: '/bank-cash-register', icon: 'pi pi-money-bill', label: 'Casa și Banca' },
{ to: '/trial-balance', icon: 'pi pi-calculator', label: 'Balanță de Verificare' },
]
},
{
title: 'Sistem',
items: [
{ to: '/cache-stats', icon: 'pi pi-chart-bar', label: 'Statistici cache' },
]
}
];
// User menu handlers
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value;
};
// Handle menu close
const handleMenuClose = () => {
menuOpen.value = false;
const closeUserMenu = () => {
userMenuOpen.value = false;
};
const handleUserMenuToggle = () => {
toggleUserMenu();
};
// Handle company change
@@ -61,6 +140,35 @@ const handleCompanyChanged = (company) => {
console.log("Company changed in App:", company);
};
// Handle period change
const handlePeriodChanged = (period) => {
console.log("Period changed in App:", period);
};
// Navigate to Telegram
const navigateToTelegram = async () => {
try {
closeUserMenu();
await router.push("/telegram");
} catch (error) {
console.error("Navigation error:", error);
}
};
// Handle logout
const handleLogout = async () => {
try {
authStore.logout();
companyStore.reset();
periodStore.reset();
closeUserMenu();
menuOpen.value = false;
await router.push("/login");
} catch (error) {
console.error("Logout error:", error);
}
};
// Initialize app
onMounted(async () => {
// Check authentication on app start
@@ -93,6 +201,146 @@ onMounted(async () => {
.main-content:not(.with-navbar) {
min-height: 100vh;
}
/* User Menu Container */
.user-menu-container {
position: relative;
}
/* Header User Button */
.header-user {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
color: var(--color-primary);
cursor: pointer;
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
}
.header-user:hover {
background: var(--color-bg-secondary);
}
/* User Dropdown */
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown, 1000);
overflow: hidden;
}
.user-dropdown-header {
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.user-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.user-name {
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
}
.user-email {
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.user-dropdown-divider {
height: 1px;
background: var(--color-border);
}
.user-dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
color: var(--color-text);
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.user-dropdown-item:hover {
background: var(--color-bg-secondary);
}
.user-dropdown-item:focus {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
background: var(--color-bg-secondary);
}
/* User Menu Overlay */
.user-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: transparent;
}
/* Chevron rotation animation */
.rotate-180 {
transform: rotate(180deg);
transition: transform var(--transition-fast);
}
.pi-chevron-down {
transition: transform var(--transition-fast);
}
/* Desktop only class */
.desktop-only {
display: inline;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.user-dropdown {
min-width: 200px;
}
.user-dropdown-header {
padding: var(--space-sm);
}
.user-dropdown-item {
padding: var(--space-sm);
}
/* Hide profile menu on mobile - use hamburger menu instead */
.mobile-hide {
display: none !important;
}
.desktop-only {
display: none;
}
}
</style>
<style>

View File

@@ -2,6 +2,10 @@
/* Import order is critical for proper CSS cascade */
/* 0. Shared Layout Styles (from shared/frontend/styles) */
@import '../../../../../shared/frontend/styles/layout/header.css';
@import '../../../../../shared/frontend/styles/layout/navigation.css';
/* 1. Core Foundation */
@import "./core/variables.css";
@import "./core/tokens.css"; /* NEW - Extended design tokens */

View File

@@ -1,138 +1,17 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Accounting Period Store for Reports App
*
* Uses the shared accounting period store factory from shared/frontend/stores/accountingPeriod.js
* Configured with the reports API service (port 8001)
*/
import { createAccountingPeriodStore } from "@shared/frontend/stores/accountingPeriod";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
import { useCompanyStore } from "./companies";
export const useAccountingPeriodStore = defineStore("accountingPeriod", () => {
// State
const periods = ref([]);
const selectedPeriod = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Getters
const hasPeriods = computed(() => periods.value.length > 0);
const currentPeriod = computed(() => selectedPeriod.value);
// Computed date range for current period (first/last day of month)
const dateRange = computed(() => {
if (!selectedPeriod.value) return { dateFrom: null, dateTo: null };
const { an, luna } = selectedPeriod.value;
const firstDay = new Date(an, luna - 1, 1);
const lastDay = new Date(an, luna, 0);
return {
dateFrom: firstDay,
dateTo: lastDay,
};
});
// Actions
const loadPeriods = async (companyId) => {
if (!companyId) return { success: false };
isLoading.value = true;
error.value = null;
try {
const response = await apiService.get("/calendar/periods", {
params: { company: companyId },
});
periods.value = response.data.periods || [];
// Try to restore saved period or use most recent
const saved = initializeSelectedPeriod();
if (saved) {
const exists = periods.value.find(
(p) => p.an === saved.an && p.luna === saved.luna
);
if (exists) {
selectedPeriod.value = exists;
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load periods";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedPeriod = (period) => {
selectedPeriod.value = period;
persistSelectedPeriod(period);
};
const resetToLatest = () => {
if (periods.value.length > 0) {
setSelectedPeriod(periods.value[0]);
}
};
const reset = () => {
periods.value = [];
selectedPeriod.value = null;
isLoading.value = false;
error.value = null;
};
// localStorage helpers
const getStorageKey = () => {
const authStore = useAuthStore();
const companyStore = useCompanyStore();
const username = authStore.user?.username;
const companyId = companyStore.selectedCompany?.id_firma;
if (!username || !companyId) return null;
return `selected_period_${username}_${companyId}`;
};
const initializeSelectedPeriod = () => {
const key = getStorageKey();
if (!key) return null;
const saved = localStorage.getItem(key);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
localStorage.removeItem(key);
}
}
return null;
};
const persistSelectedPeriod = (period) => {
const key = getStorageKey();
if (key && period) {
localStorage.setItem(key, JSON.stringify(period));
}
};
return {
// State
periods,
selectedPeriod,
isLoading,
error,
// Getters
hasPeriods,
currentPeriod,
dateRange,
// Actions
loadPeriods,
setSelectedPeriod,
resetToLatest,
reset,
};
});
export const useAccountingPeriodStore = createAccountingPeriodStore(
apiService,
useAuthStore,
useCompanyStore
);

View File

@@ -1,205 +1,12 @@
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
/**
* Companies Store for Reports App
*
* Uses the shared companies store factory from shared/frontend/stores/companies.js
* Configured with the reports API service (port 8001)
*/
import { createCompaniesStore } from "@shared/frontend/stores/companies";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
export const useCompanyStore = defineStore("companies", () => {
// Initialize from localStorage - per user
const initializeSelectedCompany = () => {
// Get current username from auth store
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.log("[Companies] No username available for initialization");
return null;
}
const key = `selected_company_${username}`;
const saved = localStorage.getItem(key);
if (saved) {
try {
const company = JSON.parse(saved);
console.log(
`[Companies] Loaded saved company for user ${username}:`,
company.name,
);
return company;
} catch (e) {
console.error("Failed to parse saved company", e);
localStorage.removeItem(key);
}
}
return null;
};
// State
const companies = ref([]);
const selectedCompany = ref(initializeSelectedCompany());
const isLoading = ref(false);
const error = ref(null);
// Watch for auth user changes to restore selected company
const authStore = useAuthStore();
watch(
() => authStore.user,
(newUser) => {
if (newUser && newUser.username && !selectedCompany.value) {
console.log(
"[Companies] User became available, attempting to restore selected company",
);
const restoredCompany = initializeSelectedCompany();
if (restoredCompany) {
selectedCompany.value = restoredCompany;
console.log(
"[Companies] Successfully restored selected company:",
restoredCompany.name,
);
}
}
},
{ immediate: true },
);
// Getters
const companyList = computed(() => companies.value);
const hasCompanies = computed(() => companies.value.length > 0);
const selectedCompanyId = computed(
() => selectedCompany.value?.id_firma || null,
);
// Computed property for formatted company list display
const companyListFormatted = computed(() => {
return companies.value.map((company) => ({
...company,
displayName: company.fiscal_code
? `${company.name} (${company.fiscal_code})`
: company.name,
}));
});
// Actions
const loadCompanies = async () => {
isLoading.value = true;
error.value = null;
try {
console.log("[COMPANY STORE DEBUG] Loading companies...");
const response = await apiService.get("/companies");
console.log("[COMPANY STORE DEBUG] API Response:", response.data);
companies.value = response.data.companies || [];
console.log("[COMPANY STORE DEBUG] Companies array:", companies.value);
// Security validation: Check if saved company is accessible to current user
if (selectedCompany.value) {
const exists = companies.value.find(
(c) => c.id_firma === selectedCompany.value.id_firma,
);
if (!exists) {
console.warn(
"[Companies][Security] Saved company not accessible to current user, clearing",
);
clearSelectedCompany();
} else {
console.log(
"[Companies][Security] Saved company validated successfully",
);
}
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load companies";
console.error("Failed to load companies:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedCompany = (company) => {
selectedCompany.value = company;
// Get current username from auth store
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.warn("[Companies] Cannot save company - no username available");
return;
}
const key = `selected_company_${username}`;
if (company) {
localStorage.setItem(key, JSON.stringify(company));
console.log(
`[Companies] Saved company for user ${username}:`,
company.name,
);
} else {
localStorage.removeItem(key);
console.log(`[Companies] Cleared company for user ${username}`);
}
};
const clearSelectedCompany = () => {
selectedCompany.value = null;
// Get current username from auth store
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
console.log(`[Companies] Cleared company for user ${username}`);
}
};
const getCompanyById = (id_firma) => {
return companies.value.find(
(company) => company.id_firma === parseInt(id_firma),
);
};
const clearError = () => {
error.value = null;
};
const reset = () => {
companies.value = [];
selectedCompany.value = null;
isLoading.value = false;
error.value = null;
// Clear saved company for current user
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
return {
// State
companies,
selectedCompany,
isLoading,
error,
// Getters
companyList,
companyListFormatted,
hasCompanies,
selectedCompanyId,
// Actions
loadCompanies,
setSelectedCompany,
clearSelectedCompany,
getCompanyById,
clearError,
reset,
};
});
export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);