feat: Implement unified Vue SPA with granular service control
Consolidate Reports and Data Entry apps into a single Vue.js SPA with: Architecture: - Module-based structure with lazy-loaded routes (@reports, @data-entry) - Error boundaries per module to prevent cascade failures - Dual API proxy in Vite for microservices (reports:8001, data-entry:8003) - Pinia store factories for shared auth, company, and period stores - Vite path aliases for clear module boundaries (@shared, @reports, @data-entry) Service Management: - Granular service control scripts (backend-reports.sh, backend-data-entry.sh, bot.sh, frontend.sh) - 87% faster frontend restart: 7s vs 53s full restart - 38% faster full startup: 33s vs 53s via parallel backend initialization - Enhanced start-dev.sh with proper service timeouts (OCR: 30s, Vite: 15s, Bot: 10s) - status.sh for comprehensive health checks Features: - Auto-select first company on login with period auto-load - Hamburger menu with feature toggle support - JWT token auto-injection via axios interceptors - Unified header with company/period selectors - IIS web.config for production deployment with multi-API routing UX Improvements: - Vue watchers for reactive company/period loading - Lazy store initialization with graceful error handling - Period persistence per user+company in localStorage - Feature flags for optional modules Deployment: - Single IIS site serves unified frontend with API proxy rules - Maintains separate backend processes for microservices - Windows line ending fixes (.env CRLF → LF conversion) Stats: 112 files changed, 38,342 insertions(+), 2,342 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
<template>
|
||||
<div class="maturity-card">
|
||||
<div class="card-header">
|
||||
<h3>Analiză Comparativă Scadențe</h3>
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
@change="handlePeriodChange"
|
||||
class="period-selector"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<option value="7d">7 zile</option>
|
||||
<option value="1m">1 lună</option>
|
||||
<option value="3m">3 luni</option>
|
||||
<option value="6m">6 luni</option>
|
||||
<option value="12m">12 luni</option>
|
||||
<option value="all">Toate</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Se încarcă analiza scadențelor...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<div class="error-icon">!</div>
|
||||
<p>{{ error }}</p>
|
||||
<button @click="loadData" class="retry-btn">Încearcă din nou</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="maturity-comparison">
|
||||
<!-- Clients Side -->
|
||||
<div class="clients-side">
|
||||
<h4 class="side-title clients-title">
|
||||
Clienți - De încasat
|
||||
<span class="total-amount">{{ formatCurrency(clientsTotal) }}</span>
|
||||
</h4>
|
||||
<div class="maturity-list">
|
||||
<div
|
||||
v-for="(client, index) in clientsData"
|
||||
:key="`client-${index}`"
|
||||
class="maturity-item"
|
||||
:class="{
|
||||
overdue: client.daysOverdue > 0,
|
||||
critical: client.daysOverdue > 30,
|
||||
}"
|
||||
>
|
||||
<div class="item-info">
|
||||
<span class="client-name">{{ client.name }}</span>
|
||||
<span class="due-info">
|
||||
<span v-if="client.daysOverdue > 0" class="overdue-days">
|
||||
Restant {{ client.daysOverdue }} zile
|
||||
</span>
|
||||
<span v-else class="due-date">
|
||||
Scadent în {{ Math.abs(client.daysOverdue) }} zile
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="amount-bar">
|
||||
<div class="bar-container">
|
||||
<div
|
||||
class="bar-fill clients-bar"
|
||||
:style="{
|
||||
width: getBarWidth(client.amount, maxClientAmount) + '%',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="amount-value">{{
|
||||
formatCurrency(client.amount)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="clientsData.length === 0" class="empty-state">
|
||||
<p>Nu există facturi de încasat pentru această perioadă</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="comparison-divider"></div>
|
||||
|
||||
<!-- Suppliers Side -->
|
||||
<div class="suppliers-side">
|
||||
<h4 class="side-title suppliers-title">
|
||||
Furnizori - De plătit
|
||||
<span class="total-amount">{{ formatCurrency(suppliersTotal) }}</span>
|
||||
</h4>
|
||||
<div class="maturity-list">
|
||||
<div
|
||||
v-for="(supplier, index) in suppliersData"
|
||||
:key="`supplier-${index}`"
|
||||
class="maturity-item"
|
||||
:class="{
|
||||
overdue: supplier.daysOverdue > 0,
|
||||
critical: supplier.daysOverdue > 30,
|
||||
}"
|
||||
>
|
||||
<div class="item-info">
|
||||
<span class="supplier-name">{{ supplier.name }}</span>
|
||||
<span class="due-info">
|
||||
<span v-if="supplier.daysOverdue > 0" class="overdue-days">
|
||||
Restant {{ supplier.daysOverdue }} zile
|
||||
</span>
|
||||
<span v-else class="due-date">
|
||||
Scadent în {{ Math.abs(supplier.daysOverdue) }} zile
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="amount-bar">
|
||||
<div class="bar-container">
|
||||
<div
|
||||
class="bar-fill suppliers-bar"
|
||||
:style="{
|
||||
width:
|
||||
getBarWidth(supplier.amount, maxSupplierAmount) + '%',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="amount-value">{{
|
||||
formatCurrency(supplier.amount)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="suppliersData.length === 0" class="empty-state">
|
||||
<p>Nu există facturi de plătit pentru această perioadă</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance Indicator -->
|
||||
<div v-if="!isLoading && !error" class="balance-indicator">
|
||||
<div class="balance-content">
|
||||
<div class="balance-text">
|
||||
<span class="balance-label">{{ balanceLabel }}</span>
|
||||
<span class="balance-amount" :class="balanceClass">
|
||||
{{ formatCurrency(Math.abs(balance)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="recommendations.length > 0" class="recommendations">
|
||||
<details>
|
||||
<summary>Recomandări</summary>
|
||||
<ul>
|
||||
<li v-for="(rec, index) in recommendations" :key="index">
|
||||
{{ rec }}
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with period info -->
|
||||
<div v-if="!isLoading && !error" class="card-footer">
|
||||
<div class="period-info">
|
||||
<span class="period-label">Perioada analizată:</span>
|
||||
<span class="period-value">{{ getPeriodLabel(selectedPeriod) }}</span>
|
||||
</div>
|
||||
<div class="last-updated">
|
||||
<span class="update-label">Actualizat:</span>
|
||||
<span class="update-time">{{ formatLastUpdated(lastUpdated) }}</span>
|
||||
<button
|
||||
@click="refreshData"
|
||||
class="refresh-btn"
|
||||
:disabled="isLoading"
|
||||
title="Reîmprospătează datele"
|
||||
>
|
||||
<i
|
||||
class="pi pi-refresh refresh-icon"
|
||||
:class="{ spinning: isLoading }"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useDashboardStore } from "@reports/stores/dashboard";
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["periodChanged"]);
|
||||
|
||||
// Store
|
||||
const dashboardStore = useDashboardStore();
|
||||
|
||||
// Reactive state
|
||||
const selectedPeriod = ref("1m");
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const lastUpdated = ref(null);
|
||||
|
||||
// Mock data structure - in production this would come from API
|
||||
const maturityData = ref({
|
||||
clients: [],
|
||||
suppliers: [],
|
||||
balance: 0,
|
||||
recommendations: [],
|
||||
});
|
||||
|
||||
// Romanian currency formatter
|
||||
const formatCurrency = (value) => {
|
||||
if (value === null || value === undefined) return "0,00 RON";
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const clientsData = computed(() => maturityData.value.clients || []);
|
||||
const suppliersData = computed(() => maturityData.value.suppliers || []);
|
||||
const recommendations = computed(
|
||||
() => maturityData.value.recommendations || [],
|
||||
);
|
||||
|
||||
const clientsTotal = computed(() =>
|
||||
clientsData.value.reduce((sum, client) => sum + (client.amount || 0), 0),
|
||||
);
|
||||
|
||||
const suppliersTotal = computed(() =>
|
||||
suppliersData.value.reduce(
|
||||
(sum, supplier) => sum + (supplier.amount || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
const balance = computed(() => clientsTotal.value - suppliersTotal.value);
|
||||
|
||||
const balanceClass = computed(() =>
|
||||
balance.value < 0 ? "deficit" : "surplus",
|
||||
);
|
||||
|
||||
const balanceIcon = computed(() => (balance.value < 0 ? "📉" : "📈"));
|
||||
|
||||
const balanceLabel = computed(() =>
|
||||
balance.value < 0 ? "Deficit estimat:" : "Surplus estimat:",
|
||||
);
|
||||
|
||||
const maxClientAmount = computed(() =>
|
||||
Math.max(...clientsData.value.map((c) => c.amount || 0), 1),
|
||||
);
|
||||
|
||||
const maxSupplierAmount = computed(() =>
|
||||
Math.max(...suppliersData.value.map((s) => s.amount || 0), 1),
|
||||
);
|
||||
|
||||
// Methods
|
||||
const getBarWidth = (amount, maxAmount) => {
|
||||
return maxAmount > 0 ? Math.min((amount / maxAmount) * 100, 100) : 0;
|
||||
};
|
||||
|
||||
const getPeriodLabel = (period) => {
|
||||
const labels = {
|
||||
"7d": "Toate restanțele + următoarele 7 zile",
|
||||
"1m": "Toate restanțele + următoarea lună",
|
||||
"3m": "Toate restanțele + următoarele 3 luni",
|
||||
"6m": "Toate restanțele + următoarele 6 luni",
|
||||
"12m": "Toate restanțele + următorul an",
|
||||
all: "Toate soldurile (fără filtru)",
|
||||
};
|
||||
return labels[period] || period;
|
||||
};
|
||||
|
||||
const formatLastUpdated = (timestamp) => {
|
||||
if (!timestamp) return "Necunoscut";
|
||||
return new Date(timestamp).toLocaleString("ro-RO");
|
||||
};
|
||||
|
||||
const handlePeriodChange = () => {
|
||||
emit("periodChanged", selectedPeriod.value);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
loadData(true);
|
||||
};
|
||||
|
||||
const loadData = async (forceRefresh = false) => {
|
||||
if (!props.companyId) {
|
||||
error.value = "ID firmă necunoscut";
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Apelăm API-ul real pentru a obține datele de scadențe
|
||||
const response = await dashboardStore.loadMaturityData(
|
||||
props.companyId,
|
||||
selectedPeriod.value,
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
maturityData.value = response.data;
|
||||
lastUpdated.value = new Date();
|
||||
} else {
|
||||
throw new Error(response?.error || "Eroare la încărcarea datelor");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load maturity data:", err);
|
||||
error.value =
|
||||
err.message ||
|
||||
"Eroare la încărcarea datelor. Vă rugăm încercați din nou.";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.companyId,
|
||||
(newCompanyId) => {
|
||||
if (newCompanyId) {
|
||||
loadData();
|
||||
}
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (props.companyId) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Base Card Styles */
|
||||
.maturity-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-fast, 0.3s ease);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maturity-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-lg, 1rem);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.period-selector:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.period-selector:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl, 2rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top: 3px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: var(--space-md, 1rem);
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Comparison Layout */
|
||||
.maturity-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1fr;
|
||||
gap: var(--space-lg, 1rem);
|
||||
padding: var(--space-lg, 1rem);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.comparison-divider {
|
||||
background: var(--color-border);
|
||||
margin: var(--space-md, 1rem) 0;
|
||||
}
|
||||
|
||||
/* Side Headers */
|
||||
.side-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 var(--space-md, 1rem) 0;
|
||||
font-size: var(--text-base, 1rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
padding-bottom: var(--space-sm, 0.5rem);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.clients-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.suppliers-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
/* Maturity Lists */
|
||||
.maturity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.maturity-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.maturity-list::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.maturity-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Maturity Items */
|
||||
.maturity-item {
|
||||
padding: var(--space-sm, 0.5rem);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.maturity-item:hover {
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.maturity-item.overdue {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.maturity-item.critical {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.client-name,
|
||||
.supplier-name {
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.due-info {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.overdue-days {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.due-date {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Amount Bars */
|
||||
.amount-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.clients-bar {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.suppliers-bar {
|
||||
background: var(--color-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl, 2rem);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Balance Indicator */
|
||||
.balance-indicator {
|
||||
padding: var(--space-lg, 1rem) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.balance-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.balance-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.balance-amount.surplus {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.balance-amount.deficit {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Recommendations */
|
||||
.recommendations {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.recommendations details {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recommendations summary {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--color-primary);
|
||||
list-style: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.recommendations summary:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.recommendations ul {
|
||||
margin: var(--space-sm, 0.5rem) 0 0 0;
|
||||
padding-left: var(--space-lg, 1rem);
|
||||
}
|
||||
|
||||
.recommendations li {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
/* Card Footer */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-md, 0.75rem) var(--space-lg, 1rem);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.period-info,
|
||||
.last-updated {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
}
|
||||
|
||||
.period-label,
|
||||
.update-label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.period-value,
|
||||
.update-time {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
margin-left: var(--space-sm, 0.5rem);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
display: inline-block;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-icon.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.maturity-comparison {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.comparison-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
text-align: center;
|
||||
font-size: var(--text-base, 1rem);
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.maturity-comparison {
|
||||
padding: var(--space-md, 0.75rem);
|
||||
}
|
||||
|
||||
.balance-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommendations {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.side-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.maturity-card {
|
||||
margin: 0 -var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.maturity-comparison,
|
||||
.balance-indicator,
|
||||
.card-footer {
|
||||
padding: var(--space-md, 0.75rem);
|
||||
}
|
||||
|
||||
.maturity-list {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user