Files
CRM_Montzar/apps/web/src/views/TahesabSettingsView.vue
T

854 lines
26 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateTahesabConnectionRequest,
TahesabConnectionDto,
UpdateTahesabConnectionRequest,
} from '@mento/shared';
import { brand } from '@mento/shared';
import {
UiBadge,
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiSelect,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<TahesabConnectionDto[]>([]);
const editingId = ref<string | null>(null);
const form = reactive({
label: '',
baseUrl: '',
dbName: 'DB',
token: '',
isDefault: false,
allowInsecureTls: false,
enabled: true,
});
const sync = reactive({
connectionId: '',
phone: '',
name: '',
moshtariCode: '' as string | number,
fromCode: '',
toCode: '',
});
const syncBusy = ref(false);
type SyncResult = {
moshtariCode: number | null;
name: string;
tel: string;
alreadyLinked?: boolean;
personId?: string | null;
personName?: string | null;
};
const syncResults = ref<SyncResult[]>([]);
function pendingImportCodes() {
return syncResults.value
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
.map((r) => r.moshtariCode as number);
}
function apiErr(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) return fallback;
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ') || fallback;
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
function resetForm() {
editingId.value = null;
form.label = '';
form.baseUrl = '';
form.dbName = 'DB';
form.token = '';
form.isDefault = items.value.length === 0;
form.allowInsecureTls = false;
form.enabled = true;
}
function looksLikePrivateHttps(url: string) {
return /^https:\/\/(\d{1,3}\.){3}\d{1,3}(:\d+)?/i.test(url.trim());
}
function isTlsError(text: string | null | undefined) {
if (!text) return false;
return /self-signed|certificate|UNABLE_TO_VERIFY|CERT_|گواهی/i.test(text);
}
function onBaseUrlBlur() {
if (!editingId.value && looksLikePrivateHttps(form.baseUrl)) {
form.allowInsecureTls = true;
}
}
function startEdit(row: TahesabConnectionDto) {
editingId.value = row.id;
form.label = row.label;
form.baseUrl = row.baseUrl;
form.dbName = row.dbName;
form.token = '';
form.isDefault = row.isDefault;
form.allowInsecureTls = row.allowInsecureTls;
form.enabled = row.enabled;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<TahesabConnectionDto[]>(
'/integrations/tahesab/connections',
);
items.value = data;
if (!editingId.value) form.isDefault = data.length === 0;
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری اتصالات ناموفق بود.',
)
: 'بارگذاری اتصالات ناموفق بود.';
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
if (editingId.value) {
const body: UpdateTahesabConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
dbName: form.dbName.trim(),
isDefault: form.isDefault,
allowInsecureTls: form.allowInsecureTls,
enabled: form.enabled,
};
if (form.token.trim()) body.token = form.token.trim();
await api.patch(
`/integrations/tahesab/connections/${editingId.value}`,
body,
);
ok.value = 'اتصال به‌روزرسانی شد.';
} else {
if (!form.token.trim()) {
error.value = 'توکن الزامی است.';
return;
}
const body: CreateTahesabConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
dbName: form.dbName.trim(),
token: form.token.trim(),
isDefault: form.isDefault,
allowInsecureTls: form.allowInsecureTls,
enabled: form.enabled,
};
await api.post('/integrations/tahesab/connections', body);
ok.value = 'اتصال افزوده شد.';
}
resetForm();
await load();
} 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;
}
}
async function testConn(id: string) {
error.value = '';
ok.value = '';
try {
const { data } = await api.post<{ ok: boolean; error?: string }>(
`/integrations/tahesab/connections/${id}/test`,
);
if (data.ok) ok.value = 'اتصال سالم است.';
else {
error.value = data.error ?? 'تست اتصال ناموفق بود.';
if (isTlsError(data.error)) {
error.value +=
' → دکمه «فعال‌سازی SSL خودامضا» را بزنید یا در ویرایش اتصال تیک آن را بگذارید.';
}
}
await load();
} catch (e) {
error.value = apiErr(e, 'تست ناموفق');
}
}
async function enableInsecureTls(id: string) {
error.value = '';
ok.value = '';
try {
await api.patch(`/integrations/tahesab/connections/${id}`, {
allowInsecureTls: true,
});
ok.value = 'پذیرش گواهی خودامضا فعال شد. در حال تست…';
await load();
await testConn(id);
} catch (e) {
error.value = apiErr(e, 'فعال‌سازی ناموفق بود');
}
}
async function removeConn(id: string) {
if (!confirm('این اتصال حذف شود؟ لینک مشتریان مرتبط هم پاک می‌شود.')) return;
error.value = '';
try {
await api.delete(`/integrations/tahesab/connections/${id}`);
ok.value = 'اتصال حذف شد.';
if (editingId.value === id) resetForm();
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق')
: 'حذف ناموفق';
}
}
async function searchTahesab(mode: 'range' | 'filter') {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا یک اتصال انتخاب کنید.';
return;
}
syncBusy.value = true;
try {
const body: Record<string, unknown> = { connectionId: sync.connectionId };
if (mode === 'range') {
const fromRaw = String(sync.fromCode ?? '').trim();
const toRaw = String(sync.toCode ?? '').trim();
if (!fromRaw && !toRaw) {
body.fromCode = 1;
body.toCode = 5000;
} else {
const fromCode = fromRaw ? Number(fromRaw) : 1;
const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) {
error.value = 'بازه کد مشتری نامعتبر است.';
return;
}
body.fromCode = fromCode;
body.toCode = toCode;
}
} else if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
body.moshtariCode = Number(sync.moshtariCode);
} else if (sync.phone.trim()) {
body.phone = sync.phone.trim();
} else if (sync.name.trim()) {
body.name = sync.name.trim();
} else {
error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.';
return;
}
const { data } = await api.post<{ results: SyncResult[] }>(
'/integrations/tahesab/sync/search',
body,
{ timeout: 60_000 },
);
syncResults.value = data.results ?? [];
if (!syncResults.value.length) {
ok.value = 'نتیجه‌ای یافت نشد.';
} else {
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
ok.value = `${syncResults.value.length} مشتری پیدا شد` + (linked ? ` (${linked} قبلاً لینک شده)` : '');
}
} catch (e) {
error.value = apiErr(e, 'جستجو ناموفق');
} finally {
syncBusy.value = false;
}
}
async function importOne(moshtariCode: number) {
error.value = '';
ok.value = '';
try {
syncBusy.value = true;
const { data } = await api.post<{ personId: string }>(
'/integrations/tahesab/sync/import',
{ connectionId: sync.connectionId, moshtariCode },
);
ok.value = `وارد شد — در مخاطبین با شناسه ${data.personId}`;
const row = syncResults.value.find((r) => r.moshtariCode === moshtariCode);
if (row) {
row.alreadyLinked = true;
row.personId = data.personId;
}
} catch (e) {
error.value = apiErr(e, 'وارد کردن ناموفق');
} finally {
syncBusy.value = false;
}
}
async function importBulk(mode: 'results' | 'range') {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
const body: Record<string, unknown> = { connectionId: sync.connectionId };
if (mode === 'results') {
const codes = pendingImportCodes();
if (!codes.length) {
error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه لینک شده‌اند یا لیست خالی است).';
return;
}
if (
!confirm(
`${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در ${brand.nameFa} ساخته و لینک می‌شوند. ادامه؟`,
)
) {
return;
}
body.moshtariCodes = codes;
} else {
const fromRaw = String(sync.fromCode ?? '').trim();
const toRaw = String(sync.toCode ?? '').trim();
if (!fromRaw && !toRaw) {
if (
!confirm(
`کل مشتریان از کد ۱ تا ۵۰۰۰ از ته‌حساب به‌عنوان شخص وارد ${brand.nameFa} می‌شوند. ادامه؟`,
)
) {
return;
}
body.fromCode = 1;
body.toCode = 5000;
} else {
const fromCode = fromRaw ? Number(fromRaw) : 1;
const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) {
error.value = 'بازه نامعتبر است.';
return;
}
if (toCode - fromCode > 2000) {
error.value = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است.';
return;
}
if (
!confirm(
`مشتریان با کد ${fromCode} تا ${toCode} از ته‌حساب وارد ${brand.nameFa} می‌شوند. ادامه؟`,
)
) {
return;
}
body.fromCode = fromCode;
body.toCode = toCode;
}
}
try {
syncBusy.value = true;
const { data } = await api.post<{
imported: number;
skipped: number;
failed: number;
}>('/integrations/tahesab/sync/import-bulk', body);
ok.value = `وارد شد: ${data.imported} · قبلاً بود: ${data.skipped} · ناموفق: ${data.failed}`;
await searchTahesab(mode === 'range' ? 'range' : 'filter');
} catch (e) {
error.value = apiErr(e, 'وارد کردن دسته‌ای ناموفق');
} finally {
syncBusy.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="crm-page tahesab-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>
<div
v-if="items.some((r) => !r.allowInsecureTls && isTlsError(r.lastError))"
class="crm-alert crm-alert-warning"
>
اتصال شما به‌خاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال،
<strong>فعال‌سازی SSL خودامضا</strong> را بزنید؛ بعد دوباره «نمایش بازه» را امتحان کنید.
</div>
<UiCard v-if="!loading && items.length" title="اتصالات تعریف‌شده" class="section-card">
<div class="connections-list">
<div v-for="row in items" :key="row.id" class="connection-row">
<div class="connection-info">
<div class="connection-title-row">
<strong class="connection-label">{{ row.label }}</strong>
<UiBadge v-if="row.isDefault" variant="primary">پیش‌فرض</UiBadge>
<UiBadge :variant="row.enabled ? 'success' : 'neutral'">
{{ row.enabled ? 'فعال' : 'غیرفعال' }}
</UiBadge>
<UiBadge v-if="row.allowInsecureTls" variant="warning">SSL خودامضا</UiBadge>
</div>
<p class="connection-url font-mono">{{ row.baseUrl }}</p>
<p class="connection-meta">
<span>بانک: {{ row.dbName }}</span>
<span class="meta-dot">·</span>
<span>توکن: {{ row.tokenMasked }}</span>
</p>
<div
v-if="row.lastHealthAt"
class="health-status"
:class="row.lastHealthOk ? 'ok' : 'bad'"
>
<span class="health-indicator"></span>
<span>آخرین وضعیت: {{ row.lastHealthOk ? 'موفق' : 'ناموفق' }}</span>
<span v-if="row.lastError"> — {{ row.lastError }}</span>
</div>
</div>
<div class="connection-actions">
<UiButton
v-if="!row.allowInsecureTls && isTlsError(row.lastError)"
variant="secondary"
size="sm"
@click="enableInsecureTls(row.id)"
>
فعال‌سازی SSL خودامضا
</UiButton>
<UiButton variant="ghost" size="sm" @click="startEdit(row)">
<template #icon>
<UiIcon name="pencil" />
</template>
ویرایش
</UiButton>
<UiButton variant="ghost" size="sm" @click="testConn(row.id)">
<template #icon>
<UiIcon name="refresh" />
</template>
تست اتصال
</UiButton>
<UiButton variant="danger" size="sm" @click="removeConn(row.id)">
<template #icon>
<UiIcon name="trash" />
</template>
حذف
</UiButton>
</div>
</div>
</div>
</UiCard>
<UiCard :title="editingId ? 'ویرایش اتصال ته‌حساب' : 'افزودن اتصال جدید ته‌حساب'" class="section-card">
<form class="crm-form" @submit.prevent="save">
<div class="crm-form-grid-2">
<UiField label="عنوان اتصال" required>
<UiInput v-model="form.label" required maxlength="120" placeholder="مثلاً شعبه مرکزی، دیتابیس مالی" />
</UiField>
<UiField label="آدرس وب‌سرویس (Base URL)" required hint="آدرس سرور وب‌سرویس ته‌حساب">
<UiInput
v-model="form.baseUrl"
required
dir="ltr"
placeholder="https://IP:8081 یا https://api.tahesab.ir"
@blur="onBaseUrlBlur"
/>
</UiField>
<UiField label="نام دیتابیس (DBName)" required>
<UiInput v-model="form.dbName" required dir="ltr" placeholder="DB یا TahesabDB" />
</UiField>
<UiField label="توکن دسترسی (Bearer Token)" :required="!editingId" :hint="editingId ? 'خالی بگذارید تا توکن فعلی تغییر نکند.' : 'توکن احراز هویت وب‌سرویس'">
<UiInput
v-model="form.token"
:required="!editingId"
type="password"
dir="ltr"
autocomplete="new-password"
placeholder="توکن امنیتی..."
/>
</UiField>
</div>
<div class="checkbox-group">
<label class="custom-check">
<input v-model="form.isDefault" type="checkbox" />
<span>اتصال پیش‌فرض سازمان</span>
</label>
<label class="custom-check">
<input v-model="form.enabled" type="checkbox" />
<span>اتصال فعال باشد</span>
</label>
<label class="custom-check highlight">
<input v-model="form.allowInsecureTls" type="checkbox" />
<span>پذیرش گواهی SSL خودامضا (برای IP محلی یا پورت داخلی بدون گواهی رسمی)</span>
</label>
</div>
<div class="form-actions-row">
<UiButton type="submit" :loading="saving">
{{ editingId ? 'به‌روزرسانی اتصال' : 'افزودن اتصال' }}
</UiButton>
<UiButton v-if="editingId" variant="ghost" type="button" @click="resetForm">
انصراف
</UiButton>
</div>
</form>
</UiCard>
<UiCard title="وارد کردن و همگام‌سازی مشتریان از ته‌حساب" class="section-card">
<p class="sync-desc">
با استفاده از این بخش می‌توانید اشخاص و طرف‌حساب‌های ته‌حساب را به عنوان مخاطب وارد نرم‌افزار کرده و سوابق مالی آن‌ها را پیوند دهید.
</p>
<div class="crm-form">
<UiField label="اتصال مورد نظر جهت همگام‌سازی" required>
<UiSelect v-model="sync.connectionId">
<option disabled value="">انتخاب اتصال</option>
<option v-for="row in items" :key="row.id" :value="row.id">
{{ row.label }} ({{ row.dbName }})
</option>
</UiSelect>
</UiField>
<div class="sync-sub-section">
<h4 class="sync-subhead">۱) وارد کردن دسته‌ای با بازه کد مشتری</h4>
<p class="sync-sub-desc">
در صورت خالی بودن «از» و «تا»، مشتریان کد ۱ تا ۵۰۰۰ بررسی می‌شوند (حداکثر ۲۰۰۰ کد در هر درخواست).
</p>
<div class="crm-form-grid-2">
<UiInput v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (مثلاً ۱)" />
<UiInput v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (مثلاً ۵۰۰)" />
</div>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('range')"
>
نمایش بازه
</UiButton>
<UiButton
:disabled="!sync.connectionId || syncBusy"
@click="importBulk('range')"
>
وارد کردن کل بازه
</UiButton>
</div>
</div>
<div class="sync-sub-section">
<h4 class="sync-subhead">۲) جستجوی تکی یا فیلتر مشخصات</h4>
<div class="crm-form-grid-3">
<UiInput v-model="sync.phone" dir="ltr" placeholder="موبایل یا تلفن..." />
<UiInput v-model="sync.name" placeholder="نام یا عنوان مشتری..." />
<UiInput v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری ته‌حساب..." />
</div>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('filter')"
>
جستجو در ته‌حساب
</UiButton>
<UiButton
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
@click="importBulk('results')"
>
وارد کردن نتایج جدید ({{ pendingImportCodes().length }})
</UiButton>
</div>
</div>
<div v-if="syncResults.length" class="sync-results-box">
<h4 class="sync-results-title">نتایج جستجو ({{ syncResults.length }})</h4>
<div class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th>کد مشتری</th>
<th>نام و نام خانوادگی</th>
<th>تلفن</th>
<th>وضعیت لینک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="r in syncResults" :key="String(r.moshtariCode)">
<td class="font-mono">{{ r.moshtariCode }}</td>
<td class="font-bold">{{ r.name || 'بدون نام' }}</td>
<td class="font-mono" dir="ltr">{{ r.tel }}</td>
<td>
<UiBadge v-if="r.alreadyLinked" variant="success">
لینک شده{{ r.personName ? ` (${r.personName})` : '' }}
</UiBadge>
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
</td>
<td>
<UiButton
v-if="!r.alreadyLinked"
size="sm"
:disabled="!r.moshtariCode || syncBusy"
@click="importOne(r.moshtariCode!)"
>
وارد کردن
</UiButton>
<RouterLink
v-else-if="r.personId"
:to="{ name: 'person-detail', params: { id: r.personId } }"
>
<UiButton variant="ghost" size="sm">
مشاهده شخص
</UiButton>
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</UiCard>
</section>
</template>
<style scoped>
.tahesab-page {
display: flex;
flex-direction: column;
gap: var(--sp-6);
width: 100%;
max-width: 100%;
}
.section-card {
padding: var(--sp-6);
}
.connections-list {
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.connection-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: border-color var(--transition-fast);
}
.connection-row:hover {
border-color: var(--color-brand-300, #5eead4);
}
.connection-info {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.connection-title-row {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.connection-label {
font-size: var(--fs-md);
font-weight: 700;
color: var(--color-text-primary);
}
.connection-url {
font-size: var(--fs-xs);
color: var(--color-text-secondary);
direction: ltr;
text-align: right;
margin: 0;
}
.connection-meta {
font-size: var(--fs-xs);
color: var(--color-text-muted);
margin: 0;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.meta-dot {
color: var(--color-text-muted);
}
.health-status {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
margin-top: var(--sp-1);
}
.health-indicator {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
}
.health-status.ok {
color: var(--color-success-text, #15803d);
}
.health-status.ok .health-indicator {
background: var(--color-success-text, #15803d);
}
.health-status.bad {
color: var(--color-danger-text, #b91c1c);
}
.health-status.bad .health-indicator {
background: var(--color-danger-text, #b91c1c);
}
.connection-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin: var(--sp-2) 0;
}
.custom-check {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.custom-check.highlight {
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-md);
background: var(--color-warning-bg, #fffbeb);
border: 1px solid var(--color-warning-border, #fef3c7);
color: var(--color-warning-text, #92400e);
font-weight: 600;
}
.form-actions-row {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.sync-desc {
font-size: var(--fs-sm);
color: var(--color-text-secondary);
margin-top: 0;
}
.sync-sub-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);
}
.sync-subhead {
font-size: var(--fs-sm);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.sync-sub-desc {
font-size: var(--fs-xs);
color: var(--color-text-muted);
margin: 0;
}
.sync-buttons {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.sync-results-box {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-top: var(--sp-2);
}
.sync-results-title {
font-size: var(--fs-sm);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.font-mono {
font-family: var(--font-mono, monospace);
}
.font-bold {
font-weight: 600;
}
@media (max-width: 640px) {
.connection-row {
flex-direction: column;
align-items: stretch;
}
.connection-actions {
justify-content: flex-end;
}
}
</style>