Initial commit: ROA2WEB - FastAPI + Vue.js + Telegram Bot

Modern ERP Reports Application with microservices architecture

Tech Stack:
- Backend: FastAPI + python-oracledb (Oracle DB integration)
- Frontend: Vue.js 3 + PrimeVue + Vite
- Telegram Bot: python-telegram-bot + SQLite
- Infrastructure: Shared database pool, JWT authentication, SSH tunnel

Features:
- FastAPI backend with async Oracle connection pool
- Vue.js 3 responsive frontend with PrimeVue components
- Telegram bot alternative interface
- Microservices architecture with shared components
- Complete deployment support (Linux Docker + Windows IIS)
- Comprehensive testing (Playwright E2E + pytest)

Repository Structure:
- reports-app/ - Main application (backend, frontend, telegram-bot)
- shared/ - Shared components (database pool, auth, utils)
- deployment/ - Deployment scripts (Linux & Windows)
- docs/ - Project documentation
- security/ - Security scanning and git hooks
This commit is contained in:
2025-10-25 14:55:08 +03:00
commit 6b13ffa183
237 changed files with 70035 additions and 0 deletions

View File

@@ -0,0 +1,376 @@
<template>
<div class="app-container">
<div class="register-view">
<!-- Header -->
<div class="view-header">
<h1 class="page-title">
<i class="pi pi-wallet"></i>
Registrul de Casă și Bancă
</h1>
<p class="page-subtitle">
Vizualizați toate mișcările din conturile de bancă și casă
</p>
</div>
<!-- Filters -->
<Card class="filters-card">
<template #content>
<div class="filters-grid">
<div class="filter-item">
<label>Data început</label>
<Calendar v-model="filters.dateFrom" dateFormat="dd/mm/yy" />
</div>
<div class="filter-item">
<label>Data sfârșit</label>
<Calendar v-model="filters.dateTo" dateFormat="dd/mm/yy" />
</div>
<div class="filter-item">
<label>Căutare partener</label>
<InputText v-model="filters.partnerName" placeholder="Nume partener..." />
</div>
<div class="filter-actions">
<Button
label="Aplică Filtre"
icon="pi pi-filter"
@click="applyFilters"
/>
<Button
label="Resetează"
icon="pi pi-times"
class="p-button-secondary"
@click="resetFilters"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Stats -->
<div class="summary-stats">
<Card class="stat-card">
<template #content>
<div class="stat-content">
<div class="stat-icon green">
<i class="pi pi-arrow-down"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">{{ formatCurrency(treasuryStore.totals.total_incasari) }}</h3>
<p class="stat-label">Total Încasări</p>
</div>
</div>
</template>
</Card>
<Card class="stat-card">
<template #content>
<div class="stat-content">
<div class="stat-icon red">
<i class="pi pi-arrow-up"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">{{ formatCurrency(treasuryStore.totals.total_plati) }}</h3>
<p class="stat-label">Total Plăți</p>
</div>
</div>
</template>
</Card>
</div>
<!-- Data Table -->
<Card class="data-card">
<template #content>
<DataTable
:value="treasuryStore.registers"
:loading="treasuryStore.isLoading"
:paginator="true"
:rows="pagination.rows"
:total-records="treasuryStore.pagination.totalRecords"
:lazy="true"
@page="onPage"
class="p-datatable-sm"
:rowClass="getRowClass"
>
<Column field="dataact" header="Data">
<template #body="slotProps">
{{ formatDate(slotProps.data.dataact) }}
</template>
</Column>
<Column field="nract" header="Nr. Act" style="width: 100px" />
<Column field="nume" header="Partener" />
<Column field="nume_cont_bancar" header="Cont" />
<Column field="tip_registru" header="Tip">
<template #body="slotProps">
<Tag :value="slotProps.data.tip_registru" :severity="getRegisterSeverity(slotProps.data.tip_registru)" />
</template>
</Column>
<Column field="incasari" header="Încasări">
<template #body="slotProps">
<span class="amount-green" v-if="slotProps.data.incasari > 0">
{{ formatCurrency(slotProps.data.incasari, slotProps.data.valuta) }}
</span>
<span v-else>-</span>
</template>
</Column>
<Column field="plati" header="Plăți">
<template #body="slotProps">
<span class="amount-red" v-if="slotProps.data.plati > 0">
{{ formatCurrency(slotProps.data.plati, slotProps.data.valuta) }}
</span>
<span v-else>-</span>
</template>
</Column>
<Column field="sold" header="Sold">
<template #body="slotProps">
<span :class="slotProps.data.sold >= 0 ? 'amount-green' : 'amount-red'">
{{ formatCurrency(slotProps.data.sold, slotProps.data.valuta) }}
</span>
</template>
</Column>
<Column field="explicatia" header="Explicație" />
</DataTable>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useTreasuryStore } from "../stores/treasury";
import { useCompanyStore } from "../stores/companies";
import { format } from "date-fns";
import { ro } from "date-fns/locale";
const treasuryStore = useTreasuryStore();
const companyStore = useCompanyStore();
const filters = ref({
dateFrom: null,
dateTo: null,
partnerName: ""
});
const pagination = ref({
page: 0,
rows: 50
});
const formatCurrency = (amount, currency = 'RON') => {
if (!amount) return "0,00 " + currency;
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: currency
}).format(amount);
};
const formatDate = (dateString) => {
if (!dateString) return "";
return format(new Date(dateString), "dd MMM yyyy", { locale: ro });
};
const getRowClass = (data) => {
return data.tip_registru.includes('BANCA') ? 'bank-row' : 'cash-row';
};
const getRegisterSeverity = (type) => {
if (type.includes('BANCA')) return 'info';
if (type.includes('CASA')) return 'warning';
return null;
};
const onPage = (event) => {
pagination.value = event;
loadData();
};
const applyFilters = () => {
pagination.value.page = 0;
loadData();
};
const resetFilters = () => {
filters.value = {
dateFrom: null,
dateTo: null,
partnerName: ""
};
loadData();
};
const loadData = async () => {
if (!companyStore.selectedCompany) return;
treasuryStore.setPagination(pagination.value);
await treasuryStore.loadBankCashRegister(
companyStore.selectedCompany.id_firma,
{
date_from: filters.value.dateFrom?.toISOString().split("T")[0],
date_to: filters.value.dateTo?.toISOString().split("T")[0],
partner_name: filters.value.partnerName
}
);
};
onMounted(() => {
if (companyStore.selectedCompany) {
loadData();
}
});
</script>
<style scoped>
.app-container {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.view-header {
margin-bottom: 2rem;
}
.page-title {
color: var(--primary-color);
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-subtitle {
color: var(--text-color-secondary);
font-size: 1.1rem;
margin: 0;
}
.filters-card {
margin-bottom: 2rem;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
align-items: end;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-item label {
font-weight: 600;
color: var(--text-color);
}
.filter-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-start;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
border-radius: 12px;
border: 1px solid var(--surface-border);
overflow: hidden;
}
.stat-content {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-icon.green {
background: linear-gradient(135deg, var(--green-500), var(--green-600));
}
.stat-icon.red {
background: linear-gradient(135deg, var(--red-500), var(--red-600));
}
.stat-details h3 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: var(--text-color);
}
.stat-details p {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin: 0.25rem 0 0 0;
}
.data-card {
border-radius: 12px;
border: 1px solid var(--surface-border);
}
.amount-green {
color: var(--green-600);
font-weight: 600;
}
.amount-red {
color: var(--red-600);
font-weight: 600;
}
:deep(.bank-row) {
background-color: var(--blue-50);
}
:deep(.cash-row) {
background-color: var(--yellow-50);
}
@media (max-width: 768px) {
.app-container {
padding: 1rem;
}
.page-title {
font-size: 1.75rem;
}
.filters-grid {
grid-template-columns: 1fr;
}
.summary-stats {
grid-template-columns: 1fr;
}
.filter-actions {
justify-content: stretch;
}
.filter-actions .p-button {
flex: 1;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
<template>
<div class="app-container">
<div class="invoices">
<!-- Header Section -->
<div class="page-header">
<h1 class="page-title">
<i class="pi pi-file-text"></i>
Facturi
</h1>
<p class="page-subtitle">
Vizualizați și gestionați facturile pentru compania selectată
</p>
</div>
<!-- Company Selection -->
<Card v-if="!companyStore.selectedCompany" class="company-selection-card">
<template #content>
<div class="company-selection">
<p class="text-color-secondary mb-3">
Selectați o companie pentru a vizualiza facturile:
</p>
<Dropdown
v-model="selectedCompanyId"
:options="companyStore.companyListFormatted"
option-label="displayName"
option-value="id_firma"
placeholder="Alegeți compania"
class="w-full"
@change="handleCompanyChange"
/>
</div>
</template>
</Card>
<!-- Filters and Controls -->
<Card v-if="companyStore.selectedCompany" class="filters-card">
<template #content>
<div class="filters-container">
<div class="filters-row">
<!-- Invoice Type -->
<div class="filter-group">
<label class="filter-label">Tip Factură</label>
<Dropdown
v-model="filters.type"
:options="invoiceTypes"
option-label="label"
option-value="value"
placeholder="Tip factură"
@change="handleFilterChange"
/>
</div>
<!-- Date Range -->
<div class="filter-group">
<label class="filter-label">Data De</label>
<Calendar
v-model="filters.dateFrom"
date-format="dd/mm/yy"
placeholder="Selectați data"
@date-select="handleFilterChange"
/>
</div>
<div class="filter-group">
<label class="filter-label">Data Până</label>
<Calendar
v-model="filters.dateTo"
date-format="dd/mm/yy"
placeholder="Selectați data"
@date-select="handleFilterChange"
/>
</div>
<!-- Search -->
<div class="filter-group search-group">
<label class="filter-label">Căutare</label>
<InputText
v-model="filters.searchTerm"
placeholder="Căutați după număr, partener..."
@input="handleSearchChange"
/>
</div>
</div>
<div class="filters-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează Filtre"
class="p-button-outlined p-button-secondary"
@click="clearFilters"
/>
<Button
icon="pi pi-refresh"
label="Actualizează"
:loading="invoicesStore.isLoading"
@click="refreshData"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Statistics -->
<div
v-if="companyStore.selectedCompany && invoicesStore.hasInvoices"
class="summary-stats"
>
<Card class="stat-card stat-total">
<template #content>
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-file-text"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">{{ invoicesStore.totalInvoices }}</h3>
<p class="stat-label">Total Facturi</p>
</div>
</div>
</template>
</Card>
<Card class="stat-card stat-paid">
<template #content>
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-check-circle"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">
{{ invoicesStore.paidInvoices.length }}
</h3>
<p class="stat-label">Achitate</p>
<small class="stat-amount">{{
formatCurrency(invoicesStore.totalAmountPaid)
}}</small>
</div>
</div>
</template>
</Card>
<Card class="stat-card stat-overdue">
<template #content>
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-exclamation-circle"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">
{{ invoicesStore.overdueInvoices.length }}
</h3>
<p class="stat-label">Restante</p>
<small class="stat-amount">{{
formatCurrency(invoicesStore.totalAmountOverdue)
}}</small>
</div>
</div>
</template>
</Card>
</div>
<!-- Invoices Table -->
<Card v-if="companyStore.selectedCompany" class="table-card">
<template #content>
<DataTable
:value="invoicesStore.invoiceList"
:loading="invoicesStore.isLoading"
:paginator="true"
:rows="pagination.rows"
:total-records="invoicesStore.totalInvoices"
:lazy="true"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rows-per-page-options="[25, 50, 100]"
current-page-report-template="Afișare {first} - {last} din {totalRecords} înregistrări"
:row-class="getRowClass"
responsive-layout="scroll"
@page="onPageChange"
@sort="onSort"
>
<template #empty>
<div class="no-data">
<i class="pi pi-info-circle"></i>
<p>Nu au fost găsite facturi</p>
</div>
</template>
<template #loading>
<div class="loading-table">
<ProgressSpinner />
<p>Se încarcă facturile...</p>
</div>
</template>
<Column field="numar_document" header="Număr Document" sortable>
<template #body="slotProps">
<strong>{{ slotProps.data.numar_document }}</strong>
</template>
</Column>
<Column field="data_document" header="Data Document" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.data_document) }}
</template>
</Column>
<Column field="nume_partener" header="Partener" sortable>
<template #body="slotProps">
<div class="partner-info">
<span class="partner-name">{{
slotProps.data.nume_partener
}}</span>
<small
v-if="slotProps.data.cod_partener"
class="partner-code"
>
{{ slotProps.data.cod_partener }}
</small>
</div>
</template>
</Column>
<Column field="suma" header="Sumă" sortable>
<template #body="slotProps">
<span class="amount" :class="getAmountClass(slotProps.data)">
{{ formatCurrency(slotProps.data.suma) }}
</span>
</template>
</Column>
<Column field="css_class" header="Status">
<template #body="slotProps">
<Tag
:value="getStatusText(slotProps.data.css_class)"
:severity="getStatusSeverity(slotProps.data.css_class)"
/>
</template>
</Column>
<Column field="data_scadenta" header="Data Scadență" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.data_scadenta) }}
</template>
</Column>
<Column header="Acțiuni" :exportable="false">
<template #body="slotProps">
<div class="table-actions">
<Button
icon="pi pi-eye"
class="p-button-rounded p-button-text p-button-sm"
v-tooltip="'Vezi detalii'"
@click="viewInvoiceDetails(slotProps.data)"
/>
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
<!-- Invoice Details Dialog -->
<Dialog
v-model:visible="showDetailsDialog"
:header="`Detalii Factură ${selectedInvoice?.numar_document}`"
:modal="true"
:style="{ width: '50vw' }"
:breakpoints="{ '960px': '75vw', '641px': '90vw' }"
>
<div v-if="selectedInvoice" class="invoice-details">
<div class="details-grid">
<div class="detail-item">
<label>Număr Document:</label>
<span>{{ selectedInvoice.numar_document }}</span>
</div>
<div class="detail-item">
<label>Data Document:</label>
<span>{{ formatDate(selectedInvoice.data_document) }}</span>
</div>
<div class="detail-item">
<label>Partener:</label>
<span>{{ selectedInvoice.nume_partener }}</span>
</div>
<div class="detail-item">
<label>Cod Partener:</label>
<span>{{ selectedInvoice.cod_partener || "-" }}</span>
</div>
<div class="detail-item">
<label>Sumă:</label>
<span class="amount">{{
formatCurrency(selectedInvoice.suma)
}}</span>
</div>
<div class="detail-item">
<label>Status:</label>
<Tag
:value="getStatusText(selectedInvoice.css_class)"
:severity="getStatusSeverity(selectedInvoice.css_class)"
/>
</div>
<div class="detail-item">
<label>Data Scadență:</label>
<span>{{ formatDate(selectedInvoice.data_scadenta) }}</span>
</div>
</div>
</div>
</Dialog>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useToast } from "primevue/usetoast";
import { useCompanyStore } from "../stores/companies";
import { useInvoicesStore } from "../stores/invoices";
import { format } from "date-fns";
import { ro } from "date-fns/locale";
const toast = useToast();
const companyStore = useCompanyStore();
const invoicesStore = useInvoicesStore();
// State
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
const showDetailsDialog = ref(false);
const selectedInvoice = ref(null);
const filters = ref({
type: "CLIENTI",
dateFrom: null,
dateTo: null,
searchTerm: "",
});
const pagination = ref({
page: 0,
rows: 50,
});
// Options
const invoiceTypes = [
{ label: "Clienți", value: "CLIENTI" },
{ label: "Furnizori", value: "FURNIZORI" },
];
// Methods
const formatCurrency = (amount) => {
if (!amount) return "0,00 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
}).format(amount);
};
const formatDate = (dateString) => {
if (!dateString) return "";
try {
return format(new Date(dateString), "dd/MM/yyyy", { locale: ro });
} catch (error) {
return dateString;
}
};
const getStatusText = (cssClass) => {
switch (cssClass) {
case "invoice-paid":
return "Achitat";
case "invoice-overdue":
return "Restant";
default:
return "Neutru";
}
};
const getStatusSeverity = (cssClass) => {
switch (cssClass) {
case "invoice-paid":
return "success";
case "invoice-overdue":
return "danger";
default:
return "info";
}
};
const getRowClass = (data) => {
return data.css_class || "";
};
const getAmountClass = (invoice) => {
switch (invoice.css_class) {
case "invoice-paid":
return "amount-paid";
case "invoice-overdue":
return "amount-overdue";
default:
return "";
}
};
const handleCompanyChange = async () => {
if (!selectedCompanyId.value) return;
const company = companyStore.getCompanyById(selectedCompanyId.value);
if (company) {
companyStore.setSelectedCompany(company);
await loadInvoices();
}
};
const handleFilterChange = async () => {
pagination.value.page = 0;
await loadInvoices();
};
const handleSearchChange = (() => {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(async () => {
pagination.value.page = 0;
await loadInvoices();
}, 500);
};
})();
const clearFilters = async () => {
filters.value = {
type: "CLIENTI",
dateFrom: null,
dateTo: null,
searchTerm: "",
};
pagination.value.page = 0;
await loadInvoices();
};
const refreshData = async () => {
await loadInvoices();
toast.add({
severity: "success",
summary: "Actualizare reușită",
detail: "Facturile au fost actualizate cu succes",
life: 3000,
});
};
const loadInvoices = async () => {
if (!companyStore.selectedCompany) return;
try {
invoicesStore.setFilters(filters.value);
invoicesStore.setPagination(pagination.value);
await invoicesStore.loadInvoices(companyStore.selectedCompany.id_firma, {
tip: filters.value.type,
date_from: filters.value.dateFrom?.toISOString().split("T")[0],
date_to: filters.value.dateTo?.toISOString().split("T")[0],
search: filters.value.searchTerm,
page: pagination.value.page,
size: pagination.value.rows,
});
} catch (error) {
console.error("Failed to load invoices:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut încărca facturile",
life: 5000,
});
}
};
const onPageChange = async (event) => {
pagination.value.page = event.page;
pagination.value.rows = event.rows;
await loadInvoices();
};
const onSort = async (event) => {
// Handle sorting if needed
await loadInvoices();
};
const viewInvoiceDetails = (invoice) => {
selectedInvoice.value = invoice;
showDetailsDialog.value = true;
};
// Lifecycle
onMounted(async () => {
// Load companies if not loaded
if (!companyStore.hasCompanies) {
await companyStore.loadCompanies();
}
// Load invoices if company is selected
if (companyStore.selectedCompany) {
await loadInvoices();
}
});
// Watch for company changes
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await loadInvoices();
}
},
);
</script>
<style scoped>
.invoices {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-color);
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-subtitle {
font-size: 1.1rem;
color: var(--text-color-secondary);
margin: 0;
}
.company-selection-card,
.filters-card {
margin-bottom: 2rem;
}
.filters-container {
padding: 1rem;
}
.filters-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.search-group {
grid-column: span 2;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-label {
font-weight: 600;
color: var(--text-color);
font-size: 0.9rem;
}
.filters-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid var(--surface-border);
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
border-left: 4px solid var(--primary-color);
}
.stat-card.stat-total {
border-left-color: var(--blue-500);
}
.stat-card.stat-paid {
border-left-color: var(--green-500);
}
.stat-card.stat-overdue {
border-left-color: var(--red-500);
}
.stat-content {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
}
.stat-icon {
font-size: 2rem;
color: var(--primary-color);
}
.stat-details {
flex: 1;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
color: var(--text-color);
}
.stat-label {
font-weight: 600;
margin: 0 0 0.25rem 0;
color: var(--text-color-secondary);
}
.stat-amount {
color: var(--text-color-secondary);
font-weight: 500;
}
.table-card {
margin-bottom: 2rem;
}
.partner-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.partner-name {
font-weight: 500;
}
.partner-code {
color: var(--text-color-secondary);
}
.amount {
font-weight: 600;
}
.amount-paid {
color: var(--green-600);
}
.amount-overdue {
color: var(--red-600);
}
.table-actions {
display: flex;
gap: 0.5rem;
}
.no-data,
.loading-table {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.no-data i,
.loading-table i {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.invoice-details {
padding: 1rem 0;
}
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-item label {
font-weight: 600;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.detail-item span {
font-weight: 500;
color: var(--text-color);
}
/* Row styling based on status */
:deep(.p-datatable .p-datatable-tbody > tr.invoice-paid) {
background-color: var(--green-50);
}
:deep(.p-datatable .p-datatable-tbody > tr.invoice-overdue) {
background-color: var(--red-50);
}
/* Responsive design */
@media (max-width: 768px) {
.invoices {
padding: 1rem;
}
.page-title {
font-size: 2rem;
}
.filters-row {
grid-template-columns: 1fr;
}
.search-group {
grid-column: span 1;
}
.summary-stats {
grid-template-columns: 1fr;
}
.filters-actions {
flex-direction: column;
}
.details-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,394 @@
<template>
<div class="login-container">
<div class="login-wrapper">
<Card class="login-card">
<template #header>
<div class="login-header">
<i class="pi pi-chart-bar text-primary text-6xl"></i>
<h1 class="login-title">ROA Reports</h1>
<p class="login-subtitle">Rapoarte ERP - Facturi și Încasări</p>
</div>
</template>
<template #content>
<form @submit.prevent="handleLogin" class="login-form">
<div class="field">
<label for="username" class="field-label">Utilizator</label>
<InputText
id="username"
v-model="credentials.username"
placeholder="Introduceți numele de utilizator"
:class="{ 'p-invalid': formErrors.username }"
class="w-full"
autocomplete="username"
@blur="validateField('username')"
/>
<small v-if="formErrors.username" class="p-error">
{{ formErrors.username }}
</small>
</div>
<div class="field">
<label for="password" class="field-label">Parolă</label>
<Password
id="password"
v-model="credentials.password"
placeholder="Introduceți parola"
:class="{ 'p-invalid': formErrors.password }"
class="w-full"
:feedback="false"
toggle-mask
autocomplete="current-password"
@blur="validateField('password')"
/>
<small v-if="formErrors.password" class="p-error">
{{ formErrors.password }}
</small>
</div>
<div v-if="authStore.error" class="error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<Button
type="submit"
label="Conectare"
class="w-full login-button"
:loading="authStore.isLoading"
:disabled="!isFormValid"
/>
</form>
</template>
<template #footer>
<div class="login-footer">
<small class="text-color-secondary">
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
</small>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useToast } from "primevue/usetoast";
import { useAuthStore } from "../stores/auth";
const router = useRouter();
const toast = useToast();
const authStore = useAuthStore();
// Form data
const credentials = ref({
username: "",
password: "",
});
const formErrors = ref({
username: "",
password: "",
});
// Computed properties
const currentYear = computed(() => new Date().getFullYear());
const isFormValid = computed(() => {
return (
credentials.value.username.trim() !== "" &&
credentials.value.password.trim() !== "" &&
!formErrors.value.username &&
!formErrors.value.password
);
});
// Methods
const validateField = (field) => {
switch (field) {
case "username":
formErrors.value.username =
credentials.value.username.trim() === ""
? "Numele de utilizator este obligatoriu"
: "";
break;
case "password":
formErrors.value.password =
credentials.value.password.trim() === ""
? "Parola este obligatorie"
: "";
break;
}
};
const validateForm = () => {
validateField("username");
validateField("password");
return isFormValid.value;
};
const handleLogin = async () => {
if (!validateForm()) {
return;
}
try {
const result = await authStore.login(credentials.value);
if (result.success) {
// Redirect to dashboard (removed welcome notification)
router.push("/dashboard");
} else {
toast.add({
severity: "error",
summary: "Eroare de conectare",
detail: result.error || "Date de conectare incorecte",
life: 5000,
});
}
} catch (error) {
console.error("Login error:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "A apărut o eroare neașteptată",
life: 5000,
});
}
};
// Clear errors when user starts typing
const clearErrors = () => {
authStore.clearError();
formErrors.value = {
username: "",
password: "",
};
};
// Lifecycle hooks
onMounted(() => {
// Clear any previous errors
clearErrors();
// Focus on username field
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.focus();
}
});
onUnmounted(() => {
clearErrors();
});
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
padding: 1rem;
}
.login-wrapper {
width: 100%;
max-width: 400px;
}
.login-card {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-header {
text-align: center;
padding: 2rem 2rem 1rem 2rem;
background: white;
}
.login-title {
margin: 1rem 0 0.5rem 0;
color: var(--primary-color);
font-size: 2rem;
font-weight: 700;
}
.login-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.95rem;
}
.login-form {
padding: 0 2rem 2rem 2rem;
}
.field {
margin-bottom: 1.5rem;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-color);
}
.login-button {
margin-top: 1rem;
padding: 0.75rem;
font-size: 1.1rem;
font-weight: 600;
background: #3b82f6 !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.login-button:hover {
background: #2563eb !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Better input styling */
:deep(.p-inputtext),
:deep(.p-password input) {
border: 2px solid #e5e7eb !important;
padding: 12px !important;
font-size: 16px !important;
transition: all 0.3s ease !important;
border-radius: 8px !important;
}
:deep(.p-inputtext:focus),
:deep(.p-password input:focus) {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
outline: none !important;
}
:deep(.p-inputtext:hover),
:deep(.p-password input:hover) {
border-color: #9ca3af !important;
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: var(--red-50);
color: var(--red-800);
border: 1px solid var(--red-200);
border-radius: 6px;
font-size: 0.9rem;
}
.login-footer {
text-align: center;
padding: 1rem 2rem;
background-color: var(--surface-50);
border-top: 1px solid var(--surface-200);
}
/* Responsive design */
@media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.login-card {
border-radius: 8px;
}
.login-header {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-form {
padding: 0 1rem 1.5rem 1rem;
}
/* Ensure inputs are touch-friendly */
.p-inputtext,
.p-password input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.login-footer {
padding: 1rem;
}
}
@media (max-width: 480px) {
.login-container {
padding: 0.25rem;
}
.login-card {
margin: 0;
}
.login-header {
padding: 1rem 0.5rem;
}
.login-title {
font-size: 1.25rem;
}
.login-subtitle {
font-size: 0.875rem;
}
.login-form {
padding: 0 0.5rem 1rem 0.5rem;
}
.login-footer {
padding: 0.75rem 0.5rem;
}
}
/* Animation for smooth transitions */
.login-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,432 @@
<template>
<div>
<!-- Dashboard Header -->
<DashboardHeader @menu-toggle="handleMenuToggle" />
<!-- Hamburger Menu -->
<HamburgerMenu :is-open="menuOpen" @close="handleMenuClose" />
<!-- Main Content -->
<main class="main-content">
<div class="app-container">
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">Telegram Bot</h1>
<p class="page-subtitle">Conectează-ți contul pentru acces rapid din Telegram</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se generează codul...</p>
</div>
<!-- Main Card -->
<div v-else class="telegram-card">
<!-- Generate Button -->
<div class="generate-section">
<button
@click="generateCode"
:disabled="loading"
class="generate-btn"
>
{{ loading ? 'Se generează...' : 'Generează Cod' }}
</button>
</div>
<!-- Code Display & Actions -->
<div v-if="linkingCode" class="code-section">
<!-- Code Display -->
<div class="code-display">
<div class="code-header">
<span class="code-label">Cod</span>
<span class="code-timer">{{ formatTime(timeRemaining) }}</span>
</div>
<div class="code-value">{{ linkingCode }}</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<a
:href="telegramDeepLink"
target="_blank"
rel="noopener noreferrer"
class="action-btn primary-action-btn"
>
Deschide Telegram
</a>
<Button
:label="showQR ? 'Ascunde QR' : 'Arată QR'"
@click="showQR = !showQR"
class="action-btn"
outlined
/>
<Button
label="Copiază Cod"
@click="copyCode"
class="action-btn"
outlined
/>
</div>
<!-- QR Code Display -->
<div v-if="showQR" class="qr-section">
<QRCodeVue :value="telegramDeepLink" :size="200" level="H" />
</div>
</div>
</div>
</div>
</main>
<!-- Toast -->
<Toast position="top-right" />
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import DashboardHeader from '../components/layout/DashboardHeader.vue'
import HamburgerMenu from '../components/layout/HamburgerMenu.vue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import QRCodeVue from 'qrcode.vue'
import { apiService } from '../services/api'
const toast = useToast()
// State
const menuOpen = ref(false)
const linkingCode = ref('')
const timeRemaining = ref(0)
const loading = ref(false)
const showQR = ref(false)
let countdownInterval = null
// Config
const BOT_USERNAME = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || 'roa2web_bot'
// Computed
const telegramDeepLink = computed(() => {
if (!linkingCode.value) return ''
return `https://t.me/${BOT_USERNAME}?start=${linkingCode.value}`
})
// Methods
const handleMenuToggle = (isOpen) => {
menuOpen.value = isOpen
}
const handleMenuClose = () => {
menuOpen.value = false
}
const generateCode = async () => {
loading.value = true
showQR.value = false
try {
const response = await apiService.post('/telegram/auth/generate-code')
linkingCode.value = response.data.linking_code
timeRemaining.value = response.data.expires_in_minutes * 60
toast.add({
severity: 'success',
summary: 'Cod Generat',
detail: 'Alege o metodă de conectare',
life: 3000
})
startCountdown()
} catch (error) {
console.error('Error generating code:', error)
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.response?.data?.detail || 'Nu am putut genera codul',
life: 5000
})
} finally {
loading.value = false
}
}
const startCountdown = () => {
if (countdownInterval) clearInterval(countdownInterval)
countdownInterval = setInterval(() => {
if (timeRemaining.value > 0) {
timeRemaining.value--
} else {
clearInterval(countdownInterval)
linkingCode.value = ''
toast.add({
severity: 'warn',
summary: 'Cod Expirat',
detail: 'Generează un cod nou',
life: 4000
})
}
}, 1000)
}
const formatTime = (seconds) => {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
const copyCode = async () => {
try {
await navigator.clipboard.writeText(linkingCode.value)
toast.add({
severity: 'success',
summary: 'Copiat',
detail: 'Cod copiat în clipboard',
life: 2000
})
} catch (error) {
const tempInput = document.createElement('input')
tempInput.value = linkingCode.value
document.body.appendChild(tempInput)
tempInput.select()
document.execCommand('copy')
document.body.removeChild(tempInput)
toast.add({
severity: 'success',
summary: 'Copiat',
life: 2000
})
}
}
onUnmounted(() => {
if (countdownInterval) clearInterval(countdownInterval)
})
</script>
<style scoped>
/* Layout - Consistent with Dashboard */
.main-content {
margin-left: 0;
padding: var(--space-lg);
min-height: 100vh;
background: var(--color-bg);
}
.app-container {
max-width: 1000px;
margin: 0 auto;
}
/* Page Header */
.page-header {
margin-bottom: var(--space-xl);
}
.page-title {
margin: 0 0 var(--space-xs) 0;
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
color: var(--color-text);
}
.page-subtitle {
margin: 0;
font-size: var(--text-base);
color: var(--color-text-secondary);
}
/* Loading */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: var(--space-3xl);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Main Card */
.telegram-card {
background: var(--color-bg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-xl);
border: 1px solid var(--color-border);
}
/* Generate Section */
.generate-section {
display: flex;
justify-content: center;
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.generate-btn {
min-width: 200px;
padding: 12px 24px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.generate-btn:hover:not(:disabled) {
background: #4f46e5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
.generate-btn:active:not(:disabled) {
transform: translateY(0);
}
.generate-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Code Section */
.code-section {
margin-top: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* Code Display */
.code-display {
background: linear-gradient(135deg, rgba(67, 97, 238, 0.08), rgba(67, 97, 238, 0.02));
border: 2px solid var(--color-primary);
border-radius: var(--radius-md);
padding: var(--space-md);
text-align: center;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs);
font-size: var(--text-sm);
}
.code-label {
color: var(--color-text-secondary);
font-weight: var(--font-semibold);
}
.code-timer {
color: var(--color-primary);
font-weight: var(--font-bold);
font-family: 'Courier New', monospace;
}
.code-value {
font-size: 2rem;
font-weight: var(--font-bold);
color: var(--color-primary);
letter-spacing: 0.3em;
font-family: 'Courier New', monospace;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.action-btn {
flex: 1;
min-width: 160px;
justify-content: center;
}
.primary-action-btn {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 160px;
padding: 11px 20px;
background: var(--primary-500, #6366f1);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
transition: all 0.2s ease;
border: none;
cursor: pointer;
line-height: 1.5;
}
.primary-action-btn:hover {
background: var(--primary-600, #4f46e5);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* QR Section */
.qr-section {
display: flex;
justify-content: center;
padding: var(--space-lg);
background: white;
border-radius: var(--radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* Responsive */
@media (max-width: 768px) {
.main-content {
padding: var(--space-md);
}
.telegram-card {
padding: var(--space-md);
}
.code-value {
font-size: 1.5rem;
letter-spacing: 0.2em;
}
.action-buttons {
flex-direction: column;
}
.action-btn,
.primary-btn {
width: 100%;
min-width: unset;
}
}
</style>

View File

@@ -0,0 +1,378 @@
<template>
<div class="company-selector-mini">
<div class="company-dropdown" ref="dropdown">
<button
class="company-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Select company"
>
<div class="company-info">
<span class="company-name">{{ selectedCompanyName }}</span>
<span class="company-code">{{ selectedCompanyCode }}</span>
</div>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': dropdownOpen }"></i>
</button>
<div
v-show="dropdownOpen"
class="company-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="dropdown-search">
<div class="search-wrapper">
<i class="pi pi-search search-icon"></i>
<input
type="text"
v-model="searchQuery"
placeholder="Search companies..."
class="search-input"
@keydown.escape="closeDropdown"
>
</div>
</div>
<div class="company-list">
<div
v-for="company in filteredCompanies"
:key="company.id"
class="company-item"
:class="{ active: company.id === selectedCompany?.id }"
@click="selectCompany(company)"
>
<div class="company-details">
<div class="company-main-name">{{ company.name }}</div>
<div class="company-sub-info">
<span class="company-cui">CUI: {{ company.cui }}</span>
<span class="company-separator"></span>
<span class="company-status" :class="company.status">{{ company.status }}</span>
</div>
</div>
<i v-if="company.id === selectedCompany?.id" class="pi pi-check company-selected-icon"></i>
</div>
</div>
<div v-if="filteredCompanies.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>No companies found</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useCompanyStore } from '../../stores/companies'
export default {
name: 'CompanySelectorMini',
props: {
modelValue: {
type: Object,
default: null
}
},
emits: ['update:modelValue', 'company-changed'],
setup(props, { emit }) {
const companiesStore = useCompanyStore()
const dropdown = ref(null)
const dropdownOpen = ref(false)
const searchQuery = ref('')
const selectedCompany = computed({
get: () => props.modelValue || companiesStore.selectedCompany,
set: (value) => {
emit('update:modelValue', value)
companiesStore.setSelectedCompany(value)
}
})
const selectedCompanyName = computed(() => {
return selectedCompany.value?.name || 'Select Company'
})
const selectedCompanyCode = computed(() => {
return selectedCompany.value?.cui ? `CUI: ${selectedCompany.value.cui}` : ''
})
const filteredCompanies = computed(() => {
if (!searchQuery.value) {
return companiesStore.companies
}
const query = searchQuery.value.toLowerCase()
return companiesStore.companies.filter(company =>
company.name.toLowerCase().includes(query) ||
company.cui.toLowerCase().includes(query)
)
})
const toggleDropdown = () => {
dropdownOpen.value = !dropdownOpen.value
if (dropdownOpen.value) {
searchQuery.value = ''
}
}
const closeDropdown = () => {
dropdownOpen.value = false
searchQuery.value = ''
}
const selectCompany = (company) => {
selectedCompany.value = company
emit('company-changed', company)
closeDropdown()
}
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
// Load companies if not already loaded
if (companiesStore.companies.length === 0) {
companiesStore.fetchCompanies()
}
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
return {
dropdown,
dropdownOpen,
searchQuery,
selectedCompany,
selectedCompanyName,
selectedCompanyCode,
filteredCompanies,
toggleDropdown,
closeDropdown,
selectCompany
}
}
}
</script>
<style scoped>
.company-selector-mini {
position: relative;
max-width: 280px;
}
.company-dropdown {
position: relative;
}
.company-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
width: 100%;
text-align: left;
min-width: 200px;
}
.company-trigger:hover {
border-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-code {
display: block;
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin-top: 2px;
}
.pi-chevron-down {
transition: transform var(--transition-fast);
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.rotate-180 {
transform: rotate(180deg);
}
.company-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
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);
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-fast);
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.dropdown-search {
padding: var(--space-sm);
border-bottom: 1px solid var(--color-border);
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-sm);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
font-size: var(--text-sm);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-xl);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--color-bg);
color: var(--color-text);
transition: border-color var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.company-list {
max-height: 200px;
overflow-y: auto;
}
.company-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
cursor: pointer;
transition: background-color var(--transition-fast);
border-bottom: 1px solid var(--color-border-light);
}
.company-item:last-child {
border-bottom: none;
}
.company-item:hover {
background: var(--color-bg-secondary);
}
.company-item.active {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.company-details {
flex: 1;
min-width: 0;
}
.company-main-name {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.company-sub-info {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-xs);
opacity: 0.8;
}
.company-separator {
opacity: 0.5;
}
.company-status.active {
color: var(--color-success);
}
.company-status.inactive {
color: var(--color-error);
}
.company-selected-icon {
color: inherit;
font-size: var(--text-sm);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.company-selector-mini {
max-width: none;
width: 100%;
}
.company-trigger {
min-width: auto;
}
.company-dropdown-panel {
left: -16px;
right: -16px;
width: calc(100% + 32px);
}
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<header class="header-container">
<nav class="header-nav">
<!-- Left side: Brand + Hamburger -->
<div class="flex items-center gap-4">
<button
class="hamburger-btn"
:class="{ active: menuOpen }"
@click="toggleMenu"
aria-label="Toggle navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<router-link to="/dashboard" class="header-brand">
<span>ROA2WEB</span>
</router-link>
</div>
<!-- Center: Quick Actions -->
<div class="quick-actions desktop-only">
<button class="quick-action-btn" @click="refreshData" title="Refresh Data">
<i class="pi pi-refresh"></i>
<span>Refresh</span>
</button>
<button class="quick-action-btn" @click="exportData" title="Export Data">
<i class="pi pi-download"></i>
<span>Export</span>
</button>
<button class="quick-action-btn" @click="searchData" title="Search">
<i class="pi pi-search"></i>
<span>Search</span>
</button>
</div>
<!-- Right side: Company + User -->
<div class="header-actions">
<CompanySelectorMini
v-model="selectedCompany"
@company-changed="onCompanyChanged"
/>
<div class="header-user" @click="toggleUserMenu">
<i class="pi pi-user"></i>
<span class="desktop-only">User</span>
<i class="pi pi-chevron-down"></i>
</div>
</div>
</nav>
</header>
</template>
<script>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import CompanySelectorMini from '../dashboard/CompanySelectorMini.vue'
import { useCompanyStore } from '../../stores/companies'
export default {
name: 'DashboardHeader',
components: {
CompanySelectorMini
},
emits: ['menu-toggle', 'refresh', 'export', 'search', 'company-changed'],
setup(props, { emit }) {
const router = useRouter()
const companiesStore = useCompanyStore()
const menuOpen = ref(false)
const selectedCompany = computed({
get: () => companiesStore.selectedCompany,
set: (value) => companiesStore.setSelectedCompany(value)
})
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
emit('menu-toggle', menuOpen.value)
}
const refreshData = () => {
emit('refresh')
}
const exportData = () => {
emit('export')
}
const searchData = () => {
emit('search')
}
const onCompanyChanged = (company) => {
emit('company-changed', company)
}
const toggleUserMenu = () => {
// TODO: Implement user menu dropdown
console.log('User menu clicked')
}
return {
menuOpen,
selectedCompany,
toggleMenu,
refreshData,
exportData,
searchData,
onCompanyChanged,
toggleUserMenu
}
}
}
</script>

File diff suppressed because it is too large Load Diff