Files
boursecu_admin/src/components/TahesabDocSyncProgress.vue
T
mohmdsdgandCursor ba12569b97 feat(tahesab): minimizable modal for bulk document sync progress
One-click start on customer-sync and documents tabs; poll every 10s, restore the modal after refresh if the server job is still running, and show failed counts with stop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 20:48:14 +03:30

659 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<Transition name="cip-fade">
<div
v-if="store.visible && !store.minimized"
class="cip-overlay"
@click.self="store.active ? store.minimize() : null"
>
<div class="cip-modal" :class="store.finished ? `cip-modal--${outcomeInfo.tone}` : null">
<div class="cip-modal__head">
<div class="cip-modal__title">
<Icon :icon="headIcon" :size="18" />
<span>{{ headTitle }}</span>
</div>
<div class="cip-modal__head-actions">
<button
v-if="store.active"
class="cip-icon-btn"
title="کوچک کردن"
@click="store.minimize()"
>
<Icon icon="vuesax-linear:minus" :size="18" />
</button>
<button v-else class="cip-icon-btn" title="بستن" @click="store.dismiss()">
<Icon icon="vuesax-linear:close-circle" :size="18" />
</button>
</div>
</div>
<div v-if="store.active" class="cip-modal__body">
<div class="cip-counter">
<span class="cip-counter__done">{{ store.state.processed }}</span>
<span class="cip-counter__sep">/</span>
<span class="cip-counter__total">{{ store.state.total }}</span>
</div>
<div class="cip-counter__label">در حال ارسال سندها به ته‌حساب…</div>
<div class="cip-track">
<div class="cip-track__fill" :style="{ width: store.percent + '%' }" />
</div>
<div class="cip-percent">{{ store.percent }}٪</div>
<div class="cip-current">
<a-spin size="small" />
<span class="cip-current__name">{{ store.state.current || '...' }}</span>
</div>
<div class="cip-stats">
<div class="cip-stat cip-stat--ok">
<Icon icon="vuesax-linear:tick-circle" :size="14" />
{{ store.state.success }} موفق
</div>
<div v-if="store.state.failed" class="cip-stat cip-stat--fail">
<Icon icon="vuesax-linear:close-circle" :size="14" />
{{ store.state.failed }} ناموفق
</div>
</div>
</div>
<div v-else class="cip-modal__body cip-report">
<div class="cip-report__hero" :class="`cip-report__hero--${outcomeInfo.tone}`">
<Icon :icon="outcomeInfo.icon" :size="34" />
</div>
<div class="cip-report__title">{{ outcomeInfo.text }}</div>
<div class="cip-report__subtitle">
از {{ store.state.total }} سند، {{ store.state.processed }} مورد پردازش شد
</div>
<div class="cip-report__cards">
<div class="cip-rcard cip-rcard--ok">
<Icon icon="vuesax-linear:tick-circle" :size="20" />
<div class="cip-rcard__num">{{ store.state.success }}</div>
<div class="cip-rcard__label">ارسال شد</div>
</div>
<div class="cip-rcard" :class="store.state.failed ? 'cip-rcard--fail' : 'cip-rcard--muted'">
<Icon icon="vuesax-linear:close-circle" :size="20" />
<div class="cip-rcard__num">{{ store.state.failed }}</div>
<div class="cip-rcard__label">به خطا خورد</div>
</div>
</div>
<div v-if="store.state.errors.length" class="cip-report__errors">
<div class="cip-report__errors-head">
<Icon icon="vuesax-linear:close-circle" :size="14" />
جزئیات خطا ({{ store.state.errors.length }})
</div>
<div class="cip-report__errors-list">
<div v-for="(e, i) in store.state.errors" :key="i" class="cip-erow">
<span class="cip-erow__name">{{ e.label }}</span>
<span class="cip-erow__msg">{{ e.error }}</span>
</div>
</div>
</div>
</div>
<div class="cip-modal__foot">
<a-button v-if="store.active" danger size="small" @click="store.stop()">توقف</a-button>
<a-button v-if="store.active" size="small" @click="store.minimize()">کوچک کردن</a-button>
<a-button v-if="store.finished" type="primary" size="small" @click="store.dismiss()">
بستن گزارش
</a-button>
</div>
</div>
</div>
</Transition>
<Transition name="cip-slide">
<button
v-if="store.visible && store.minimized"
class="cip-mini tds-mini"
:class="store.finished ? `cip-mini--${outcomeInfo.tone}` : null"
@click="store.expand()"
>
<span class="cip-mini__ring">
<svg viewBox="0 0 36 36" class="cip-mini__svg">
<circle class="cip-mini__bg" cx="18" cy="18" r="15.5" />
<circle class="cip-mini__fg" cx="18" cy="18" r="15.5" :style="ringStyle" />
</svg>
<span class="cip-mini__pct">
<template v-if="store.finished">
<Icon :icon="outcomeInfo.icon" :size="16" />
</template>
<template v-else>{{ store.percent }}</template>
</span>
</span>
<span class="cip-mini__text">
<span class="cip-mini__title">
<template v-if="store.finished">{{ outcomeInfo.text }}</template>
<template v-else>ارسال سند به ته‌حساب</template>
</span>
<span class="cip-mini__count">
<template v-if="store.finished">
{{ store.state.success }} موفق · {{ store.state.failed }} خطا
</template>
<template v-else>{{ store.state.processed }} از {{ store.state.total }}</template>
</span>
</span>
<span class="cip-mini__bar">
<span class="cip-mini__bar-fill" :style="{ width: store.percent + '%' }" />
</span>
</button>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
const store = useTahesabDocSyncStore()
const OUTCOME_MAP = {
success: { icon: 'vuesax-linear:tick-circle', text: 'ارسال سندها تمام شد', tone: 'ok' as const },
error: { icon: 'vuesax-linear:close-circle', text: 'ارسال سندها ناموفق بود', tone: 'fail' as const },
partial: { icon: 'vuesax-linear:danger', text: 'بعضی سندها به خطا خورد', tone: 'warn' as const },
canceled: { icon: 'vuesax-linear:info-circle', text: 'ارسال متوقف شد', tone: 'warn' as const },
running: { icon: 'vuesax-linear:document-upload', text: 'ارسال سند به ته‌حساب', tone: 'ok' as const },
}
const outcomeInfo = computed(() => OUTCOME_MAP[store.outcome])
const headIcon = computed(() =>
store.finished ? outcomeInfo.value.icon : 'vuesax-linear:document-upload',
)
const headTitle = computed(() =>
store.finished ? 'گزارش ارسال سندها' : 'ارسال سندها به ته‌حساب',
)
const RING_CIRC = 2 * Math.PI * 15.5
const ringStyle = computed(() => ({
strokeDasharray: `${RING_CIRC}`,
strokeDashoffset: `${RING_CIRC * (1 - store.percent / 100)}`,
}))
onMounted(() => {
void store.hydrate()
})
</script>
<style scoped lang="less">
/* ─── مدال ─── */
.cip-overlay {
position: fixed;
inset: 0;
z-index: 2200;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(2px);
}
.cip-modal {
width: 100%;
max-width: 420px;
border-radius: 16px;
background: var(--admin-bg-surface);
border: 1px solid var(--admin-border-strong);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
overflow: hidden;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #0ea5e9 100%);
color: #fff;
}
&--ok .cip-modal__head {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
}
&--warn .cip-modal__head {
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
}
&--fail .cip-modal__head {
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
}
&__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
}
&__head-actions {
display: flex;
gap: 4px;
}
&__body {
padding: 20px 18px 8px;
display: flex;
flex-direction: column;
align-items: center;
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px 16px;
}
}
.cip-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.18);
color: #fff;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.32);
}
}
.cip-counter {
display: flex;
align-items: baseline;
gap: 4px;
font-weight: 800;
line-height: 1;
&__done {
font-size: 38px;
color: #7c3aed;
}
&__sep {
font-size: 24px;
color: var(--admin-text-muted);
}
&__total {
font-size: 24px;
color: var(--admin-text-secondary);
}
}
.cip-counter__label {
margin-top: 6px;
font-size: 13px;
color: var(--admin-text-secondary);
}
.cip-track {
width: 100%;
height: 9px;
margin-top: 16px;
border-radius: 99px;
background: var(--admin-bg-muted);
overflow: hidden;
&__fill {
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, #7c3aed, #0ea5e9);
transition: width 0.35s ease;
}
}
.cip-percent {
align-self: flex-end;
margin-top: 6px;
font-size: 12px;
font-weight: 700;
color: var(--admin-text-secondary);
}
.cip-current {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
max-width: 100%;
&__name {
font-size: 12px;
color: var(--admin-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.cip-stats {
display: flex;
gap: 10px;
margin-top: 14px;
}
.cip-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 99px;
&--ok {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
&--fail {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
}
/* ─── گزارش پایانی ─── */
.cip-report {
padding-top: 22px;
&__hero {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
&--ok {
background: rgba(16, 185, 129, 0.14);
color: #059669;
}
&--warn {
background: rgba(245, 158, 11, 0.16);
color: #d97706;
}
&--fail {
background: rgba(239, 68, 68, 0.13);
color: #dc2626;
}
}
&__title {
margin-top: 12px;
font-size: 16px;
font-weight: 800;
color: var(--admin-text);
text-align: center;
}
&__subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--admin-text-secondary);
text-align: center;
}
&__cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
width: 100%;
margin-top: 18px;
}
&__errors {
width: 100%;
margin-top: 16px;
}
&__errors-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #dc2626;
margin-bottom: 6px;
}
&__errors-list {
max-height: 160px;
overflow-y: auto;
border-radius: 10px;
border: 1px solid var(--admin-border);
background: var(--admin-bg-muted);
}
}
.cip-rcard {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 14px 10px;
border-radius: 12px;
border: 1px solid transparent;
&__num {
font-size: 26px;
font-weight: 800;
line-height: 1.1;
}
&__label {
font-size: 11px;
font-weight: 600;
}
&--ok {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.25);
color: #059669;
}
&--fail {
background: rgba(239, 68, 68, 0.09);
border-color: rgba(239, 68, 68, 0.22);
color: #dc2626;
}
&--muted {
background: var(--admin-bg-muted);
border-color: var(--admin-border);
color: var(--admin-text-muted);
}
}
.cip-erow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
font-size: 12px;
border-bottom: 1px solid var(--admin-border);
&:last-child {
border-bottom: none;
}
&__name {
font-weight: 700;
color: var(--admin-text);
white-space: nowrap;
}
&__msg {
color: #dc2626;
text-align: left;
flex: 1;
word-break: break-word;
}
}
/* ─── کوچک‌شده ─── */
.cip-mini {
position: fixed;
top: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 2100;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px 8px 10px;
border: 1px solid var(--admin-border-strong);
border-radius: 99px;
background: var(--admin-bg-surface);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
@media (max-width: 767px) {
top: 58px;
max-width: calc(100vw - 24px);
}
&:hover {
transform: translateX(-50%) translateY(1px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
&__ring {
position: relative;
width: 34px;
height: 34px;
flex-shrink: 0;
}
&__svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
&__bg {
fill: none;
stroke: var(--admin-bg-muted);
stroke-width: 3.5;
}
&__fg {
fill: none;
stroke: #7c3aed;
stroke-width: 3.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.35s ease;
}
&__pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
color: #7c3aed;
}
&__text {
display: flex;
flex-direction: column;
line-height: 1.3;
min-width: 0;
}
&__title {
font-size: 12px;
font-weight: 700;
color: var(--admin-text);
white-space: nowrap;
}
&__count {
font-size: 11px;
color: var(--admin-text-secondary);
white-space: nowrap;
}
&__bar {
position: absolute;
left: 14px;
right: 14px;
bottom: 3px;
height: 2px;
border-radius: 99px;
background: var(--admin-bg-muted);
overflow: hidden;
}
&__bar-fill {
display: block;
height: 100%;
border-radius: 99px;
background: #7c3aed;
transition: width 0.35s ease;
}
/* رنگ بر اساس نتیجه */
&--ok {
.cip-mini__fg,
.cip-mini__bar-fill {
stroke: #10b981;
background: #10b981;
}
.cip-mini__pct {
color: #059669;
}
}
&--warn {
.cip-mini__fg,
.cip-mini__bar-fill {
stroke: #f59e0b;
background: #f59e0b;
}
.cip-mini__pct {
color: #d97706;
}
}
&--fail {
.cip-mini__fg,
.cip-mini__bar-fill {
stroke: #ef4444;
background: #ef4444;
}
.cip-mini__pct {
color: #dc2626;
}
}
}
.tds-mini.cip-mini {
top: 112px;
@media (max-width: 767px) {
top: 106px;
}
}
/* ─── انیمیشن‌ها ─── */
.cip-fade-enter-active,
.cip-fade-leave-active {
transition: opacity 0.3s ease;
}
.cip-fade-enter-from,
.cip-fade-leave-to {
opacity: 0;
}
.cip-slide-enter-active,
.cip-slide-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.cip-slide-enter-from,
.cip-slide-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-12px);
}
</style>