feat(auth,dashboard): 2FA mobile session persistence and sparkline cards

- Persist 2FA state in sessionStorage to survive mobile page reloads
- Reuse existing valid OTP on re-login to avoid rate limiting and duplicate emails
- Add embedded sparkline charts to SolduriCompactCard with expand toggle
- Mobile dashboard redesigned: 2 pages with enriched compact cards + cashflow type
- Login UI simplified: remove gradient bg, subtitle, icon; use design tokens
- Focus OTP input when session is restored from 2FA state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-26 14:36:22 +00:00
parent cbcd5fe8e7
commit 06cbf8fb9d
13 changed files with 654 additions and 248 deletions

View File

@@ -406,34 +406,45 @@ def create_auth_router(
# Pas 4: Dacă are email → trimitem OTP (2FA) # Pas 4: Dacă are email → trimitem OTP (2FA)
if user_email: if user_email:
code = await create_otp(user_email, actual_username, login_data.server_id) # Check for existing valid OTP (mobile page reload scenario)
existing_entry = get_otp_entry(user_email)
if code is None: if existing_entry:
# Rate limited # OTP already exists and is valid — skip generation and email
raise HTTPException( logger.info(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, f"[2FA] Reusing existing OTP for {user_email[:3]}*** "
detail="Prea multe cereri de cod. Așteptați 10 minute și încercați din nou." f"(user='{actual_username}', skipping email)"
) )
else:
# Generate new OTP
code = await create_otp(user_email, actual_username, login_data.server_id)
# Trimitem emailul if code is None:
try: # Rate limited
from backend.modules.telegram.utils.email_service import get_email_service
email_service = get_email_service()
email_sent = await email_service.send_auth_code(user_email, code, actual_username)
if not email_sent:
logger.error(f"[2FA] Failed to send OTP email to {user_email[:3]}***")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Nu s-a putut trimite codul de verificare. Încercați din nou." detail="Prea multe cereri de cod. Așteptați 10 minute și încercați din nou."
) )
logger.info(f"[2FA] OTP sent to {user_email[:3]}*** for user '{actual_username}'") # Trimitem emailul
try:
from backend.modules.telegram.utils.email_service import get_email_service
email_service = get_email_service()
email_sent = await email_service.send_auth_code(user_email, code, actual_username)
except ImportError: if not email_sent:
# Email service nu e disponibil — fallback la login direct logger.error(f"[2FA] Failed to send OTP email to {user_email[:3]}***")
logger.warning("[2FA] Email service not available, falling back to direct login") raise HTTPException(
user_email = None status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Nu s-a putut trimite codul de verificare. Încercați din nou."
)
logger.info(f"[2FA] OTP sent to {user_email[:3]}*** for user '{actual_username}'")
except ImportError:
# Email service nu e disponibil — fallback la login direct
logger.warning("[2FA] Email service not available, falling back to direct login")
user_email = None
# Pas 5: Dacă 2FA activ → returnăm cerere de cod # Pas 5: Dacă 2FA activ → returnăm cerere de cod
if user_email: if user_email:

View File

