diff --git a/api/src/controllers/expense.controller.ts b/api/src/controllers/expense.controller.ts index bc6044a..8a3582d 100644 --- a/api/src/controllers/expense.controller.ts +++ b/api/src/controllers/expense.controller.ts @@ -266,7 +266,8 @@ const parsePositiveInt = (value: unknown, fallback: number, max = 100) => { return Math.min(Math.max(Math.trunc(parsed), 1), max); }; const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus); -const approvalNeedsProof = (nextStatus: ExpenseStatus) => nextStatus === 'APPROVED'; +const isShoppingListImportedExpense = (customFields?: Record | null) => String(customFields?.externalSource ?? '').trim().toLowerCase() === 'shopping-list-api'; +const approvalNeedsProof = (nextStatus: ExpenseStatus, customFields?: Record | null) => nextStatus === 'APPROVED' && !isShoppingListImportedExpense(customFields); const loadOwnedExpenses = async (ids: string[], user: AuthenticatedRequest['user']) => { const where = user?.role === 'ADMIN' @@ -442,7 +443,7 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) => return res.status(400).json({ message: 'A new expense can start only as draft, pending, or approved.' }); } - if (approvalNeedsProof(parsed.data.status) && uploadedFiles.length === 0) { + if (approvalNeedsProof(parsed.data.status, parsed.data.customFields) && uploadedFiles.length === 0) { removeUploadedFiles(uploadedFiles); return res.status(400).json({ message: 'An attachment is required before an expense can be approved.' }); } @@ -559,7 +560,7 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) => const proofIdsToRemove = new Set(parsed.data.removeProofIds); const remainingProofs = item.proofs.filter((proof) => !proofIdsToRemove.has(proof.id)); - if (approvalNeedsProof(parsed.data.status) && remainingProofs.length + uploadedFiles.length === 0) { + if (approvalNeedsProof(parsed.data.status, parsed.data.customFields) && remainingProofs.length + uploadedFiles.length === 0) { removeUploadedFiles(uploadedFiles); return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' }); } @@ -646,7 +647,7 @@ export const bulkUpdateExpenseStatus = async (req: AuthenticatedRequest, res: Re return res.status(400).json({ message: `Status transition from ${invalidTransition.status} to ${parsed.data.status} is not allowed for ${invalidTransition.title}.` }); } - const missingProof = items.find((item) => approvalNeedsProof(parsed.data.status) && !item.proofs.length); + const missingProof = items.find((item) => approvalNeedsProof(parsed.data.status, item.customFields) && !item.proofs.length); if (missingProof) { return res.status(400).json({ message: `Add at least one attachment before approving ${missingProof.title}.` }); } @@ -694,7 +695,7 @@ export const updateExpenseStatus = async (req: AuthenticatedRequest, res: Respon return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` }); } - if (approvalNeedsProof(parsed.data.status) && !item.proofs.length) { + if (approvalNeedsProof(parsed.data.status, item.customFields) && !item.proofs.length) { return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' }); } diff --git a/web/src/app/core/services/ui.service.ts b/web/src/app/core/services/ui.service.ts index cbea2f1..54c4f23 100644 --- a/web/src/app/core/services/ui.service.ts +++ b/web/src/app/core/services/ui.service.ts @@ -82,6 +82,14 @@ const translations: Record> = { 'dashboard.recent': 'Ostatnie wydatki', 'dashboard.recentHint': 'Ostatnio dodane pozycje wraz z kontrahentami.', 'dashboard.noChartData': 'Brak danych do pokazania wykresu kategorii.', + 'dashboard.trend': 'Wykres wydatków', + 'dashboard.noTrendData': 'Brak danych do wykresu wydatków.', + 'dashboard.range.1m': 'Miesiąc', + 'dashboard.range.2m': '2 mies.', + 'dashboard.range.3m': '3 mies.', + 'dashboard.range.q': 'Kwartał', + 'dashboard.range.6m': 'Pół roku', + 'dashboard.range.12m': 'Rok', 'stats.title': 'Statystyki', 'stats.subtitle': 'Analiza miesięczna, kwartalna i roczna z podziałem na kategorie i zakres dat.', @@ -529,6 +537,14 @@ const translations: Record> = { 'dashboard.recent': 'Recent expenses', 'dashboard.recentHint': 'Most recently added items with merchants.', 'dashboard.noChartData': 'No category chart data available.', + 'dashboard.trend': 'Expense trend', + 'dashboard.noTrendData': 'No expense trend data available.', + 'dashboard.range.1m': 'Month', + 'dashboard.range.2m': '2 mo', + 'dashboard.range.3m': '3 mo', + 'dashboard.range.q': 'Quarter', + 'dashboard.range.6m': 'Half year', + 'dashboard.range.12m': 'Year', 'stats.title': 'Statistics', 'stats.subtitle': 'Monthly, quarterly and yearly analysis by category and date range.', diff --git a/web/src/app/features/dashboard/dashboard.component.ts b/web/src/app/features/dashboard/dashboard.component.ts index fdedf10..f2ebdec 100644 --- a/web/src/app/features/dashboard/dashboard.component.ts +++ b/web/src/app/features/dashboard/dashboard.component.ts @@ -1,16 +1,23 @@ import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; -import { AfterViewChecked, Component, OnDestroy, OnInit, inject } from '@angular/core'; -import { Chart, ArcElement, DoughnutController, Legend, Tooltip } from 'chart.js'; +import { AfterViewChecked, Component, OnDestroy, OnInit, effect, inject } from '@angular/core'; +import { Chart, ArcElement, CategoryScale, DoughnutController, Legend, LineController, LineElement, LinearScale, PointElement, Tooltip } from 'chart.js'; +import { AuthService } from '../../core/services/auth.service'; import { ExpensesService } from '../../core/services/expenses.service'; import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service'; import { StatsService } from '../../core/services/stats.service'; import { UiService } from '../../core/services/ui.service'; import type { CashflowResponse, Expense, ShoppingListSummary, StatsResponse } from '../../shared/models'; -Chart.register(DoughnutController, ArcElement, Tooltip, Legend); +Chart.register(DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale); -const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v6'; +const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v7'; const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c']; +type TimelineRangeKey = '1m' | '2m' | '3m' | 'q' | '6m' | '12m'; + +type TimelineRangeOption = { + value: TimelineRangeKey; + labelKey: string; +}; const formatLocalDate = (date: Date) => { const year = date.getFullYear(); @@ -19,10 +26,27 @@ const formatLocalDate = (date: Date) => { return `${year}-${month}-${day}`; }; +const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); + +const addMonths = (date: Date, amount: number) => { + const next = new Date(date); + next.setMonth(next.getMonth() + amount, 1); + return next; +}; + const getMonthRange = () => { const today = new Date(); return { - start: formatLocalDate(new Date(today.getFullYear(), today.getMonth(), 1)), + start: formatLocalDate(startOfMonth(today)), + end: formatLocalDate(today) + }; +}; + +const getTimelineRange = (range: TimelineRangeKey) => { + const today = new Date(); + const monthsBack = ({ '1m': 0, '2m': 1, '3m': 2, q: 2, '6m': 5, '12m': 11 } as Record)[range] ?? 0; + return { + start: formatLocalDate(startOfMonth(addMonths(today, -monthsBack))), end: formatLocalDate(today) }; }; @@ -47,8 +71,36 @@ const getMonthRange = () => {
{{ ui.t('dashboard.budgetUsage') }}
{{ cashflow?.budgetUsagePercent || 0 }}%
{{ ui.t('cashflow.forecast') }}
{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}
-
{{ ui.t('dashboard.externalSpend') }}
{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}
-
{{ ui.t('dashboard.externalRecords') }}
{{ externalCount() }}
+ @if (canShowExternalStats()) { +
{{ ui.t('dashboard.externalSpend') }}
{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}
+
{{ ui.t('dashboard.externalRecords') }}
{{ externalCount() }}
+ } + +
+
+
+

{{ ui.t('dashboard.trend') }}

+
+ @for (option of timelineRangeOptions; track option.value) { + + } +
+
+
+ @if (timelineStats?.timeline?.length) { +
+ } @else { +
{{ ui.t('dashboard.noTrendData') }}
+ } +
+
+
@@ -133,39 +185,118 @@ const getMonthRange = () => { }) export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy { readonly ui = inject(UiService); + readonly auth = inject(AuthService); private readonly expensesService = inject(ExpensesService); private readonly statsService = inject(StatsService); private readonly shoppingIntegration = inject(ShoppingListIntegrationService); + readonly timelineRangeOptions: TimelineRangeOption[] = [ + { value: '1m', labelKey: 'dashboard.range.1m' }, + { value: '2m', labelKey: 'dashboard.range.2m' }, + { value: '3m', labelKey: 'dashboard.range.3m' }, + { value: 'q', labelKey: 'dashboard.range.q' }, + { value: '6m', labelKey: 'dashboard.range.6m' }, + { value: '12m', labelKey: 'dashboard.range.12m' } + ]; recentExpenses: Expense[] = []; stats: StatsResponse | null = null; + timelineStats: StatsResponse | null = null; cashflow: CashflowResponse | null = null; externalSummary: ShoppingListSummary | null = null; + selectedTimelineRange: TimelineRangeKey = '3m'; private categoryChart?: Chart; - private chartPending = false; + private timelineChart?: Chart; + private categoryChartPending = false; + private timelineChartPending = false; + + constructor() { + effect(() => { + if (this.canShowExternalStats()) this.loadExternalSummary(); + else { + this.externalSummary = null; + this.persistCache(); + } + }); + } ngOnInit() { this.restoreCache(); this.loadDashboard(); + this.loadTimeline(); } ngAfterViewChecked() { - if (this.chartPending) { - this.chartPending = false; - this.renderChart(); + if (this.categoryChartPending) { + this.categoryChartPending = false; + this.renderCategoryChart(); + } + + if (this.timelineChartPending) { + this.timelineChartPending = false; + this.renderTimelineChart(); } } ngOnDestroy() { this.categoryChart?.destroy(); + this.timelineChart?.destroy(); + } + + canShowExternalStats() { + return Boolean(this.auth.currentUser()?.integrationsEnabled); } loadDashboard() { const range = getMonthRange(); - this.expensesService.list().subscribe({ next: (response) => { this.recentExpenses = response.items.slice(0, 8); this.persistCache(); } }); - this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({ next: (response) => { this.stats = response; this.chartPending = true; this.persistCache(); } }); - this.statsService.cashflow().subscribe({ next: (response) => { this.cashflow = response; this.persistCache(); } }); - this.shoppingIntegration.summary({ start_date: range.start, end_date: range.end }).subscribe({ next: (response) => { this.externalSummary = response; this.persistCache(); }, error: () => { this.externalSummary = this.externalSummary ?? null; } }); + this.expensesService.list().subscribe({ + next: (response) => { + this.recentExpenses = response.items.slice(0, 8); + this.persistCache(); + } + }); + this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({ + next: (response) => { + this.stats = response; + this.categoryChartPending = true; + this.persistCache(); + } + }); + this.statsService.cashflow().subscribe({ + next: (response) => { + this.cashflow = response; + this.persistCache(); + } + }); + } + + changeTimelineRange(range: TimelineRangeKey) { + if (this.selectedTimelineRange === range) return; + this.selectedTimelineRange = range; + this.loadTimeline(); + } + + loadTimeline() { + const range = getTimelineRange(this.selectedTimelineRange); + this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({ + next: (response) => { + this.timelineStats = response; + this.timelineChartPending = true; + this.persistCache(); + } + }); + } + + private loadExternalSummary() { + const range = getMonthRange(); + this.shoppingIntegration.summary({ start_date: range.start, end_date: range.end }).subscribe({ + next: (response) => { + this.externalSummary = response; + this.persistCache(); + }, + error: () => { + this.externalSummary = this.externalSummary ?? null; + } + }); } externalAmount() { return Number(this.externalSummary?.total ?? this.externalSummary?.amount ?? this.externalSummary?.meta?.total_amount ?? 0); } @@ -180,11 +311,7 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy { }[status] || 'text-bg-secondary'; } - private scheduleChartRender() { - requestAnimationFrame(() => this.renderChart()); - } - - private renderChart() { + private renderCategoryChart() { const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null; if (!canvas || !this.stats?.byCategory?.length) { this.categoryChart?.destroy(); @@ -208,22 +335,69 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy { }); } + private renderTimelineChart() { + const canvas = document.getElementById('dashboardTimelineChart') as HTMLCanvasElement | null; + if (!canvas || !this.timelineStats?.timeline?.length) { + this.timelineChart?.destroy(); + return; + } + + this.timelineChart?.destroy(); + this.timelineChart = new Chart(canvas, { + type: 'line', + data: { + labels: this.timelineStats.timeline.map((item) => item.label), + datasets: [{ + label: this.ui.t('stats.expensesLabel'), + data: this.timelineStats.timeline.map((item) => item.total), + borderColor: '#206bc4', + backgroundColor: '#206bc4', + tension: 0.25, + fill: false + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { color: '#9ca3af' } } } + } + }); + } + private restoreCache() { try { const raw = localStorage.getItem(DASHBOARD_CACHE_KEY); if (!raw) return; - const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null; cashflow?: CashflowResponse | null; externalSummary?: ShoppingListSummary | null }; + const parsed = JSON.parse(raw) as { + recentExpenses?: Expense[]; + stats?: StatsResponse | null; + timelineStats?: StatsResponse | null; + cashflow?: CashflowResponse | null; + externalSummary?: ShoppingListSummary | null; + selectedTimelineRange?: TimelineRangeKey; + }; this.recentExpenses = parsed.recentExpenses ?? []; this.stats = parsed.stats ?? null; + this.timelineStats = parsed.timelineStats ?? null; this.cashflow = parsed.cashflow ?? null; this.externalSummary = parsed.externalSummary ?? null; - this.chartPending = Boolean(this.stats?.byCategory?.length); + this.selectedTimelineRange = parsed.selectedTimelineRange ?? '3m'; + this.categoryChartPending = Boolean(this.stats?.byCategory?.length); + this.timelineChartPending = Boolean(this.timelineStats?.timeline?.length); } catch {} } private persistCache() { try { - localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats, cashflow: this.cashflow, externalSummary: this.externalSummary })); + localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ + recentExpenses: this.recentExpenses, + stats: this.stats, + timelineStats: this.timelineStats, + cashflow: this.cashflow, + externalSummary: this.externalSummary, + selectedTimelineRange: this.selectedTimelineRange + })); } catch {} } } diff --git a/web/src/app/layout/shell.component.ts b/web/src/app/layout/shell.component.ts index 6dcf452..283d4dd 100644 --- a/web/src/app/layout/shell.component.ts +++ b/web/src/app/layout/shell.component.ts @@ -45,11 +45,9 @@ import { UiService } from '../core/services/ui.service';
{{ auth.currentUser()?.fullName }}
{{ auth.currentUser()?.email }}
-
diff --git a/web/src/styles.scss b/web/src/styles.scss index 4f3363f..a51e962 100644 --- a/web/src/styles.scss +++ b/web/src/styles.scss @@ -31,6 +31,15 @@ body { --tblr-card-border-radius: var(--ec-light-radius-md); } +[data-bs-theme="dark"] { + --tblr-body-bg: #06080d; + --ec-shell-bg: #06080d; + --ec-card-shadow: 0 2px 10px rgba(0, 0, 0, 0.22); + --ec-card-border: rgba(255, 255, 255, 0.06); + --ec-navbar-bg: rgba(6, 8, 13, 0.96); + --ec-subnav-bg: rgba(10, 13, 20, 0.96); +} + [data-bs-theme="light"] .card, [data-bs-theme="light"] .pv-card, [data-bs-theme="light"] .login-card, @@ -59,15 +68,6 @@ body { border-radius: var(--ec-light-radius-sm) !important; } -[data-bs-theme="dark"] { - --tblr-body-bg: #06080d; - --ec-shell-bg: #06080d; - --ec-card-shadow: 0 2px 10px rgba(0, 0, 0, 0.22); - --ec-card-border: rgba(255, 255, 255, 0.06); - --ec-navbar-bg: rgba(6, 8, 13, 0.96); - --ec-subnav-bg: rgba(10, 13, 20, 0.96); -} - .page { min-height: 100vh; } @@ -127,10 +127,25 @@ body { color: #0f172a; } -.pv-navbar-user { +.pv-navbar-user, +.min-w-0 { min-width: 0; } +.ec-footer { + background: var(--ec-navbar-bg); + border-color: var(--ec-card-border) !important; +} + +.ec-footer-shell { + min-height: 4rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 0; +} + .card, .pv-card, .login-card, @@ -179,6 +194,73 @@ body { height: 280px; } +.ec-accent-card { + position: relative; +} + +.ec-accent-card::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 4px; +} + +.ec-accent-card-primary::before { + background: linear-gradient(90deg, var(--tblr-primary), rgba(var(--tblr-primary-rgb), 0.35)); +} + +.ec-accent-card-success::before { + background: linear-gradient(90deg, var(--tblr-success), rgba(var(--tblr-success-rgb), 0.35)); +} + +.ec-accent-card-info::before { + background: linear-gradient(90deg, var(--tblr-info), rgba(var(--tblr-info-rgb), 0.35)); +} + +.ec-stat-tile, +.ec-mini-kpi { + border: 1px solid var(--ec-card-border); + border-radius: 1rem; + padding: 1rem; + background: rgba(var(--tblr-bg-surface-rgb), 0.7); +} + +.ec-stat-tile-primary { + background: rgba(var(--tblr-primary-rgb), 0.08); +} + +.ec-stat-tile-success { + background: rgba(var(--tblr-success-rgb), 0.08); +} + +.ec-stat-label { + color: var(--tblr-secondary); + font-size: 0.875rem; + margin-bottom: 0.35rem; +} + +.ec-stat-value { + font-size: clamp(1.4rem, 2vw, 2rem); + font-weight: 700; + letter-spacing: -0.04em; +} + +.ec-mini-kpi { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.ec-mini-kpi span { + color: var(--tblr-secondary); + font-size: 0.875rem; +} + +.ec-mini-kpi strong { + font-size: 1.15rem; +} + .login-page-shell { min-height: 100vh; background: @@ -237,9 +319,13 @@ body { } .toast-host { + pointer-events: none; z-index: 1080; - width: min(420px, 100vw); - top: 1rem !important; +} + +.toast-host .toast, +.toast-host .btn-close { + pointer-events: auto; } .toast-host .toast { @@ -247,24 +333,6 @@ body { background-clip: padding-box; } -.ec-segmented-control { - gap: 0.35rem; -} - -.ec-segmented-control .nav-link { - min-width: 3rem; - justify-content: center; - font-weight: 600; -} - -.ec-segmented-control .nav-link.active { - box-shadow: inset 0 0 0 1px rgba(var(--tblr-primary-rgb), 0.1); -} - -.login-toolbar-controls { - min-width: min(100%, 17rem); -} - .ec-toast { width: min(420px, calc(100vw - 1.5rem)); border-top: 3px solid transparent; @@ -299,6 +367,44 @@ body { display: inline-block; } +.ec-segmented-control { + gap: 0.35rem; +} + +.ec-segmented-control .nav-link { + min-width: 3rem; + justify-content: center; + font-weight: 600; +} + +.ec-segmented-control .nav-link.active { + box-shadow: inset 0 0 0 1px rgba(var(--tblr-primary-rgb), 0.1); +} + +.login-toolbar-controls { + min-width: min(100%, 17rem); +} + +.badge { + color: #fff !important; +} + +.badge * { + color: inherit !important; +} + +.ec-picker-badge { + color: #fff !important; + background: rgba(var(--tblr-secondary-rgb), 0.16); + border: 1px solid rgba(var(--tblr-secondary-rgb), 0.18); +} + +.ec-picker-dot { + min-width: 0.75rem; + min-height: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.28); +} + .ec-proof-preview { width: 100%; max-height: 75vh; @@ -331,6 +437,61 @@ body { z-index: 1085; } +.ec-nav-caption { + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--tblr-secondary); + margin-bottom: 0.5rem; +} + +.ec-modal-close { + min-width: 2.75rem; + min-height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + flex: 0 0 auto; + cursor: pointer; + position: relative; + z-index: 2; +} + +.ec-proof-modal-body { + min-height: min(70vh, 720px); + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem; +} + +.ec-proof-frame { + display: block; + width: 100%; + min-height: 78vh; + border: 0; + border-radius: 0.75rem; + background: rgba(15, 23, 42, 0.04); +} + +.ec-scroll-list { + max-height: 36rem; + overflow: auto; +} + +.ec-toolbar-toggle { + border: 1px solid var(--ec-card-border); + border-radius: 999px; + padding: 0.125rem; + background: rgba(var(--tblr-bg-surface-rgb), 0.75); + box-shadow: var(--ec-card-shadow); +} + +.ec-toolbar-toggle .btn { + border-radius: 999px; +} + [data-bs-theme="dark"] .btn-primary, [data-bs-theme="dark"] .btn-success, [data-bs-theme="dark"] .btn-danger, @@ -344,6 +505,28 @@ body { filter: none; } +[data-bs-theme="dark"] .badge { + color: #fff !important; +} + +[data-bs-theme="dark"] .ec-picker-badge { + color: #fff !important; + background: rgba(248, 250, 252, 0.12); + border-color: rgba(248, 250, 252, 0.18); +} + +[data-bs-theme="dark"] .badge.bg-dark-lt { + color: #f8fafc; + background: rgba(248, 250, 252, 0.14) !important; +} + +[data-bs-theme="dark"] .badge.bg-secondary { + color: #f8fafc; +} + +[data-bs-theme="dark"] .bg-body-tertiary { + background: rgba(255, 255, 255, 0.03) !important; +} @media (max-width: 992px) { .pv-subnav-shell, @@ -377,6 +560,24 @@ body { display: none; } + .pv-subnav { + display: none; + } + + .pv-subnav.is-open { + display: block; + } + + .pv-subnav .nav-link { + width: 100%; + border-radius: 0.85rem; + } + + .pv-subnav-tabs { + flex-direction: column; + width: 100%; + } + .ec-page-header-actions { justify-content: stretch; } @@ -391,199 +592,9 @@ body { right: 0; width: 100%; } -} - - -.badge { - color: #fff !important; -} - -.badge * { - color: inherit !important; -} - -.ec-picker-badge { - color: #fff !important; - background: rgba(var(--tblr-secondary-rgb), 0.16); - border: 1px solid rgba(var(--tblr-secondary-rgb), 0.18); -} - -.ec-picker-dot { - min-width: 0.75rem; - min-height: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.28); -} - -[data-bs-theme="dark"] .badge { - color: #fff !important; -} - -[data-bs-theme="dark"] .ec-picker-badge { - color: #fff !important; - background: rgba(248, 250, 252, 0.12); - border-color: rgba(248, 250, 252, 0.18); -} - - -[data-bs-theme="dark"] .badge.bg-dark-lt { - color: #f8fafc; - background: rgba(248, 250, 252, 0.14) !important; -} - -[data-bs-theme="dark"] .badge.bg-secondary { - color: #f8fafc; -} - -.min-w-0 { min-width: 0; } -.ec-nav-caption { - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--tblr-secondary); - margin-bottom: 0.5rem; -} -.ec-footer { - background: var(--ec-navbar-bg); - border-color: var(--ec-card-border) !important; -} -.ec-footer-shell { - min-height: 4rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.9rem 0; -} -.ec-accent-card { position: relative; } -.ec-accent-card::before { - content: ""; - position: absolute; - inset: 0 0 auto 0; - height: 4px; -} -.ec-accent-card-primary::before { background: linear-gradient(90deg, var(--tblr-primary), rgba(var(--tblr-primary-rgb), 0.35)); } -.ec-accent-card-success::before { background: linear-gradient(90deg, var(--tblr-success), rgba(var(--tblr-success-rgb), 0.35)); } -.ec-accent-card-info::before { background: linear-gradient(90deg, var(--tblr-info), rgba(var(--tblr-info-rgb), 0.35)); } -.ec-stat-tile, .ec-mini-kpi { - border: 1px solid var(--ec-card-border); - border-radius: 1rem; - padding: 1rem; - background: rgba(var(--tblr-bg-surface-rgb), 0.7); -} -.ec-stat-tile-primary { background: rgba(var(--tblr-primary-rgb), 0.08); } -.ec-stat-tile-success { background: rgba(var(--tblr-success-rgb), 0.08); } -.ec-stat-label { - color: var(--tblr-secondary); - font-size: 0.875rem; - margin-bottom: 0.35rem; -} -.ec-stat-value { - font-size: clamp(1.4rem, 2vw, 2rem); - font-weight: 700; - letter-spacing: -0.04em; -} -.ec-mini-kpi { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -} -.ec-mini-kpi span { - color: var(--tblr-secondary); - font-size: 0.875rem; -} -.ec-mini-kpi strong { - font-size: 1.15rem; -} -[data-bs-theme="dark"] .bg-body-tertiary { - background: rgba(255,255,255,0.03) !important; -} -@media (max-width: 768px) { - .pv-subnav { display: none; } - .pv-subnav.is-open { display: block; } - .pv-subnav .nav-link { width: 100%; border-radius: 0.85rem; } - .pv-subnav-tabs { flex-direction: column; width: 100%; } - .ec-footer-shell { flex-direction: column; align-items: flex-start; } -} - - -.ec-modal-close { - min-width: 2.75rem; - min-height: 2.75rem; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - flex: 0 0 auto; -} - -.ec-proof-modal-body { - min-height: min(70vh, 720px); - display: flex; - align-items: center; - justify-content: center; - padding: 0.75rem; -} - -.ec-proof-frame { - width: 100%; - min-height: min(72vh, 760px); - border: 0; - border-radius: 0.75rem; - background: #fff; -} - -.ec-scroll-list { - max-height: 36rem; - overflow: auto; -} - -.ec-toolbar-toggle { - border: 1px solid var(--ec-card-border); - border-radius: 999px; - padding: 0.125rem; - background: rgba(var(--tblr-bg-surface-rgb), 0.75); - box-shadow: var(--ec-card-shadow); -} - -.ec-toolbar-toggle .btn { - border-radius: 999px; -} - - -.pv-logout-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 2.375rem; - cursor: pointer; - position: relative; - z-index: 3; -} - -.pv-logout-btn__content { - display: inline-flex; - align-items: center; - gap: 0.5rem; - pointer-events: none; -} - -.ec-proof-modal-body { - padding: 0.75rem; -} - -.ec-proof-frame { - display: block; - width: 100%; - min-height: 78vh; - border: 0; - border-radius: 0.75rem; - background: rgba(15, 23, 42, 0.04); -} - -.ec-modal-close { - cursor: pointer; - position: relative; - z-index: 2; + .ec-footer-shell { + flex-direction: column; + align-items: flex-start; + } }