fixy
This commit is contained in:
@@ -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<string, string> | null) => String(customFields?.externalSource ?? '').trim().toLowerCase() === 'shopping-list-api';
|
||||
const approvalNeedsProof = (nextStatus: ExpenseStatus, customFields?: Record<string, string> | 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.' });
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,14 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'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<UiLanguage, Record<string, string>> = {
|
||||
'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.',
|
||||
|
||||
@@ -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<TimelineRangeKey, number>)[range] ?? 0;
|
||||
return {
|
||||
start: formatLocalDate(startOfMonth(addMonths(today, -monthsBack))),
|
||||
end: formatLocalDate(today)
|
||||
};
|
||||
};
|
||||
@@ -47,8 +71,36 @@ const getMonthRange = () => {
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.budgetUsage') }}</div><div class="display-6">{{ cashflow?.budgetUsagePercent || 0 }}%</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
|
||||
@if (canShowExternalStats()) {
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
|
||||
}
|
||||
|
||||
<div class="col-12 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<h3 class="card-title mb-0">{{ ui.t('dashboard.trend') }}</h3>
|
||||
<div class="btn-list">
|
||||
@for (option of timelineRangeOptions; track option.value) {
|
||||
<button class="btn btn-sm"
|
||||
type="button"
|
||||
[class.btn-primary]="selectedTimelineRange === option.value"
|
||||
[class.btn-outline-secondary]="selectedTimelineRange !== option.value"
|
||||
(click)="changeTimelineRange(option.value)">
|
||||
{{ ui.t(option.labelKey) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (timelineStats?.timeline?.length) {
|
||||
<div class="ec-chart-wrap"><canvas id="dashboardTimelineChart"></canvas></div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noTrendData') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,9 @@ import { UiService } from '../core/services/ui.service';
|
||||
</button>
|
||||
</div>
|
||||
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div>
|
||||
<button class="btn btn-primary btn-sm px-3 flex-shrink-0 pv-logout-btn" type="button" (click)="logout()" [attr.aria-label]="ui.t('action.logout')" [attr.title]="ui.t('action.logout')">
|
||||
<span class="pv-logout-btn__content">
|
||||
<button class="btn btn-primary btn-sm" type="button" (click)="logout()" [attr.aria-label]="ui.t('action.logout')" [attr.title]="ui.t('action.logout')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M13 12v.01"/><path d="M3 21h18"/><path d="M5 21v-14a2 2 0 0 1 2 -2h5m4 0h1a2 2 0 0 1 2 2v14"/><path d="M14 7l3 -3l3 3"/></svg>
|
||||
<span>{{ ui.t('action.logout') }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user