@@ -367,14 +367,12 @@
.metric-card { .metric-card {
background: var(--surface-card); background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: var(--card-radius); border-radius: var(--card-radius);
padding: var(--card-padding, 1.5rem); padding: var(--space-xs);
transition: all var(--transition-fast); transition: all var(--transition-fast);
min-height: var(--card-min-height, 200px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--card-gap, 1rem); gap: 2px;
} }
.metric-card:hover { .metric-card:hover {
@@ -425,8 +423,7 @@
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.metric-card { .metric-card {
min-height: calc(var(--card-min-height, 200px) - 40px); padding: var(--space-xs);
padding: var(--card-padding-sm, 1rem);
} }
.metric-value { .metric-value {

View File

@@ -54,7 +54,7 @@
.metrics-row { .metrics-row {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg); gap: var(--space-md);
margin-bottom: var(--space-xl); margin-bottom: var(--space-xl);
} }
@@ -66,16 +66,16 @@
/* ===== Breakdown Patterns ===== */ /* ===== Breakdown Patterns ===== */
.breakdown-section { .breakdown-section {
padding-top: var(--space-lg); padding-top: var(--space-xs);
border-top: 1px solid var(--color-border); border-top: 1px dotted var(--color-border);
margin-top: var(--space-lg); margin-top: var(--space-xs);
} }
.breakdown-item { .breakdown-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--space-sm) 0; padding: 2px 0;
} }
.breakdown-label { .breakdown-label {
@@ -99,7 +99,7 @@
.breakdown-subitem { .breakdown-subitem {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: var(--space-xs) 0; padding: 2px 0;
} }
.breakdown-sublabel { .breakdown-sublabel {
@@ -187,5 +187,5 @@
.breakdown-divider { .breakdown-divider {
height: 1px; height: 1px;
background: var(--color-border); background: var(--color-border);
margin: var(--space-md) 0; margin: var(--space-xs) 0;
} }

View File

@@ -610,10 +610,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
/* Component-specific: Dual-chart layout for CashFlowMetricCard */ /* Component-specific: Dual-chart layout for CashFlowMetricCard */
/* Override min-height for dual chart layout */ /* Ultra-compact cashflow card */
.cashflow-card {
min-height: 420px;
}
/* Metric label and value typography */ /* Metric label and value typography */
.metric-label { .metric-label {
@@ -631,7 +628,7 @@ onBeforeUnmount(() => {
.values-section { .values-section {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
gap: 1rem; gap: 2px;
align-items: start; align-items: start;
} }
@@ -646,8 +643,8 @@ onBeforeUnmount(() => {
.divider { .divider {
width: 1px; width: 1px;
height: 100%; height: 100%;
background: var(--color-border); border-left: 1px dotted var(--color-border);
min-height: 60px; background: none;
} }
/* Charts toggle header */ /* Charts toggle header */
@@ -715,10 +712,6 @@ onBeforeUnmount(() => {
/* Responsive: Stack vertically on mobile */ /* Responsive: Stack vertically on mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.cashflow-card {
min-height: 380px;
}
.values-section { .values-section {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -462,10 +462,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
/* Component-specific: ClientiBalanceCard layout and breakdown */ /* Component-specific: ClientiBalanceCard layout and breakdown */
/* Override min-height for balance card */ /* Ultra-compact clienti balance card */
.clienti-balance-card {
min-height: 280px;
}
/* Mobile header with total and trend */ /* Mobile header with total and trend */
.card-header-mobile { .card-header-mobile {
@@ -474,7 +471,7 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
border-bottom: 1px solid var(--surface-border); border-bottom: 1px dotted var(--surface-border);
} }
.header-left { .header-left {
@@ -643,10 +640,6 @@ onBeforeUnmount(() => {
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.clienti-balance-card {
min-height: 280px;
}
.sparkline-chart { .sparkline-chart {
height: 130px; height: 130px;
} }

View File

@@ -462,10 +462,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
/* Component-specific: FurnizoriBalanceCard layout and breakdown */ /* Component-specific: FurnizoriBalanceCard layout and breakdown */
/* Override min-height for balance card */ /* Ultra-compact furnizori balance card */
.furnizori-balance-card {
min-height: 280px;
}
/* Mobile header with total and trend */ /* Mobile header with total and trend */
.card-header-mobile { .card-header-mobile {
@@ -474,7 +471,7 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
border-bottom: 1px solid var(--surface-border); border-bottom: 1px dotted var(--surface-border);
} }
.header-left { .header-left {
@@ -643,10 +640,6 @@ onBeforeUnmount(() => {
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.furnizori-balance-card {
min-height: 280px;
}
.sparkline-chart { .sparkline-chart {
height: 130px; height: 130px;
} }

View File

@@ -608,10 +608,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
/* Component-specific: TreasuryDualCard (Casa | Bancă) */ /* Component-specific: TreasuryDualCard (Casa | Bancă) */
/* Override min-height for treasury card */ /* Ultra-compact treasury card */
.treasury-dual-card {
min-height: 320px;
}
/* Treasury items container - stacked vertical */ /* Treasury items container - stacked vertical */
.treasury-items { .treasury-items {
@@ -622,8 +619,9 @@ onBeforeUnmount(() => {
/* Treasury group (Casa sau Bancă) */ /* Treasury group (Casa sau Bancă) */
.treasury-group { .treasury-group {
border: 1px solid var(--surface-border); border: none;
border-radius: var(--radius-sm); border-bottom: 1px dotted var(--surface-border);
border-radius: 0;
overflow: hidden; overflow: hidden;
} }
@@ -632,10 +630,10 @@ onBeforeUnmount(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--space-md); padding: 4px 0;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
background: var(--surface-ground); background: transparent;
transition: background-color var(--transition-fast); transition: background-color var(--transition-fast);
} }
@@ -673,16 +671,14 @@ onBeforeUnmount(() => {
/* Treasury sub-items */ /* Treasury sub-items */
.treasury-subitems { .treasury-subitems {
padding: var(--space-sm) var(--space-md) var(--space-md); padding: 0 0 4px var(--space-md);
background: var(--surface-card);
border-top: 1px solid var(--surface-border);
} }
.treasury-subitem { .treasury-subitem {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--space-xs) 0; padding: 2px 0;
} }
.treasury-sublabel { .treasury-sublabel {
@@ -768,24 +764,12 @@ onBeforeUnmount(() => {
/* Responsive: Stack vertically on mobile */ /* Responsive: Stack vertically on mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.treasury-dual-card {
min-height: 280px;
}
.sparkline-chart { .sparkline-chart {
height: 130px; height: 130px;
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.treasury-dual-card {
min-height: 240px;
}
.treasury-header {
padding: var(--space-sm);
}
.treasury-value { .treasury-value {
font-size: var(--text-base); font-size: var(--text-base);
} }

View File

@@ -29,15 +29,31 @@
<!-- Expandable Breakdown Section --> <!-- Expandable Breakdown Section -->
<div v-if="isExpanded && hasBreakdown" class="solduri-compact-card__breakdown"> <div v-if="isExpanded && hasBreakdown" class="solduri-compact-card__breakdown">
<!-- CashFlow: Încasări + Plăți -->
<template v-if="type === 'cashflow'">
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">Încasări</span>
<span class="solduri-compact-card__breakdown-value solduri-compact-card__breakdown-value--success">
{{ formatAmount((breakdown as CashFlowBreakdown)?.incasari || 0) }}
</span>
</div>
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">Plăți</span>
<span class="solduri-compact-card__breakdown-value solduri-compact-card__breakdown-value--danger">
{{ formatAmount((breakdown as CashFlowBreakdown)?.plati || 0) }}
</span>
</div>
</template>
<!-- Trezorerie: Casa + Bancă --> <!-- Trezorerie: Casa + Bancă -->
<template v-if="type === 'trezorerie'"> <template v-else-if="type === 'trezorerie'">
<!-- Casa Total --> <!-- Casa Total -->
<div class="solduri-compact-card__breakdown-item"> <div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">Casa</span> <span class="solduri-compact-card__breakdown-label">Casa</span>
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(casaTotal) }}</span> <span class="solduri-compact-card__breakdown-value">{{ formatAmount(casaTotal) }}</span>
</div> </div>
<!-- Sub-conturi Casa (imediat sub Casa) --> <!-- Sub-conturi Casa (level 2 only) -->
<template v-if="breakdown?.casa?.items?.length"> <template v-if="showSubDetails && breakdown?.casa?.items?.length">
<div <div
v-for="(item, idx) in breakdown.casa.items" v-for="(item, idx) in breakdown.casa.items"
:key="`casa-${idx}`" :key="`casa-${idx}`"
@@ -54,8 +70,8 @@
<span class="solduri-compact-card__breakdown-label">Bancă</span> <span class="solduri-compact-card__breakdown-label">Bancă</span>
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(bancaTotal) }}</span> <span class="solduri-compact-card__breakdown-value">{{ formatAmount(bancaTotal) }}</span>
</div> </div>
<!-- Sub-conturi Bancă (imediat sub Bancă) --> <!-- Sub-conturi Bancă (level 2 only) -->
<template v-if="breakdown?.banca?.items?.length"> <template v-if="showSubDetails && breakdown?.banca?.items?.length">
<div <div
v-for="(item, idx) in breakdown.banca.items" v-for="(item, idx) in breakdown.banca.items"
:key="`banca-${idx}`" :key="`banca-${idx}`"
@@ -83,8 +99,8 @@
{{ formatAmount(breakdown?.restant?.total || 0) }} {{ formatAmount(breakdown?.restant?.total || 0) }}
</span> </span>
</div> </div>
<!-- Perioade restante --> <!-- Perioade restante (level 2 only) -->
<template v-if="breakdown?.restant?.perioade"> <template v-if="showSubDetails && breakdown?.restant?.perioade">
<div <div
v-for="(value, key) in breakdown.restant.perioade" v-for="(value, key) in breakdown.restant.perioade"
:key="key" :key="key"
@@ -169,14 +185,61 @@
</template> </template>
</template> </template>
</div> </div>
<!-- Chart toggle - only shown when card is expanded and chartConfig is provided -->
<div
v-if="isExpanded && chartConfig"
class="solduri-compact-card__charts-toggle"
@click.stop="toggleChartExpanded"
>
<span class="solduri-compact-card__charts-toggle-label">Grafice evoluție</span>
<i
class="pi pi-chevron-right solduri-compact-card__charts-chevron"
:class="{ 'solduri-compact-card__charts-chevron--expanded': isChartExpanded }"
></i>
</div>
<!-- Chart content - lazy rendered with v-if to avoid zero-dimension canvas -->
<div v-if="isExpanded && isChartExpanded && chartConfig" class="solduri-compact-card__charts-content">
<!-- Dual charts (Trezorerie: Casa + Banca) -->
<template v-if="chartConfig.dual">
<div class="solduri-compact-card__chart-wrapper">
<div class="solduri-compact-card__chart-label" :style="{ color: chartConfig.dual.primaryColor }">
{{ chartConfig.dual.primaryLabel }}
</div>
<div class="solduri-compact-card__chart-container">
<canvas ref="chartCanvas1"></canvas>
</div>
</div>
<div class="solduri-compact-card__chart-wrapper">
<div class="solduri-compact-card__chart-label" :style="{ color: chartConfig.dual.secondaryColor }">
{{ chartConfig.dual.secondaryLabel }}
</div>
<div class="solduri-compact-card__chart-container">
<canvas ref="chartCanvas2"></canvas>
</div>
</div>
</template>
<!-- Single chart (Clienti/Furnizori) -->
<template v-else-if="chartConfig.single">
<div class="solduri-compact-card__chart-wrapper">
<div class="solduri-compact-card__chart-container">
<canvas ref="chartCanvas1"></canvas>
</div>
</div>
</template>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
// Type definitions // Type definitions
type CardType = 'trezorerie' | 'clienti' | 'furnizori' | 'tva' type CardType = 'trezorerie' | 'clienti' | 'furnizori' | 'tva' | 'cashflow'
interface TrezorerieBreakdown { interface TrezorerieBreakdown {
casa?: { casa?: {
@@ -198,7 +261,33 @@ interface ClientiFurnizoriBreakdown {
} }
} }
type BreakdownType = TrezorerieBreakdown | ClientiFurnizoriBreakdown | null interface CashFlowBreakdown {
incasari: number
plati: number
}
type BreakdownType = TrezorerieBreakdown | ClientiFurnizoriBreakdown | CashFlowBreakdown | null
interface ChartConfig {
dual?: {
primaryData: number[]
primaryPreviousData?: number[]
primaryLabel: string
primaryColor: string
secondaryData: number[]
secondaryPreviousData?: number[]
secondaryLabel: string
secondaryColor: string
}
single?: {
data: number[]
previousData?: number[]
label: string
color: string
}
labels: string[]
previousLabels?: string[]
}
// Props // Props
const props = defineProps<{ const props = defineProps<{
@@ -208,10 +297,13 @@ const props = defineProps<{
breakdown?: BreakdownType breakdown?: BreakdownType
casaTotal?: number casaTotal?: number
bancaTotal?: number bancaTotal?: number
chartConfig?: ChartConfig
}>() }>()
// State // State - 3-level expansion cycle: 0 (collapsed) → 1 (totals) → 2 (sub-details) → 0
const isExpanded = ref(false) const expansionLevel = ref(0)
const isExpanded = computed(() => expansionLevel.value > 0)
const showSubDetails = computed(() => expansionLevel.value >= 2)
const expandedGroups = ref(new Set<string>()) const expandedGroups = ref(new Set<string>())
const toggleGroup = (key: string) => { const toggleGroup = (key: string) => {
if (expandedGroups.value.has(key)) { if (expandedGroups.value.has(key)) {
@@ -222,13 +314,242 @@ const toggleGroup = (key: string) => {
expandedGroups.value = new Set(expandedGroups.value) expandedGroups.value = new Set(expandedGroups.value)
} }
// Chart state
const isChartExpanded = ref(false)
const chartCanvas1 = ref<HTMLCanvasElement | null>(null)
const chartCanvas2 = ref<HTMLCanvasElement | null>(null)
let chartInstance1: Chart | null = null
let chartInstance2: Chart | null = null
const toggleChartExpanded = () => {
isChartExpanded.value = !isChartExpanded.value
}
// Helper: hex to rgba
const hexToRgba = (hex: string, alpha: number): string => {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// Create a sparkline chart on a canvas element
const createSparklineChart = (
canvas: HTMLCanvasElement,
data: number[],
previousData: number[] | undefined,
color: string,
label: string
): Chart | null => {
if (!canvas || !data || data.length === 0) return null
const ctx = canvas.getContext('2d')
if (!ctx) return null
const labels = props.chartConfig?.labels || data.map((_, i) => `L${i + 1}`)
const datasets: any[] = [{
label: `${label} (curent)`,
data: data,
borderColor: color,
backgroundColor: hexToRgba(color, 0.1),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: color,
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2,
}]
if (previousData && previousData.length > 0) {
datasets.push({
label: `${label} (an precedent)`,
data: previousData,
borderColor: hexToRgba(color, 0.4),
backgroundColor: hexToRgba(color, 0.05),
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: hexToRgba(color, 0.6),
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2,
})
}
// Y-axis: symmetric range with padding (from ClientiBalanceCard pattern)
const allDataPoints = [...data]
if (previousData && previousData.length > 0) allDataPoints.push(...previousData)
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
const minVisibleRange = dataMean * 0.25
const center = (dataMin + dataMax) / 2
const targetRange = Math.max(dataRange, minVisibleRange)
const calculatedMin = center - targetRange / 2
const calculatedMax = center + targetRange / 2
const paddingAmount = targetRange * 0.1
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
return new Chart(ctx, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
labels: {
boxWidth: 12, boxHeight: 12, padding: 8,
font: { size: 10, family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' },
color: 'rgba(107, 114, 128, 0.9)',
usePointStyle: true, pointStyle: 'line',
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context: any) => context[0].label || '',
label: (context: any) => {
const value = context.parsed.y
const lbl = context.dataset.label || ''
const formatted = new Intl.NumberFormat('ro-RO', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
return `${lbl}: ${formatted}`
},
},
},
},
scales: {
x: {
display: true,
grid: { display: false, drawBorder: false },
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: { size: 10, family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' },
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: { display: false },
},
y: {
display: true,
min: yMin,
max: yMax,
grid: { color: 'rgba(107, 114, 128, 0.1)', drawBorder: false },
ticks: {
color: color,
font: { size: 11, family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' },
maxTicksLimit: 3,
callback: function(value: number | string) {
const num = typeof value === 'string' ? parseFloat(value) : value
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) return (num / 1000).toFixed(0) + 'k'
return num.toFixed(0)
},
},
border: { display: false },
},
},
},
})
}
// Create all charts based on chartConfig
const createCharts = () => {
destroyCharts()
const config = props.chartConfig
if (!config) return
if (config.dual) {
if (chartCanvas1.value) {
chartInstance1 = createSparklineChart(
chartCanvas1.value,
config.dual.primaryData,
config.dual.primaryPreviousData,
config.dual.primaryColor,
config.dual.primaryLabel
)
}
if (chartCanvas2.value) {
chartInstance2 = createSparklineChart(
chartCanvas2.value,
config.dual.secondaryData,
config.dual.secondaryPreviousData,
config.dual.secondaryColor,
config.dual.secondaryLabel
)
}
} else if (config.single) {
if (chartCanvas1.value) {
chartInstance1 = createSparklineChart(
chartCanvas1.value,
config.single.data,
config.single.previousData,
config.single.color,
config.single.label
)
}
}
}
// Destroy chart instances
const destroyCharts = () => {
if (chartInstance1) { chartInstance1.destroy(); chartInstance1 = null }
if (chartInstance2) { chartInstance2.destroy(); chartInstance2 = null }
}
// Reset chart state when card is collapsed
watch(expansionLevel, (level) => {
if (level === 0) {
isChartExpanded.value = false
destroyCharts()
}
})
// Watch chart expansion - lazy initialize charts
watch(isChartExpanded, async (expanded) => {
if (expanded && props.chartConfig) {
await nextTick()
createCharts()
} else {
destroyCharts()
}
})
// Cleanup on unmount
onBeforeUnmount(() => {
destroyCharts()
})
// Computed: Label based on type // Computed: Label based on type
const label = computed(() => { const label = computed(() => {
const labels: Record<CardType, string> = { const labels: Record<CardType, string> = {
trezorerie: 'TREZORERIE', trezorerie: 'TREZORERIE',
clienti: 'CLIENȚI', clienti: 'CLIENȚI',
furnizori: 'FURNIZORI', furnizori: 'FURNIZORI',
tva: 'DATORII BUGET' tva: 'DATORII BUGET',
cashflow: 'CASH FLOW'
} }
return labels[props.type] || props.type.toUpperCase() return labels[props.type] || props.type.toUpperCase()
}) })
@@ -242,6 +563,11 @@ const valueColorClass = computed(() => {
? 'solduri-compact-card__value--danger' ? 'solduri-compact-card__value--danger'
: 'solduri-compact-card__value--success' : 'solduri-compact-card__value--success'
} }
if (props.type === 'cashflow') {
return props.total >= 0
? 'solduri-compact-card__value--success'
: 'solduri-compact-card__value--danger'
}
return '' return ''
}) })
@@ -264,14 +590,25 @@ const hasBreakdown = computed(() => {
if (props.type === 'tva') { if (props.type === 'tva') {
return props.breakdown !== null && props.breakdown !== undefined return props.breakdown !== null && props.breakdown !== undefined
} }
if (props.type === 'cashflow') {
return props.breakdown !== null && props.breakdown !== undefined
}
return false return false
}) })
// Max expansion levels per card type
const maxExpansionLevel = computed(() => {
// TVA and cashflow: only 1 level (0 → 1 → 0)
if (props.type === 'tva' || props.type === 'cashflow') return 1
// Trezorerie, clienti, furnizori: 2 levels (0 → 1 → 2 → 0)
return 2
})
// Methods // Methods
const toggleExpanded = () => { const toggleExpanded = () => {
if (hasBreakdown.value) { if (!hasBreakdown.value) return
isExpanded.value = !isExpanded.value const next = expansionLevel.value + 1
} expansionLevel.value = next > maxExpansionLevel.value ? 0 : next
} }
const formatAmount = (amount: number | undefined | null): string => { const formatAmount = (amount: number | undefined | null): string => {
@@ -563,4 +900,76 @@ const formatPeriodLabel = (key: string): string => {
min-height: 80px; min-height: 80px;
} }
} }
/* Chart toggle */
.solduri-compact-card__charts-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
min-height: 44px;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.solduri-compact-card__charts-toggle:hover {
background: var(--surface-border);
}
.solduri-compact-card__charts-toggle-label {
user-select: none;
}
.solduri-compact-card__charts-chevron {
font-size: var(--text-xs);
color: var(--color-text-secondary);
transition: transform var(--transition-fast);
}
.solduri-compact-card__charts-chevron--expanded {
transform: rotate(90deg);
}
/* Chart content */
.solduri-compact-card__charts-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.solduri-compact-card__chart-wrapper {
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
}
.solduri-compact-card__chart-label {
font-size: var(--text-xs);
font-weight: var(--font-semibold);
margin-bottom: var(--space-xs);
}
.solduri-compact-card__chart-container {
height: 150px;
position: relative;
}
.solduri-compact-card__chart-container canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive - Chart adjustments */
@media (max-width: 480px) {
.solduri-compact-card__chart-container {
height: 130px;
}
}
</style> </style>

View File

@@ -49,9 +49,9 @@
<!-- Secțiune Carduri Noi - Adăugare --> <!-- Secțiune Carduri Noi - Adăugare -->
<div class="metrics-cards-section"> <div class="metrics-cards-section">
<!-- Mobile: Swipeable KPI Cards Carousel --> <!-- Mobile: Swipeable KPI Cards Carousel -->
<!-- US-2002: 6 pages - first page is 2x2 grid with solduri, pages 2-5 are original graph cards, page 6 is financial indicators --> <!-- 2 pages: enriched compact cards with embedded charts + financial indicators -->
<SwipeableCards v-if="isMobile" :totalCards="6" :fixed-dots="true" :fill-height="true" class="mobile-kpi-carousel"> <SwipeableCards v-if="isMobile" :totalCards="2" :fixed-dots="true" :fill-height="true" class="mobile-kpi-carousel">
<!-- Page 1: Grid 2x2 cu Solduri Compacte --> <!-- Page 1: Compact cards with embedded sparkline charts -->
<template #card-0> <template #card-0>
<div class="solduri-grid-2x2"> <div class="solduri-grid-2x2">
<SolduriCompactCard <SolduriCompactCard
@@ -60,16 +60,65 @@
:casaTotal="treasuryData?.breakdown?.casa?.total || 0" :casaTotal="treasuryData?.breakdown?.casa?.total || 0"
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0" :bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
:breakdown="treasuryData?.breakdown" :breakdown="treasuryData?.breakdown"
:chartConfig="{
dual: {
primaryData: casaSparkline,
primaryPreviousData: casaPreviousSparkline,
primaryLabel: 'Casa',
primaryColor: '#10b981',
secondaryData: bancaSparkline,
secondaryPreviousData: bancaPreviousSparkline,
secondaryLabel: 'Bancă',
secondaryColor: '#3b82f6'
},
labels: sparklineLabels
}"
/>
<SolduriCompactCard
type="cashflow"
:total="netCashFlow"
:breakdown="{ incasari: monthlyInflows, plati: monthlyOutflows }"
:chartConfig="{
dual: {
primaryData: inflowsSparkline,
primaryPreviousData: inflowsPreviousSparkline,
primaryLabel: 'Încasări',
primaryColor: '#10b981',
secondaryData: outflowsSparkline,
secondaryPreviousData: outflowsPreviousSparkline,
secondaryLabel: 'Plăți',
secondaryColor: '#ef4444'
},
labels: sparklineLabels
}"
/> />
<SolduriCompactCard <SolduriCompactCard
type="clienti" type="clienti"
:total="netBalanceData?.clienti_total || 0" :total="netBalanceData?.clienti_total || 0"
:breakdown="netBalanceData?.breakdown?.clienti" :breakdown="netBalanceData?.breakdown?.clienti"
:chartConfig="{
single: {
data: clientiSparkline,
previousData: clientiPreviousSparkline,
label: 'Clienți',
color: '#10b981'
},
labels: sparklineLabels
}"
/> />
<SolduriCompactCard <SolduriCompactCard
type="furnizori" type="furnizori"
:total="netBalanceData?.furnizori_total || 0" :total="netBalanceData?.furnizori_total || 0"
:breakdown="netBalanceData?.breakdown?.furnizori" :breakdown="netBalanceData?.breakdown?.furnizori"
:chartConfig="{
single: {
data: furnizoriSparkline,
previousData: furnizoriPreviousSparkline,
label: 'Furnizori',
color: '#f59e0b'
},
labels: sparklineLabels
}"
/> />
<SolduriCompactCard <SolduriCompactCard
type="tva" type="tva"
@@ -79,68 +128,8 @@
/> />
</div> </div>
</template> </template>
<!-- Page 2: TreasuryDualCard (original graph card) --> <!-- Page 2: FinancialIndicatorsCard (US-015) -->
<template #card-1> <template #card-1>
<TreasuryDualCard
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
:casaItems="treasuryData?.breakdown?.casa?.items || []"
:bancaItems="treasuryData?.breakdown?.banca?.items || []"
:casaTrend="casaTrend"
:bancaTrend="bancaTrend"
:casaSparklineData="casaSparkline"
:bancaSparklineData="bancaSparkline"
:casaPreviousSparklineData="casaPreviousSparkline"
:bancaPreviousSparklineData="bancaPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="treasuryCacheInfo"
/>
</template>
<!-- Page 3: CashFlowMetricCard (original graph card) -->
<template #card-2>
<CashFlowMetricCard
:inflowsValue="monthlyInflows"
:outflowsValue="monthlyOutflows"
:inflowsTrend="inflowsTrend"
:outflowsTrend="outflowsTrend"
:inflowsSparkline="inflowsSparkline"
:outflowsSparkline="outflowsSparkline"
:inflowsPreviousSparkline="inflowsPreviousSparkline"
:outflowsPreviousSparkline="outflowsPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="cashflowCacheInfo"
/>
</template>
<!-- Page 4: ClientiBalanceCard (original graph card) -->
<template #card-3>
<ClientiBalanceCard
:total="netBalanceData?.clienti_total || 0"
:trend="clientiTrend"
:sparklineData="clientiSparkline"
:previousSparklineData="clientiPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.clienti"
:cacheInfo="netBalanceCacheInfo"
/>
</template>
<!-- Page 5: FurnizoriBalanceCard (original graph card) -->
<template #card-4>
<FurnizoriBalanceCard
:total="netBalanceData?.furnizori_total || 0"
:trend="furnizoriTrend"
:sparklineData="furnizoriSparkline"
:previousSparklineData="furnizoriPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.furnizori"
:cacheInfo="netBalanceCacheInfo"
/>
</template>
<!-- Page 6: FinancialIndicatorsCard (US-015) -->
<template #card-5>
<FinancialIndicatorsCard <FinancialIndicatorsCard
:loading="dashboardStore.financialIndicators.loading" :loading="dashboardStore.financialIndicators.loading"
:error="dashboardStore.financialIndicators.error" :error="dashboardStore.financialIndicators.error"

View File

@@ -1,16 +1,10 @@
<template> <template>
<div class="login-container">
<div class="login-wrapper"> <div class="login-wrapper">
<Card class="login-card"> <Card class="login-card">
<template #header>
<div class="login-header">
<i :class="['pi', appIcon, 'text-primary', 'text-6xl']"></i>
<h1 class="login-title">{{ appTitle }}</h1>
<p class="login-subtitle">{{ appSubtitle }}</p>
</div>
</template>
<template #content> <template #content>
<div class="login-header">
<h1 class="login-title">{{ appTitle }}</h1>
</div>
<!-- Loading state while detecting auth mode --> <!-- Loading state while detecting auth mode -->
<div v-if="authStore.loginStep === 'loading'" class="login-loading"> <div v-if="authStore.loginStep === 'loading'" class="login-loading">
<i class="pi pi-spin pi-spinner text-4xl text-primary"></i> <i class="pi pi-spin pi-spinner text-4xl text-primary"></i>
@@ -20,7 +14,6 @@
<!-- 2FA Step verificare cod --> <!-- 2FA Step verificare cod -->
<div v-else-if="authStore.loginStep === '2fa'" class="login-2fa"> <div v-else-if="authStore.loginStep === '2fa'" class="login-2fa">
<div class="twofa-header"> <div class="twofa-header">
<i class="pi pi-envelope text-primary twofa-icon"></i>
<p class="twofa-info"> <p class="twofa-info">
Cod trimis la <strong>{{ authStore.otpMaskedEmail }}</strong> Cod trimis la <strong>{{ authStore.otpMaskedEmail }}</strong>
</p> </p>
@@ -176,7 +169,7 @@
<template #footer> <template #footer>
<div class="login-footer"> <div class="login-footer">
<small class="text-color-secondary"> <small class="text-color-secondary">
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate ROMFAST © {{ currentYear }}
</small> </small>
</div> </div>
</template> </template>
@@ -214,7 +207,6 @@
</template> </template>
</Dialog> </Dialog>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@@ -234,11 +226,11 @@ const props = defineProps({
}, },
appSubtitle: { appSubtitle: {
type: String, type: String,
required: true, default: '',
}, },
appIcon: { appIcon: {
type: String, type: String,
required: true, default: '',
}, },
redirectPath: { redirectPath: {
type: String, type: String,
@@ -468,11 +460,6 @@ const handleUnifiedInput = (event) => {
val = val.slice(0, 8); val = val.slice(0, 8);
} }
unifiedCode.value = val; unifiedCode.value = val;
// Auto-submit la 6 cifre (OTP complet)
if (/^\d{6}$/.test(val)) {
handleVerifyUnified();
}
}; };
// Verificare unificată — detectează tipul codului și apelează handler-ul corect // Verificare unificată — detectează tipul codului și apelează handler-ul corect
@@ -593,11 +580,15 @@ onMounted(async () => {
// Detect auth mode and set appropriate login step (US-011) // Detect auth mode and set appropriate login step (US-011)
await props.authStore.getAuthMode(); await props.authStore.getAuthMode();
// Focus identity field after auth mode is detected // Focus the right input based on current step
setTimeout(() => { setTimeout(() => {
const identityInput = document.getElementById("identity"); if (props.authStore.loginStep === '2fa') {
if (identityInput) { // Restored from session — focus OTP input
identityInput.focus(); const otpInput = document.getElementById("otp-code");
if (otpInput) otpInput.focus();
} else {
const identityInput = document.getElementById("identity");
if (identityInput) identityInput.focus();
} }
}, 100); }, 100);
}); });
@@ -647,10 +638,6 @@ onUnmounted(() => {
gap: var(--space-sm); gap: var(--space-sm);
} }
.twofa-icon {
font-size: 2.5rem;
}
.twofa-info { .twofa-info {
color: var(--text-color); color: var(--text-color);
margin: 0; margin: 0;

View File

@@ -28,6 +28,11 @@ const STORAGE_KEYS = {
AUTH_MODE: "auth_mode", AUTH_MODE: "auth_mode",
}; };
// sessionStorage keys (tab-scoped, auto-cleared on tab close)
const SESSION_KEYS = {
PENDING_2FA: "pending_2fa",
};
/** /**
* Returnează cheia localStorage pentru tokenul de trusted device. * Returnează cheia localStorage pentru tokenul de trusted device.
* Cheia e per-user și per-server pentru izolare corectă. * Cheia e per-user și per-server pentru izolare corectă.
@@ -148,6 +153,12 @@ export function createAuthStore(apiService, options = {}) {
authMode.value = mode; authMode.value = mode;
localStorage.setItem(STORAGE_KEYS.AUTH_MODE, mode); localStorage.setItem(STORAGE_KEYS.AUTH_MODE, mode);
// Try to restore pending 2FA session (mobile page reload)
if (_restore2FASession()) {
_startResendCountdown();
return { mode, supports_email_login };
}
// Set initial login step based on auth mode // Set initial login step based on auth mode
if (mode === "single-server") { if (mode === "single-server") {
loginStep.value = "username"; loginStep.value = "username";
@@ -272,6 +283,7 @@ export function createAuthStore(apiService, options = {}) {
// Salvăm server_id pending — verify2FA() îl va folosi în loc de selectedServerId // Salvăm server_id pending — verify2FA() îl va folosi în loc de selectedServerId
pendingServerId.value = credentials.server_id || null; pendingServerId.value = credentials.server_id || null;
loginStep.value = "2fa"; loginStep.value = "2fa";
_save2FASession();
_startResendCountdown(); _startResendCountdown();
return { success: true, requires_2fa: true, masked_email: responseData.masked_email }; return { success: true, requires_2fa: true, masked_email: responseData.masked_email };
} }
@@ -338,6 +350,7 @@ export function createAuthStore(apiService, options = {}) {
is2FALoading.value = false; is2FALoading.value = false;
pendingServerId.value = null; pendingServerId.value = null;
_stopResendCountdown(); _stopResendCountdown();
_clear2FASession();
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN); localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
@@ -415,6 +428,7 @@ export function createAuthStore(apiService, options = {}) {
is2FALoading.value = false; is2FALoading.value = false;
pendingServerId.value = null; pendingServerId.value = null;
_stopResendCountdown(); _stopResendCountdown();
_clear2FASession();
}; };
/** /**
@@ -467,6 +481,62 @@ export function createAuthStore(apiService, options = {}) {
resendCountdown.value = 0; resendCountdown.value = 0;
}; };
// -------------------------------------------------------------------------
// 2FA SESSION PERSISTENCE (mobile page reload fix)
// -------------------------------------------------------------------------
const _save2FASession = () => {
try {
sessionStorage.setItem(SESSION_KEYS.PENDING_2FA, JSON.stringify({
otpEmail: otpEmail.value,
otpMaskedEmail: otpMaskedEmail.value,
pendingServerId: pendingServerId.value,
loginEmail: loginEmail.value,
selectedServerId: selectedServerId.value,
savedAt: Date.now(),
}));
} catch (e) {
// sessionStorage may be unavailable (Safari private mode)
}
};
const _clear2FASession = () => {
try { sessionStorage.removeItem(SESSION_KEYS.PENDING_2FA); } catch (e) { /* ignore */ }
};
const _restore2FASession = () => {
try {
const raw = sessionStorage.getItem(SESSION_KEYS.PENDING_2FA);
if (!raw) return false;
const session = JSON.parse(raw);
// Expire after 5 minutes (matches OTP_EXPIRY_MINUTES)
if (Date.now() - session.savedAt > 5 * 60 * 1000) {
_clear2FASession();
return false;
}
if (!session.otpEmail || !session.otpMaskedEmail) {
_clear2FASession();
return false;
}
// Restore state
otpEmail.value = session.otpEmail;
otpMaskedEmail.value = session.otpMaskedEmail;
pendingServerId.value = session.pendingServerId || null;
loginEmail.value = session.loginEmail || "";
selectedServerId.value = session.selectedServerId || selectedServerId.value;
loginStep.value = "2fa";
return true;
} catch (e) {
_clear2FASession();
return false;
}
};
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// 2FA ACTIONS // 2FA ACTIONS
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -526,6 +596,7 @@ export function createAuthStore(apiService, options = {}) {
pendingServerId.value = null; pendingServerId.value = null;
_stopResendCountdown(); _stopResendCountdown();
loginStep.value = "complete"; loginStep.value = "complete";
_clear2FASession();
const backupCodes = response.data.backup_codes; const backupCodes = response.data.backup_codes;
return { success: true, backup_codes: backupCodes }; return { success: true, backup_codes: backupCodes };
@@ -615,6 +686,7 @@ export function createAuthStore(apiService, options = {}) {
pendingServerId.value = null; pendingServerId.value = null;
_stopResendCountdown(); _stopResendCountdown();
loginStep.value = "complete"; loginStep.value = "complete";
_clear2FASession();
return { success: true }; return { success: true };
} catch (err) { } catch (err) {

View File

@@ -1,52 +1,46 @@
/* Shared Login Page Styles */ /* Shared Login Page Styles */
.login-container { /* Override main-content when login is displayed:
removes header offset and turns it into the centering container */
.main-content:has(.login-wrapper) {
margin-top: 0;
padding: 0;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient( background: var(--surface-ground);
135deg,
var(--color-primary-light) 0%,
var(--color-primary) 100%
);
padding: 1rem;
} }
.login-wrapper { .login-wrapper {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
padding: 1rem;
} }
.login-card { .login-card {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-sm);
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--surface-border);
background: var(--surface-card); background: var(--surface-card);
} }
.login-header { .login-header {
text-align: center; text-align: center;
padding: 2rem 2rem 1rem 2rem; padding: var(--space-lg) var(--space-lg) var(--space-sm);
background: var(--surface-card); background: var(--surface-card);
} }
.login-title { .login-title {
margin: 1rem 0 0.5rem 0; margin: 0;
color: var(--primary-color); color: var(--primary-color);
font-size: 2rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
} }
.login-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.95rem;
}
.login-form { .login-form {
padding: 0 2rem 2rem 2rem; padding: 0 var(--space-lg) var(--space-lg);
background: var(--surface-card); background: var(--surface-card);
} }
@@ -55,22 +49,20 @@
padding: 0.75rem; padding: 0.75rem;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
background: var(--color-primary-light) !important; background: var(--color-primary) !important;
color: white !important; color: white !important;
border: none !important; border: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: none;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.login-button:hover { .login-button:hover {
background: var(--color-primary) !important; background: var(--color-primary-dark, var(--color-primary)) !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
} }
.login-button:active { .login-button:active {
transform: translateY(0); box-shadow: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.login-error-message { .login-error-message {
@@ -88,9 +80,9 @@
.login-footer { .login-footer {
text-align: center; text-align: center;
padding: 1rem 2rem; padding: var(--space-sm) var(--space-lg);
background-color: var(--surface-ground); background: transparent;
border-top: 1px solid var(--surface-border); border-top: none;
} }
.login-footer small { .login-footer small {
@@ -98,10 +90,6 @@
} }
/* Dark mode support */ /* Dark mode support */
[data-theme="dark"] .login-footer {
background-color: var(--surface-ground);
}
[data-theme="dark"] .login-error-message { [data-theme="dark"] .login-error-message {
background-color: rgba(239, 68, 68, 0.15); background-color: rgba(239, 68, 68, 0.15);
color: var(--red-300); color: var(--red-300);
@@ -110,13 +98,9 @@
/* Responsive design */ /* Responsive design */
@media (max-width: 768px) { @media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper { .login-wrapper {
max-width: 100%; max-width: 100%;
padding: 0 1rem; padding: 0.5rem;
} }
.login-card { .login-card {
@@ -124,7 +108,7 @@
} }
.login-header { .login-header {
padding: 1.5rem 1rem; padding: var(--space-md) var(--space-md) var(--space-xs);
} }
.login-title { .login-title {
@@ -132,23 +116,23 @@
} }
.login-form { .login-form {
padding: 0 1rem 1.5rem 1rem; padding: 0 var(--space-md) var(--space-md);
} }
/* Ensure inputs are touch-friendly */ /* Ensure inputs are touch-friendly */
.login-container .p-inputtext, .login-wrapper .p-inputtext,
.login-container .p-password input { .login-wrapper .p-password input {
min-height: 44px; min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */ font-size: 16px; /* Prevents zoom on iOS */
} }
.login-footer { .login-footer {
padding: 1rem; padding: var(--space-sm) var(--space-md);
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.login-container { .login-wrapper {
padding: 0.25rem; padding: 0.25rem;
} }
@@ -157,23 +141,19 @@
} }
.login-header { .login-header {
padding: 1rem 0.5rem; padding: var(--space-sm) var(--space-sm) var(--space-xs);
} }
.login-title { .login-title {
font-size: 1.25rem; font-size: 1.25rem;
} }
.login-subtitle {
font-size: 0.875rem;
}
.login-form { .login-form {
padding: 0 0.5rem 1rem 0.5rem; padding: 0 var(--space-sm) var(--space-md);
} }
.login-footer { .login-footer {
padding: 0.75rem 0.5rem; padding: var(--space-xs) var(--space-sm);
} }
} }

View File

@@ -1,8 +1,6 @@
<template> <template>
<LoginView <LoginView
app-title="ROA2WEB" app-title="ROAWEB"
app-subtitle="Sistem Unificat - Rapoarte & Introduceri Date"
app-icon="pi-chart-bar"
redirect-path="/reports/dashboard" redirect-path="/reports/dashboard"
:auth-store="authStore" :auth-store="authStore"
/> />