feat(tahesab): poll customer sync from any page and reuse the progress modal
Start outbound/inbound on the server; hydrate after refresh so the transfer modal returns without opening accounting. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import CustomerImportProgress from '@/components/CustomerImportProgress.vue'
|
||||
import TahesabDocSyncProgress from '@/components/TahesabDocSyncProgress.vue'
|
||||
import GroupSmsSendProgress from '@/components/GroupSmsSendProgress.vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
|
||||
import { bootstrapAdminChat, useAdminChatService } from '@/utils/adminChat'
|
||||
import { supportChatMenuEnabledSetting } from '@/utils/config'
|
||||
|
||||
@@ -66,7 +67,15 @@ watch(latestIncomingNotification, (payload) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await config.getConfigData()
|
||||
void useTahesabDocSyncStore().hydrate()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => userStore.userInfo?.id,
|
||||
(id) => {
|
||||
if (id) void useTahesabDocSyncStore().hydrate()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface TahesabDocSyncState {
|
||||
running: boolean
|
||||
finished: boolean
|
||||
stopped: boolean
|
||||
stage?: 'docs' | 'customers_out' | 'customers_in'
|
||||
percent: number
|
||||
total: number
|
||||
processed: number
|
||||
@@ -45,6 +46,9 @@ export interface TahesabDocSyncState {
|
||||
failed: number
|
||||
current: string
|
||||
errors: { label?: string; error?: string }[]
|
||||
doc_total?: number
|
||||
doc_success?: number
|
||||
doc_failed?: number
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
}
|
||||
@@ -97,6 +101,16 @@ export const stopTahesabDocSync = async () =>
|
||||
'/admin/tahesab/doc-sync/stop'
|
||||
)
|
||||
|
||||
export const startTahesabCustomerOutbound = async () =>
|
||||
await useAxios.post<{ ok: boolean; message: string; doc_sync: TahesabDocSyncState }>(
|
||||
'/admin/tahesab/customer-sync/outbound/start'
|
||||
)
|
||||
|
||||
export const startTahesabCustomerInbound = async () =>
|
||||
await useAxios.post<{ ok: boolean; message: string; doc_sync: TahesabDocSyncState }>(
|
||||
'/admin/tahesab/customer-sync/inbound/start'
|
||||
)
|
||||
|
||||
export const registerCustomerInTahesab = async (customerId: number) =>
|
||||
await useAxios.post<{ ok: boolean; message: string; moshtari_code?: string | number }>(
|
||||
`/admin/customer/${customerId}/tahesab/register`
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<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-counter__label">{{ runningLabel }}</div>
|
||||
<div class="cip-track">
|
||||
<div class="cip-track__fill" :style="{ width: store.percent + '%' }" />
|
||||
</div>
|
||||
@@ -59,14 +59,12 @@
|
||||
<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__subtitle">{{ reportSubtitle }}</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 class="cip-rcard__label">{{ doneCardLabel }}</div>
|
||||
</div>
|
||||
<div class="cip-rcard" :class="store.state.failed ? 'cip-rcard--fail' : 'cip-rcard--muted'">
|
||||
<Icon icon="vuesax-linear:close-circle" :size="20" />
|
||||
@@ -121,7 +119,7 @@
|
||||
<span class="cip-mini__text">
|
||||
<span class="cip-mini__title">
|
||||
<template v-if="store.finished">{{ outcomeInfo.text }}</template>
|
||||
<template v-else>ارسال سند به تهحساب</template>
|
||||
<template v-else>{{ runningMiniTitle }}</template>
|
||||
</span>
|
||||
<span class="cip-mini__count">
|
||||
<template v-if="store.finished">
|
||||
@@ -145,21 +143,71 @@ 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 stage = computed(() => store.stage)
|
||||
|
||||
function outcomeMap() {
|
||||
if (stage.value === 'customers_in') {
|
||||
return {
|
||||
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:import', text: 'ورود از تهحساب', tone: 'ok' as const },
|
||||
}
|
||||
}
|
||||
if (stage.value === 'customers_out') {
|
||||
return {
|
||||
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:profile-2user', text: 'ثبت در تهحساب', tone: 'ok' as const },
|
||||
}
|
||||
}
|
||||
return {
|
||||
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 outcomeInfo = computed(() => outcomeMap()[store.outcome])
|
||||
const runningIcon = computed(() => {
|
||||
if (stage.value === 'customers_in') return 'vuesax-linear:import'
|
||||
if (stage.value === 'customers_out') return 'vuesax-linear:profile-2user'
|
||||
return 'vuesax-linear:document-upload'
|
||||
})
|
||||
const headIcon = computed(() => (store.finished ? outcomeInfo.value.icon : runningIcon.value))
|
||||
const headTitle = computed(() => {
|
||||
if (store.finished) {
|
||||
if (stage.value === 'customers_in') return 'گزارش ورود مشتریان'
|
||||
if (stage.value === 'customers_out') return 'گزارش ثبت مشتریان'
|
||||
return 'گزارش ارسال سندها'
|
||||
}
|
||||
if (stage.value === 'customers_in') return 'ورود مشتریان از تهحساب'
|
||||
if (stage.value === 'customers_out') return 'ثبت مشتریان در تهحساب'
|
||||
return 'ارسال سندها به تهحساب'
|
||||
})
|
||||
const runningLabel = computed(() => {
|
||||
if (stage.value === 'customers_in') return 'در حال وارد کردن مشتریان از تهحساب…'
|
||||
if (stage.value === 'customers_out') return 'در حال ثبت مشتریان پنل در تهحساب…'
|
||||
return 'در حال ارسال سندها به تهحساب…'
|
||||
})
|
||||
const runningMiniTitle = computed(() => {
|
||||
if (stage.value === 'customers_in') return 'ورود از تهحساب'
|
||||
if (stage.value === 'customers_out') return 'ثبت مشتری در تهحساب'
|
||||
return 'ارسال سند به تهحساب'
|
||||
})
|
||||
const unit = computed(() => (stage.value === 'docs' ? 'سند' : 'مشتری'))
|
||||
const doneCardLabel = computed(() => (stage.value === 'docs' ? 'ارسال شد' : 'ثبت شد'))
|
||||
const reportSubtitle = computed(() => {
|
||||
const docs = store.state.doc_total
|
||||
? `سندها: ${store.state.doc_success} موفق` + (store.state.doc_failed ? `، ${store.state.doc_failed} خطا. ` : '. ')
|
||||
: ''
|
||||
return `${docs}از ${store.state.total} ${unit.value}، ${store.state.processed} مورد پردازش شد`
|
||||
})
|
||||
const RING_CIRC = 2 * Math.PI * 15.5
|
||||
const ringStyle = computed(() => ({
|
||||
strokeDasharray: `${RING_CIRC}`,
|
||||
|
||||
@@ -105,8 +105,8 @@
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="syncingPanel"
|
||||
:disabled="!status?.panel_only"
|
||||
:loading="docSyncStore.starting || (docSyncStore.active && docSyncStore.stage === 'customers_out')"
|
||||
:disabled="!status?.panel_only && !docSyncStore.active"
|
||||
@click="syncAllPanel"
|
||||
>
|
||||
<template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template>
|
||||
@@ -243,11 +243,15 @@
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="importStore.active"
|
||||
:disabled="!importableCount"
|
||||
:loading="docSyncStore.starting || (docSyncStore.active && docSyncStore.stage === 'customers_in')"
|
||||
:disabled="!importableCount && !docSyncStore.active"
|
||||
@click="importAll"
|
||||
>
|
||||
{{ importStore.active ? 'در حال…' : `وارد کردن همه (${importableCount})` }}
|
||||
{{
|
||||
docSyncStore.active && docSyncStore.stage === 'customers_in'
|
||||
? 'در حال…'
|
||||
: `وارد کردن همه (${importableCount})`
|
||||
}}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +368,6 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
|
||||
import { toJalali } from '@core/utils/useDataTime'
|
||||
import {
|
||||
getCustomerSyncStatus,
|
||||
syncPanelToTahesab,
|
||||
importFromTahesab,
|
||||
registerCustomerInTahesab,
|
||||
linkTahesabMatchingPhones,
|
||||
@@ -374,14 +377,12 @@ import {
|
||||
type TahesabOnlyCustomer,
|
||||
type SyncPanelResult,
|
||||
} from '@/api/tahesab'
|
||||
import { useCustomerImportStore } from '@/store/customerImportStore'
|
||||
import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
|
||||
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
|
||||
import TahesabDocSyncBox from '@/pages/menuPages/accounting/components/TahesabDocSyncBox.vue'
|
||||
import { accountingTahesabCustomerSyncIntro } from '@/utils/adminHelpCopy'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
|
||||
const importStore = useCustomerImportStore()
|
||||
const docSyncStore = useTahesabDocSyncStore()
|
||||
|
||||
// ─── state ───
|
||||
@@ -600,18 +601,15 @@ async function registerOnePanel(customer: PanelOnlyCustomer) {
|
||||
}
|
||||
|
||||
async function syncAllPanel() {
|
||||
syncingPanel.value = true
|
||||
panelSyncResult.value = null
|
||||
showPanelErrors.value = false
|
||||
if (docSyncStore.active) {
|
||||
docSyncStore.expand()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await syncPanelToTahesab()
|
||||
panelSyncResult.value = res
|
||||
if (res.success) message.success(`${res.success} مشتری ثبت شد`)
|
||||
if (res.failed) message.warning(`${res.failed} مشتری ثبت نشد`)
|
||||
await load(1)
|
||||
panelDone.clear()
|
||||
} catch { message.error('خطا در ارتباط با سرور') }
|
||||
finally { syncingPanel.value = false }
|
||||
await docSyncStore.startOutbound()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || 'شروع ثبت ناموفق بود')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── تب ۲: عملیات ───
|
||||
@@ -703,11 +701,15 @@ async function importSelected() {
|
||||
async function importAll() {
|
||||
const pending = importableList.value
|
||||
if (!pending.length) return
|
||||
if (importStore.active) {
|
||||
importStore.expand()
|
||||
if (docSyncStore.active) {
|
||||
docSyncStore.expand()
|
||||
return
|
||||
}
|
||||
confirmImport(pending, () => importStore.start(pending, 'tahesab'))
|
||||
confirmImport(pending, () => {
|
||||
void docSyncStore.startInbound().catch((e: any) => {
|
||||
message.error(e?.response?.data?.message || e?.message || 'شروع ورود ناموفق بود')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmLinkPhones() {
|
||||
@@ -771,26 +773,21 @@ async function enrichPageMande() {
|
||||
|
||||
watch([tahesabPage, tahesabSearch], () => { void enrichPageMande() })
|
||||
|
||||
// با پیشرفت عملیات سراسری، ردیفهای وارد شده را همینجا هم تیک بزن
|
||||
watch(() => importStore.doneCodes.length, () => {
|
||||
importStore.doneCodes.forEach(code => tahesabDone.add(code))
|
||||
})
|
||||
|
||||
// پس از پایان کامل، آمار و لیست را تازه کن
|
||||
watch(() => importStore.status, async (s, prev) => {
|
||||
if (prev === 'running' && (s === 'done' || s === 'canceled')) {
|
||||
selectedTahesab.clear()
|
||||
if (status.value?.tahesab_only_count != null) {
|
||||
status.value.tahesab_only_count = Math.max(0, status.value.tahesab_only_count - importStore.success)
|
||||
watch(
|
||||
() => docSyncStore.finished,
|
||||
async (done, wasDone) => {
|
||||
if (!done || wasDone) return
|
||||
await load(1)
|
||||
panelDone.clear()
|
||||
if (docSyncStore.stage === 'customers_in') {
|
||||
tahesabFetched.value = false
|
||||
if (activeTab.value === 'tahesab') await loadTahesab()
|
||||
}
|
||||
await loadPanel(panelPagination.value.page)
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
load(1)
|
||||
// اگر عملیات سراسری در حال اجراست، ردیفهای انجامشده را همگام کن
|
||||
importStore.doneCodes.forEach(code => tahesabDone.add(code))
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="m-0 text-sm font-800 text-gray-800">ارسال سندها به تهحساب</p>
|
||||
<p class="mb-0 mt-1 text-xs leading-relaxed text-gray-500">
|
||||
با یک کلیک همهٔ سفارشها و شارژهای جاافتاده یکییکی و با فاصله به تهحساب میروند.
|
||||
سندهایی که قبلاً رفتهاند دوباره ارسال نمیشوند؛ میتوانید پیشرفت را کوچک کنید.
|
||||
بعد از سندها، مشتریان پنلِ بدون کد هم همانجا ثبت میشوند. پیشرفت را میتوانید کوچک کنید.
|
||||
</p>
|
||||
</div>
|
||||
<a-button
|
||||
|
||||
@@ -3,19 +3,23 @@ import { defineStore } from 'pinia'
|
||||
import {
|
||||
getTahesabPendingSummary,
|
||||
startTahesabDocSync,
|
||||
startTahesabCustomerInbound,
|
||||
startTahesabCustomerOutbound,
|
||||
stopTahesabDocSync,
|
||||
type TahesabDocSyncState,
|
||||
} from '@/api/tahesab'
|
||||
|
||||
export type DocSyncOutcome = 'running' | 'success' | 'error' | 'partial' | 'canceled'
|
||||
|
||||
const POLL_MS = 10_000
|
||||
const POLL_DOCS_MS = 10_000
|
||||
const POLL_CUSTOMERS_MS = 3_000
|
||||
|
||||
function emptyState(): TahesabDocSyncState {
|
||||
return {
|
||||
running: false,
|
||||
finished: false,
|
||||
stopped: false,
|
||||
stage: 'docs',
|
||||
percent: 0,
|
||||
total: 0,
|
||||
processed: 0,
|
||||
@@ -23,6 +27,9 @@ function emptyState(): TahesabDocSyncState {
|
||||
failed: 0,
|
||||
current: '',
|
||||
errors: [],
|
||||
doc_total: 0,
|
||||
doc_success: 0,
|
||||
doc_failed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +44,7 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
|
||||
const active = computed(() => state.value.running)
|
||||
const finished = computed(() => state.value.finished && !state.value.running)
|
||||
const percent = computed(() => state.value.percent)
|
||||
const stage = computed(() => state.value.stage || 'docs')
|
||||
const outcome = computed<DocSyncOutcome>(() => {
|
||||
if (state.value.running) return 'running'
|
||||
if (state.value.stopped) return 'canceled'
|
||||
@@ -45,13 +53,18 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
|
||||
return 'partial'
|
||||
})
|
||||
|
||||
function pollMs() {
|
||||
const s = state.value.stage
|
||||
return s === 'customers_out' || s === 'customers_in' ? POLL_CUSTOMERS_MS : POLL_DOCS_MS
|
||||
}
|
||||
|
||||
function apply(snapshot?: TahesabDocSyncState | null) {
|
||||
if (!snapshot) return
|
||||
const wasVisible = visible.value
|
||||
state.value = { ...emptyState(), ...snapshot }
|
||||
if (snapshot.running) {
|
||||
visible.value = true
|
||||
startPoll()
|
||||
restartPoll()
|
||||
return
|
||||
}
|
||||
stopPoll()
|
||||
@@ -64,7 +77,12 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
|
||||
if (timer) return
|
||||
timer = setInterval(() => {
|
||||
void hydrate()
|
||||
}, POLL_MS)
|
||||
}, pollMs())
|
||||
}
|
||||
|
||||
function restartPoll() {
|
||||
stopPoll()
|
||||
startPoll()
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
@@ -74,12 +92,16 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
async function hydrate(retries = 2) {
|
||||
try {
|
||||
const summary = await getTahesabPendingSummary()
|
||||
apply(summary.doc_sync)
|
||||
} catch {
|
||||
/* ignore */
|
||||
if (retries > 0) {
|
||||
window.setTimeout(() => {
|
||||
void hydrate(retries - 1)
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +110,7 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
|
||||
if (state.value.running) {
|
||||
visible.value = true
|
||||
minimized.value = false
|
||||
startPoll()
|
||||
restartPoll()
|
||||
return
|
||||
}
|
||||
starting.value = true
|
||||
@@ -103,6 +125,46 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function startOutbound() {
|
||||
if (starting.value) return
|
||||
if (state.value.running) {
|
||||
visible.value = true
|
||||
minimized.value = false
|
||||
restartPoll()
|
||||
return
|
||||
}
|
||||
starting.value = true
|
||||
try {
|
||||
const res = await startTahesabCustomerOutbound()
|
||||
message.value = res.message || ''
|
||||
apply(res.doc_sync)
|
||||
visible.value = true
|
||||
minimized.value = false
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startInbound() {
|
||||
if (starting.value) return
|
||||
if (state.value.running) {
|
||||
visible.value = true
|
||||
minimized.value = false
|
||||
restartPoll()
|
||||
return
|
||||
}
|
||||
starting.value = true
|
||||
try {
|
||||
const res = await startTahesabCustomerInbound()
|
||||
message.value = res.message || ''
|
||||
apply(res.doc_sync)
|
||||
visible.value = true
|
||||
minimized.value = false
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
const res = await stopTahesabDocSync()
|
||||
message.value = res.message || ''
|
||||
@@ -135,10 +197,13 @@ export const useTahesabDocSyncStore = defineStore('tahesabDocSync', () => {
|
||||
active,
|
||||
finished,
|
||||
percent,
|
||||
stage,
|
||||
outcome,
|
||||
apply,
|
||||
hydrate,
|
||||
start,
|
||||
startOutbound,
|
||||
startInbound,
|
||||
stop,
|
||||
minimize,
|
||||
expand,
|
||||
|
||||
Reference in New Issue
Block a user