added online and trigger

This commit is contained in:
2026-07-22 18:12:45 +03:30
parent a759cf7251
commit 090dbbe239
21 changed files with 1463 additions and 286 deletions
@@ -0,0 +1,296 @@
<template>
<div class="co-tab">
<div class="co-grid">
<article class="co-panel" :class="{ 'co-panel--on': active, 'co-panel--off': !active }">
<header class="co-panel__head">
<div>
<h3 class="co-panel__title">دسترسی سفارش شرطی</h3>
<p class="co-panel__key">مجوز این مشتری</p>
</div>
<span class="co-badge" :class="active ? 'co-badge--on' : 'co-badge--off'">
{{ active ? 'مجاز' : 'محدود' }}
</span>
</header>
<div class="co-panel__help">
<div class="co-help-line">
<span class="co-help-pill co-help-pill--on">روشن</span>
<span>منو در اپ دیده می‌شود و مشتری می‌تواند تریگر بسازد.</span>
</div>
<div class="co-help-line">
<span class="co-help-pill co-help-pill--off">خاموش</span>
<span>منو مخفی است و ثبت تریگر برای این مشتری بسته می‌شود.</span>
</div>
</div>
<div class="co-panel__control">
<a-radio-group
:value="active ? 'on' : 'off'"
button-style="solid"
:disabled="activeLoading"
class="co-toggle-group"
@change="onAccessChange"
>
<a-radio-button value="on">مجاز</a-radio-button>
<a-radio-button value="off">محدود</a-radio-button>
</a-radio-group>
<a-spin v-if="activeLoading" size="small" />
</div>
</article>
<article class="co-panel" :class="{ 'co-panel--muted': !active }">
<header class="co-panel__head">
<div>
<h3 class="co-panel__title">سقف تریگرهای فعال</h3>
<p class="co-panel__key">حداکثر pending همزمان</p>
</div>
<span class="co-badge co-badge--neutral">{{ limitLabel }}</span>
</header>
<p class="co-panel__desc">
بعد از اجرا، ناموفق شدن یا انقضای تریگر، یک جای خالی آزاد می‌شود. خالی = بدون سقف.
</p>
<div class="co-panel__control co-panel__control--stack">
<div class="co-limit-row">
<a-input-number
v-model:value="limitDraft"
class="co-limit-input"
:min="1"
:precision="0"
:disabled="!active"
placeholder="خالی = نامحدود"
size="large"
/>
<span class="co-limit-suffix">عدد</span>
</div>
<a-button
type="primary"
block
size="large"
:loading="limitLoading"
:disabled="!active || !limitDirty"
@click="saveLimit"
>
ذخیره سقف
</a-button>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import {
changeCustomerConditionalOrderActive,
updateCustomerConditionalOrderLimit,
} from '@/api/customer'
const props = defineProps<{
customerId: number
conditionalOrderActive?: boolean | number | null
conditionalOrderLimit?: number | null
}>()
const emit = defineEmits<{
updated: [
payload: {
conditional_order_active?: boolean
conditional_order_limit?: number | null
},
]
}>()
const activeLoading = ref(false)
const limitLoading = ref(false)
const limitDraft = ref<number | null>(null)
const active = computed(() => Boolean(props.conditionalOrderActive))
const savedLimit = computed(() => {
const v = props.conditionalOrderLimit
return v != null && Number(v) > 0 ? Number(v) : null
})
const limitLabel = computed(() =>
savedLimit.value != null ? `${savedLimit.value} عدد` : 'نامحدود'
)
const draftNormalized = computed(() => {
if (limitDraft.value == null || !Number.isFinite(Number(limitDraft.value))) return null
return Math.max(1, Math.floor(Number(limitDraft.value)))
})
const limitDirty = computed(() => draftNormalized.value !== savedLimit.value)
watch(
() => props.conditionalOrderLimit,
(v) => {
limitDraft.value = v != null && Number(v) > 0 ? Number(v) : null
},
{ immediate: true }
)
async function onAccessChange(e: { target?: { value?: string } }) {
const wantOn = e?.target?.value === 'on'
if (wantOn === active.value || !props.customerId || activeLoading.value) return
activeLoading.value = true
try {
const res = await changeCustomerConditionalOrderActive(props.customerId)
emit('updated', {
conditional_order_active: Boolean(
res?.conditional_order_active ?? !props.conditionalOrderActive
),
})
message.success('مجوز سفارش شرطی به‌روز شد')
} catch (err) {
console.error(err)
} finally {
activeLoading.value = false
}
}
async function saveLimit() {
if (!props.customerId || limitLoading.value || !active.value) return
limitLoading.value = true
try {
const limit = draftNormalized.value
const res = await updateCustomerConditionalOrderLimit(props.customerId, limit)
emit('updated', {
conditional_order_limit: res?.conditional_order_limit ?? limit,
})
message.success('سقف سفارش شرطی ذخیره شد')
} catch (e) {
console.error(e)
} finally {
limitLoading.value = false
}
}
</script>
<style scoped>
.co-tab {
@apply w-full;
}
.co-grid {
@apply grid w-full grid-cols-1 gap-3 lg:grid-cols-2;
}
.co-panel {
@apply flex min-h-52 flex-col rounded-xl border p-4;
border-color: var(--admin-border, #303030);
background: var(--admin-bg-elevated, var(--admin-surface-muted, #1a1a1a));
transition: border-color 0.2s, box-shadow 0.2s;
}
.co-panel:hover {
border-color: color-mix(in srgb, var(--admin-primary, #eab308) 45%, transparent);
}
.co-panel--on {
box-shadow: inset 0 0 0 1px color-mix(in srgb, #25855a 35%, transparent);
}
.co-panel--off {
box-shadow: inset 0 0 0 1px color-mix(in srgb, #c53030 28%, transparent);
}
.co-panel--muted {
@apply opacity-55;
}
.co-panel__head {
@apply mb-3 flex items-start justify-between gap-3;
}
.co-panel__title {
@apply m-0 text-base font-700;
color: var(--admin-text, inherit);
}
.co-panel__key {
@apply m-0 mt-1 text-xs;
color: var(--admin-text-secondary, #9ca3af);
}
.co-panel__desc {
@apply m-0 mb-4 text-xs leading-5;
color: var(--admin-text-secondary, #9ca3af);
}
.co-panel__help {
@apply mb-4 flex flex-col gap-2;
}
.co-help-line {
@apply flex items-start gap-2 text-xs leading-5;
color: var(--admin-text-secondary, #9ca3af);
}
.co-help-pill {
@apply shrink-0 rounded px-2 py-0.5 text-[11px] font-700;
}
.co-help-pill--on {
color: #25855a;
background: #e3f4e9;
}
.co-help-pill--off {
color: #c53030;
background: #fecaca;
}
.co-badge {
@apply shrink-0 rounded-full px-3 py-1 text-xs font-700;
}
.co-badge--on {
color: #25855a;
background: #e3f4e9;
}
.co-badge--off {
color: #c53030;
background: #fecaca;
}
.co-badge--neutral {
color: var(--admin-text, inherit);
background: var(--admin-bg-muted, rgba(255, 255, 255, 0.06));
}
.co-panel__control {
@apply mt-auto flex items-center gap-2;
}
.co-panel__control--stack {
@apply flex-col items-stretch gap-3;
}
.co-toggle-group {
@apply w-full;
}
.co-toggle-group :deep(.ant-radio-button-wrapper) {
@apply flex-1 text-center;
}
.co-limit-row {
@apply flex items-center gap-2;
}
.co-limit-input {
@apply w-full flex-1;
}
.co-limit-suffix {
@apply shrink-0 text-sm;
color: var(--admin-text-secondary, #9ca3af);
}
</style>
@@ -2,6 +2,7 @@
<MobileDetailSheet v-model:visible="visibleProxy" :item="item" :title="sheetTitle">
<template #default="{ item: row }">
<div class="customer-mobile-sheet">
<CustomerPresenceBadge :last-seen-at="row.last_seen_at" />
<p v-if="row.mobile" class="customer-mobile-sheet__mobile" dir="ltr">{{ row.mobile }}</p>
<div class="customer-mobile-sheet__chips">
@@ -100,7 +101,6 @@
/>
<MobileDetailRow label="کد معرف" :value="row.referral_id ?? '-'" />
<MobileDetailRow label="نام معرف" :value="row.referral?.name ?? '-'" />
<MobileDetailRow label="آخرین بازدید" :value="lastSeen(row)" value-class="text-xs" />
</MobileInfoGrid>
</div>
</template>
@@ -118,13 +118,13 @@ import MobileDetailRow from '@/components/mobile/MobileDetailRow.vue'
import MobileDetailSheet from '@/components/mobile/MobileDetailSheet.vue'
import MobileInfoGrid from '@/components/mobile/MobileInfoGrid.vue'
import CustomerWalletBalancePanel from '@/components/customer/CustomerWalletBalancePanel.vue'
import { toJalali } from '@core/utils/useDataTime'
import {
accountingProviderSetting,
customerDirectSmsEnabledSetting,
initAccountingSettingsFromApi
} from '@/utils/config'
import { displayAccountingCode } from '@/utils/accountingCode'
import CustomerPresenceBadge from '@/pages/menuPages/customers/base/components/CustomerPresenceBadge.vue'
const props = defineProps<{
visible: boolean
@@ -200,10 +200,6 @@ function confirmToggleTrade(row: Customer) {
})
}
function lastSeen(row: Customer) {
return row.last_seen_at ? toJalali(row.last_seen_at) : '-'
}
async function loadChargeTargets() {
try {
const res = await getChargeTargets()
@@ -260,6 +256,16 @@ watch(
color: #b91c1c;
}
.customer-mobile-sheet__chip.is-online {
background: rgba(37, 99, 235, 0.14);
color: #1d4ed8;
}
.customer-mobile-sheet__chip.is-muted {
background: rgba(107, 114, 128, 0.14);
color: #6b7280;
}
.customer-mobile-sheet__section {
width: 100%;
min-width: 0;
@@ -0,0 +1,120 @@
<template>
<div class="presence-badge" :class="`presence-badge--${kind}`" :title="hint">
<span class="presence-badge__glow" aria-hidden="true" />
<span class="presence-badge__dot" aria-hidden="true" />
<span class="presence-badge__label">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getCustomerPresenceKind } from '@/utils/customerPresence'
import { toJalali } from '@core/utils/useDataTime'
const props = defineProps<{
lastSeenAt?: string | Date | null
}>()
const kind = computed(() => getCustomerPresenceKind(props.lastSeenAt))
const label = computed(() => {
if (kind.value === 'online') return 'آنلاین'
if (kind.value === 'never') return 'وارد نشده'
return props.lastSeenAt ? `آخرین بازدید ${toJalali(props.lastSeenAt as string)}` : 'آفلاین'
})
const hint = computed(() => {
if (kind.value === 'online') return 'اتصال زنده (SSE) فعال است'
if (kind.value === 'never') return 'هنوز وارد اپ نشده'
return props.lastSeenAt ? toJalali(props.lastSeenAt as string) : ''
})
</script>
<style scoped>
.presence-badge {
@apply relative inline-flex items-center gap-2 mt-1.5 px-2.5 py-1 rounded-full w-fit max-w-full;
border: 1px solid transparent;
}
.presence-badge__glow {
@apply pointer-events-none absolute inset-0 rounded-full opacity-0;
}
.presence-badge__dot {
@apply relative h-2 w-2 rounded-full shrink-0;
}
.presence-badge__label {
@apply text-xs font-700 truncate;
}
.presence-badge--online {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.35);
}
.presence-badge--online .presence-badge__glow {
opacity: 1;
background: radial-gradient(circle at 30% 50%, rgba(34, 197, 94, 0.25), transparent 70%);
}
.presence-badge--online .presence-badge__dot {
background: #22c55e;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55);
animation: presence-badge-pulse 1.6s ease-out infinite;
}
.presence-badge--online .presence-badge__label {
color: #16a34a;
}
.presence-badge--offline {
background: rgba(156, 163, 175, 0.12);
border-color: rgba(156, 163, 175, 0.35);
}
.presence-badge--offline .presence-badge__dot {
background: #9ca3af;
}
.presence-badge--offline .presence-badge__label {
color: #6b7280;
}
.presence-badge--never {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
.presence-badge--never .presence-badge__dot {
background: #ef4444;
}
.presence-badge--never .presence-badge__label {
color: #dc2626;
}
@keyframes presence-badge-pulse {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55);
}
70% {
box-shadow: 0 0 0 8px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
:global(html.is-dark) .presence-badge--online .presence-badge__label {
color: #4ade80;
}
:global(html.is-dark) .presence-badge--offline .presence-badge__label {
color: #d1d5db;
}
:global(html.is-dark) .presence-badge--never .presence-badge__label {
color: #f87171;
}
</style>
@@ -0,0 +1,90 @@
<template>
<div class="presence-cell" :class="`presence-cell--${kind}`">
<span class="presence-cell__dot" aria-hidden="true" />
<span class="presence-cell__label">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getCustomerPresenceKind } from '@/utils/customerPresence'
import { toJalali } from '@core/utils/useDataTime'
const props = defineProps<{
lastSeenAt?: string | Date | null
}>()
const kind = computed(() => getCustomerPresenceKind(props.lastSeenAt))
const label = computed(() => {
if (kind.value === 'online') return 'آنلاین'
if (kind.value === 'never') return 'وارد نشده'
return props.lastSeenAt ? toJalali(props.lastSeenAt as string) : '—'
})
</script>
<style scoped>
.presence-cell {
@apply flex flex-col items-center justify-center gap-1 py-1 min-w-[5.5rem];
}
.presence-cell__dot {
@apply relative inline-block h-2.5 w-2.5 rounded-full shrink-0;
}
.presence-cell__label {
@apply text-xs font-600 text-center leading-snug;
max-width: 7.5rem;
}
.presence-cell--online .presence-cell__dot {
background: #22c55e;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55);
animation: presence-pulse 1.6s ease-out infinite;
}
.presence-cell--online .presence-cell__label {
color: #16a34a;
}
.presence-cell--offline .presence-cell__dot {
background: #9ca3af;
}
.presence-cell--offline .presence-cell__label {
color: #9ca3af;
font-size: 0.7rem;
}
.presence-cell--never .presence-cell__dot {
background: #ef4444;
}
.presence-cell--never .presence-cell__label {
color: #ef4444;
}
@keyframes presence-pulse {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55);
}
70% {
box-shadow: 0 0 0 8px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
:global(html.is-dark) .presence-cell--online .presence-cell__label {
color: #4ade80;
}
:global(html.is-dark) .presence-cell--offline .presence-cell__label {
color: #d1d5db;
}
:global(html.is-dark) .presence-cell--never .presence-cell__label {
color: #f87171;
}
</style>