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:
617
reports-app/frontend/RESPONSIVE_EXPORT_PLAN.md
Normal file
617
reports-app/frontend/RESPONSIVE_EXPORT_PLAN.md
Normal file
@@ -0,0 +1,617 @@
|
||||
# ROA2WEB - Responsive Design & Export Functionality Implementation Plan
|
||||
|
||||
## Overview
|
||||
This plan addresses the following requirements:
|
||||
1. Make login form and dashboard fully responsive
|
||||
2. Fix text size reduction issue in tables on mobile (keep text readable)
|
||||
3. Implement consistent Export Excel/PDF buttons across all tables
|
||||
4. Use same button styling throughout the dashboard
|
||||
|
||||
## Current Issues Identified
|
||||
- Tables shrink text on mobile making numbers unreadable
|
||||
- Export buttons are inconsistent (only 2 tables have them)
|
||||
- Button styles vary across the dashboard
|
||||
- Login form needs mobile optimization
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### 1. Responsive Login Form Enhancement
|
||||
**File: `src/views/LoginView.vue`**
|
||||
|
||||
#### Changes needed:
|
||||
```css
|
||||
/* Add to <style scoped> section */
|
||||
@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 */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Dashboard Table Responsiveness Fix
|
||||
**File: `src/views/DashboardView.vue`**
|
||||
|
||||
#### Key changes:
|
||||
1. **Prevent text shrinking in tables**
|
||||
2. **Add horizontal scroll on mobile**
|
||||
3. **Maintain readable font sizes**
|
||||
|
||||
```css
|
||||
/* Add to <style scoped> section */
|
||||
|
||||
/* Mobile Table Styles - Prevent text shrinking */
|
||||
@media (max-width: 768px) {
|
||||
/* Horizontal scroll for table containers */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Minimum table width to prevent compression */
|
||||
.summary-table,
|
||||
.breakdown-table,
|
||||
.dashboard-table {
|
||||
min-width: 600px !important;
|
||||
}
|
||||
|
||||
/* Maintain readable text size */
|
||||
.summary-table td,
|
||||
.summary-table th,
|
||||
.breakdown-table td,
|
||||
.breakdown-table th {
|
||||
font-size: 14px !important; /* Never go below 14px */
|
||||
padding: 0.5rem;
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Amount cells should never shrink */
|
||||
.amount-cell {
|
||||
font-size: 14px !important;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Stack controls vertically */
|
||||
.section-controls {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-controls > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Button groups on mobile */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small devices */
|
||||
@media (max-width: 480px) {
|
||||
.summary-table,
|
||||
.breakdown-table {
|
||||
min-width: 500px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Stack button groups vertically on very small screens */
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create Export Utility Functions
|
||||
**New File: `src/utils/exportUtils.js`**
|
||||
|
||||
```javascript
|
||||
import * as XLSX from 'xlsx';
|
||||
import jsPDF from 'jspdf';
|
||||
import 'jspdf-autotable';
|
||||
|
||||
/**
|
||||
* Format currency values for export
|
||||
*/
|
||||
const formatCurrency = (value) => {
|
||||
if (value == null || value === '-') return '-';
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export data to Excel
|
||||
* @param {Array} data - Array of objects to export
|
||||
* @param {String} filename - Name of the file (without extension)
|
||||
* @param {String} sheetName - Name of the Excel sheet
|
||||
*/
|
||||
export const exportToExcel = (data, filename, sheetName = 'Sheet1') => {
|
||||
try {
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
XLSX.writeFile(wb, `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Excel export failed:', error);
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export data to PDF
|
||||
* @param {Array} data - Array of objects to export
|
||||
* @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'currency'}]
|
||||
* @param {String} filename - Name of the file (without extension)
|
||||
* @param {String} title - Title for the PDF document
|
||||
*/
|
||||
export const exportToPDF = (data, columns, filename, title) => {
|
||||
try {
|
||||
const doc = new jsPDF('landscape', 'mm', 'a4');
|
||||
|
||||
// Add title
|
||||
doc.setFontSize(16);
|
||||
doc.text(title, 14, 15);
|
||||
|
||||
// Add company info
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Generat: ${new Date().toLocaleString('ro-RO')}`, 14, 25);
|
||||
|
||||
// Prepare table data
|
||||
const tableColumns = columns.map(col => col.header);
|
||||
const tableRows = data.map(row =>
|
||||
columns.map(col => {
|
||||
const value = row[col.field];
|
||||
if (col.type === 'currency') {
|
||||
return formatCurrency(value);
|
||||
}
|
||||
return value || '-';
|
||||
})
|
||||
);
|
||||
|
||||
// Add table
|
||||
doc.autoTable({
|
||||
head: [tableColumns],
|
||||
body: tableRows,
|
||||
startY: 30,
|
||||
styles: { fontSize: 9, cellPadding: 2 },
|
||||
headStyles: { fillColor: [102, 126, 234] },
|
||||
alternateRowStyles: { fillColor: [245, 245, 245] }
|
||||
});
|
||||
|
||||
// Save PDF
|
||||
doc.save(`${filename}_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error);
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export General Totals table
|
||||
*/
|
||||
export const exportGeneralTotals = (summaryData) => {
|
||||
const data = [
|
||||
{
|
||||
Tip: 'Clienți',
|
||||
'Total Facturat': summaryData?.clienti_total_facturat || 0,
|
||||
'Total Încasat': summaryData?.clienti_total_incasat || 0,
|
||||
'Sold Net': summaryData?.clienti_sold_total || 0,
|
||||
'Sold În Termen': summaryData?.clienti_sold_in_termen || 0,
|
||||
'Sold Restant': summaryData?.clienti_sold_restant || 0
|
||||
},
|
||||
{
|
||||
Tip: 'Furnizori',
|
||||
'Total Facturat': summaryData?.furnizori_total_facturat || 0,
|
||||
'Total Achitat': summaryData?.furnizori_total_achitat || 0,
|
||||
'Sold Net': summaryData?.furnizori_sold_total || 0,
|
||||
'Sold În Termen': summaryData?.furnizori_sold_in_termen || 0,
|
||||
'Sold Restant': summaryData?.furnizori_sold_restant || 0
|
||||
},
|
||||
{
|
||||
Tip: 'Trezorerie',
|
||||
'Total Facturat': '-',
|
||||
'Total Încasat/Achitat': '-',
|
||||
'Sold Net': summaryData?.trezorerie_sold || 0,
|
||||
'Sold În Termen': '-',
|
||||
'Sold Restant': '-'
|
||||
}
|
||||
];
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export Sold Net Breakdown table
|
||||
*/
|
||||
export const exportSoldNetBreakdown = (summaryData) => {
|
||||
const data = [
|
||||
{
|
||||
Categorie: 'Clienți - Restant',
|
||||
'TOTAL': summaryData?.clienti_sold_restant || 0,
|
||||
'7 zile': summaryData?.clienti_restant_7 || 0,
|
||||
'14 zile': summaryData?.clienti_restant_14 || 0,
|
||||
'30 zile': summaryData?.clienti_restant_30 || 0,
|
||||
'60 zile': summaryData?.clienti_restant_60 || 0,
|
||||
'90 zile': summaryData?.clienti_restant_90 || 0,
|
||||
'90+ zile': summaryData?.clienti_restant_over_90 || 0
|
||||
},
|
||||
{
|
||||
Categorie: 'Furnizori - Restant',
|
||||
'TOTAL': summaryData?.furnizori_sold_restant || 0,
|
||||
'7 zile': summaryData?.furnizori_restant_7 || 0,
|
||||
'14 zile': summaryData?.furnizori_restant_14 || 0,
|
||||
'30 zile': summaryData?.furnizori_restant_30 || 0,
|
||||
'60 zile': summaryData?.furnizori_restant_60 || 0,
|
||||
'90 zile': summaryData?.furnizori_restant_90 || 0,
|
||||
'90+ zile': summaryData?.furnizori_restant_over_90 || 0
|
||||
}
|
||||
];
|
||||
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Update Dashboard Export Methods
|
||||
**File: `src/views/DashboardView.vue`**
|
||||
|
||||
#### Add import:
|
||||
```javascript
|
||||
import {
|
||||
exportToExcel,
|
||||
exportToPDF,
|
||||
exportGeneralTotals as prepareGeneralTotals,
|
||||
exportSoldNetBreakdown as prepareSoldNetBreakdown
|
||||
} from '@/utils/exportUtils';
|
||||
```
|
||||
|
||||
#### Update export methods:
|
||||
```javascript
|
||||
// Export General Totals to Excel
|
||||
const exportGeneralTotalsExcel = () => {
|
||||
const data = prepareGeneralTotals(dashboardStore.summary);
|
||||
const result = exportToExcel(data, 'totaluri_generale', 'Totaluri Generale');
|
||||
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Export Reușit',
|
||||
detail: 'Fișier Excel generat cu succes',
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare Export',
|
||||
detail: 'Nu s-a putut genera fișierul Excel',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Export General Totals to PDF
|
||||
const exportGeneralTotalsPDF = () => {
|
||||
const data = prepareGeneralTotals(dashboardStore.summary);
|
||||
const columns = [
|
||||
{ field: 'Tip', header: 'Tip', type: 'text' },
|
||||
{ field: 'Total Facturat', header: 'Total Facturat', type: 'currency' },
|
||||
{ field: 'Total Încasat', header: 'Total Încasat/Achitat', type: 'currency' },
|
||||
{ field: 'Sold Net', header: 'Sold Net', type: 'currency' },
|
||||
{ field: 'Sold În Termen', header: 'Sold În Termen', type: 'currency' },
|
||||
{ field: 'Sold Restant', header: 'Sold Restant', type: 'currency' }
|
||||
];
|
||||
|
||||
const result = exportToPDF(
|
||||
data,
|
||||
columns,
|
||||
'totaluri_generale',
|
||||
`Totaluri Generale - ${companyStore.selectedCompany?.name || 'ROA Reports'}`
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Export Reușit',
|
||||
detail: 'Fișier PDF generat cu succes',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Similar methods for Sold Net Breakdown
|
||||
const exportSoldNetExcel = () => {
|
||||
const data = prepareSoldNetBreakdown(dashboardStore.summary);
|
||||
exportToExcel(data, 'detaliere_sold_net', 'Detaliere Sold Net');
|
||||
};
|
||||
|
||||
const exportSoldNetPDF = () => {
|
||||
const data = prepareSoldNetBreakdown(dashboardStore.summary);
|
||||
const columns = [
|
||||
{ field: 'Categorie', header: 'Categorie', type: 'text' },
|
||||
{ field: 'TOTAL', header: 'TOTAL', type: 'currency' },
|
||||
{ field: '7 zile', header: '7 zile', type: 'currency' },
|
||||
{ field: '14 zile', header: '14 zile', type: 'currency' },
|
||||
{ field: '30 zile', header: '30 zile', type: 'currency' },
|
||||
{ field: '60 zile', header: '60 zile', type: 'currency' },
|
||||
{ field: '90 zile', header: '90 zile', type: 'currency' },
|
||||
{ field: '90+ zile', header: '90+ zile', type: 'currency' }
|
||||
];
|
||||
|
||||
exportToPDF(
|
||||
data,
|
||||
columns,
|
||||
'detaliere_sold_net',
|
||||
`Detaliere Sold Net - ${companyStore.selectedCompany?.name || 'ROA Reports'}`
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Update Table Templates with Consistent Buttons
|
||||
**File: `src/views/DashboardView.vue`**
|
||||
|
||||
#### Update all table headers with consistent button groups:
|
||||
|
||||
```html
|
||||
<!-- General Totals Table -->
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Totaluri Generale</h2>
|
||||
<div class="section-controls">
|
||||
<div class="button-group">
|
||||
<button class="btn btn-sm btn-primary" @click="exportGeneralTotalsExcel">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span class="btn-text">Excel</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @click="exportGeneralTotalsPDF">
|
||||
<i class="pi pi-file-pdf"></i>
|
||||
<span class="btn-text">PDF</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline" @click="refreshGeneralTotals">
|
||||
<i class="pi pi-refresh"></i>
|
||||
<span class="btn-text">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sold Net Breakdown Table -->
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">DETALIERE SOLD NET</h2>
|
||||
<div class="section-controls">
|
||||
<div class="button-group">
|
||||
<button class="btn btn-sm btn-primary" @click="exportSoldNetExcel">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span class="btn-text">Excel</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @click="exportSoldNetPDF">
|
||||
<i class="pi pi-file-pdf"></i>
|
||||
<span class="btn-text">PDF</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline" @click="refreshSoldNetBreakdown">
|
||||
<i class="pi pi-refresh"></i>
|
||||
<span class="btn-text">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Section -->
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Analize Trend</h2>
|
||||
<div class="section-controls">
|
||||
<div class="control-group">
|
||||
<label>Perioada:</label>
|
||||
<select :value="selectedPeriod" class="trend-select" @change="(e) => { selectedPeriod = e.target.value; handlePeriodChange(); }">
|
||||
<option value="ytd">An curent (YTD)</option>
|
||||
<option value="12m">Ultimele 12 luni</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Tip Grafic:</label>
|
||||
<select :value="selectedChartType" class="trend-select" @change="(e) => { selectedChartType = e.target.value; handleChartTypeChange(); }">
|
||||
<option value="line">Linie</option>
|
||||
<option value="bar">Bare</option>
|
||||
<option value="area">Arie</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button class="btn btn-sm btn-primary" @click="exportTrendExcel">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span class="btn-text">Excel</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @click="exportTrendPDF">
|
||||
<i class="pi pi-file-pdf"></i>
|
||||
<span class="btn-text">PDF</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline" @click="refreshTrendData">
|
||||
<i class="pi pi-refresh"></i>
|
||||
<span class="btn-text">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6. Button Styling Updates
|
||||
**File: `src/assets/css/components/buttons.css`**
|
||||
|
||||
Add button group styles:
|
||||
```css
|
||||
/* Button Groups for Dashboard */
|
||||
.button-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Hide button text on small screens */
|
||||
@media (max-width: 640px) {
|
||||
.btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack buttons vertically on very small screens */
|
||||
@media (max-width: 480px) {
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
display: inline; /* Show text again when stacked */
|
||||
}
|
||||
}
|
||||
|
||||
/* Primary button style for exports */
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-primary-dark);
|
||||
border-color: var(--color-primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Mobile Testing (320px - 768px)
|
||||
- [ ] Login form is properly sized and centered
|
||||
- [ ] Input fields are touch-friendly (44px min height)
|
||||
- [ ] Tables scroll horizontally
|
||||
- [ ] Table text remains at 14px minimum
|
||||
- [ ] Numbers don't shrink or wrap
|
||||
- [ ] Export buttons are visible and functional
|
||||
- [ ] Buttons stack properly on small screens
|
||||
|
||||
### Tablet Testing (768px - 1024px)
|
||||
- [ ] Tables display properly with slight compression
|
||||
- [ ] All export buttons visible
|
||||
- [ ] Controls layout is optimal
|
||||
|
||||
### Desktop Testing (1024px+)
|
||||
- [ ] Full layout displays correctly
|
||||
- [ ] All features accessible
|
||||
- [ ] Export functions work properly
|
||||
|
||||
### Export Testing
|
||||
- [ ] Excel export generates valid .xlsx files
|
||||
- [ ] PDF export generates readable documents
|
||||
- [ ] All data is included in exports
|
||||
- [ ] Currency formatting is correct
|
||||
- [ ] Date/time stamps are included
|
||||
|
||||
## Dependencies Required
|
||||
```json
|
||||
{
|
||||
"xlsx": "^0.18.5",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.5.31"
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
1. Create export utility functions
|
||||
2. Update button styles in CSS
|
||||
3. Add responsive styles to LoginView
|
||||
4. Update DashboardView with new export methods
|
||||
5. Add consistent button groups to all tables
|
||||
6. Update mobile.css with table fixes
|
||||
7. Test on various devices
|
||||
|
||||
## Notes
|
||||
- Maintain 14px minimum font size for readability
|
||||
- Use horizontal scroll instead of text compression
|
||||
- Keep button styling consistent across all tables
|
||||
- Stack controls vertically on mobile for better UX
|
||||
- Test exports with real data to ensure formatting
|
||||
|
||||
## Success Criteria
|
||||
- ✅ Login form responsive on all devices
|
||||
- ✅ Dashboard tables readable on mobile (no text shrinking)
|
||||
- ✅ All tables have Excel and PDF export buttons
|
||||
- ✅ Buttons use consistent styling
|
||||
- ✅ Export functions work correctly
|
||||
- ✅ Touch-friendly interface on mobile devices
|
||||
Reference in New Issue
Block a user