Files
CRM_Montzar/apps/web/src/views/CurrencySettingsView.vue
T
s.esmaeili 1ae87c97b7 feat(ui): standardize design system, modals, sidebar, and form inputs
- Implement unified design system tokens, 10px radius, and IRANSans typography
- Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout
- Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions
- Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components
- Add draggable bottom-left QuickCreate floating action button
2026-09-12 01:33:07 +03:30

309 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { CURRENCY_OPTIONS, formatMoney } from '@/lib/money';
import MoneyInput from '@/components/MoneyInput.vue';
import {
UiButton,
UiCard,
UiField,
UiPageHeader,
UiSelect,
} from '@/components/ui';
import type { OrganizationDto, UpdateOrgCurrenciesRequest } from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const org = ref<OrganizationDto | null>(null);
const baseCurrency = ref('IRR');
const enabled = ref<string[]>(['IRR', 'USD', 'EUR']);
const rates = ref<Record<string, string>>({});
const rateCurrencies = computed(() =>
enabled.value.filter((c) => c !== baseCurrency.value),
);
function toggleCurrency(code: string) {
if (code === baseCurrency.value) return;
if (enabled.value.includes(code)) {
enabled.value = enabled.value.filter((c) => c !== code);
} else {
enabled.value = [...enabled.value, code];
if (!rates.value[code]) rates.value[code] = '';
}
}
function onBaseChange() {
if (!enabled.value.includes(baseCurrency.value)) {
enabled.value = [baseCurrency.value, ...enabled.value];
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<OrganizationDto>('/organizations/current');
org.value = data;
baseCurrency.value = data.baseCurrency || 'IRR';
enabled.value = data.enabledCurrencies?.length
? [...data.enabledCurrencies]
: ['IRR'];
const next: Record<string, string> = {};
for (const r of data.exchangeRates ?? []) {
next[r.currency] = String(r.rateToBase);
}
rates.value = next;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری تنظیمات ارز ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const body: UpdateOrgCurrenciesRequest = {
baseCurrency: baseCurrency.value,
enabledCurrencies: enabled.value,
rates: rateCurrencies.value
.filter((c) => rates.value[c] && Number(rates.value[c]) > 0)
.map((c) => ({
currency: c,
rateToBase: Number(rates.value[c]),
})),
};
const { data } = await api.patch<OrganizationDto>(
'/organizations/current/currencies',
body,
);
org.value = data;
ok.value = 'تنظیمات ارز با موفقیت ذخیره شد.';
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ذخیره ناموفق بود');
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="crm-page currency-page" dir="rtl">
<UiPageHeader
title="ارزها و نرخ تبدیل"
description="ارز پایه برای تجمیع مبالغ و پیش‌بینی‌ها استفاده می‌شود. نرخ یعنی چند واحد ارز پایه برابر با ۱ واحد ارز خارجی است."
/>
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
<UiCard v-if="!loading" class="section-card">
<form class="crm-form" @submit.prevent="save">
<UiField label="ارز پایه سیستم" hint="همه فاکتورها، پیش‌بینی‌ها و آمار کل بر پایه این واحد ارزی محاسبه خواهند شد.">
<UiSelect v-model="baseCurrency" @change="onBaseChange">
<option
v-for="c in CURRENCY_OPTIONS"
:key="c.code"
:value="c.code"
>
{{ c.labelFa }} ({{ c.code }})
</option>
</UiSelect>
</UiField>
<UiField label="ارزهای فعال در فاکتورها و معاملات">
<div class="currency-chips">
<label
v-for="c in CURRENCY_OPTIONS"
:key="c.code"
class="currency-chip"
:class="{ active: enabled.includes(c.code), locked: c.code === baseCurrency }"
>
<input
type="checkbox"
:checked="enabled.includes(c.code)"
:disabled="c.code === baseCurrency"
@change="toggleCurrency(c.code)"
/>
<span class="chip-title">{{ c.labelFa }}</span>
<span class="chip-code font-mono">{{ c.code }}</span>
</label>
</div>
</UiField>
<div v-if="rateCurrencies.length" class="rates-section">
<h3 class="rates-title">نرخ برابری نسبت به {{ baseCurrency }}</h3>
<div class="rates-list">
<div v-for="code in rateCurrencies" :key="code" class="rate-row">
<span class="rate-unit">۱ {{ code }} =</span>
<div class="rate-input-wrap">
<MoneyInput
v-model="rates[code]"
:decimals="6"
:placeholder="`نرخ برابری به ${baseCurrency}`"
/>
</div>
<span class="rate-target">{{ baseCurrency }}</span>
</div>
</div>
<p v-if="rates.USD" class="example-note">
نمونه محاسبه: ۱ دلار آمریکا ≈ <strong>{{ formatMoney(Number(rates.USD) || 0, baseCurrency) }}</strong>
</p>
</div>
<div class="form-submit-row">
<UiButton type="submit" :loading="saving">
ذخیره تنظیمات ارز
</UiButton>
</div>
</form>
</UiCard>
</section>
</template>
<style scoped>
.currency-page {
display: flex;
flex-direction: column;
gap: var(--sp-6);
width: 100%;
max-width: 100%;
}
.section-card {
padding: var(--sp-6);
}
.currency-chips {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.currency-chip {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: var(--fs-sm);
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
}
.currency-chip:hover {
border-color: var(--color-brand-400);
}
.currency-chip.active {
border-color: var(--color-brand-500);
background: var(--color-brand-50, #f0fdfa);
color: var(--color-brand-800, #115e59);
}
.currency-chip.locked {
opacity: 0.8;
cursor: default;
}
.chip-title {
font-weight: 600;
}
.chip-code {
font-size: var(--fs-xs);
color: var(--color-text-muted);
}
.font-mono {
font-family: var(--font-mono, monospace);
}
.rates-section {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-4);
background: var(--color-surface-soft);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
}
.rates-title {
font-size: var(--fs-sm);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.rates-list {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.rate-row {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.rate-unit {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--color-text-primary);
min-width: 4.5rem;
}
.rate-input-wrap {
flex: 1;
max-width: 14rem;
}
.rate-target {
font-size: var(--fs-sm);
color: var(--color-text-secondary);
font-family: var(--font-mono, monospace);
}
.example-note {
font-size: var(--fs-xs);
color: var(--color-text-secondary);
margin: 0;
}
.example-note strong {
color: var(--color-brand-700, #0f766e);
}
.form-submit-row {
display: flex;
justify-content: flex-end;
margin-top: var(--sp-2);
}
</style>