gift card export, new category, login theme fix

This commit is contained in:
2026-09-20 17:25:25 +03:30
parent 7ff90b05d0
commit 8004dd52b3
6 changed files with 359 additions and 13 deletions
+3
View File
@@ -10,6 +10,9 @@ export type CategoryListParams = {
export const getCategories = async (params?: CategoryListParams) =>
await useAxios.get<Category[]>('/admin/categories', { params })
export const createCategory = async (payload: CategoryInput) =>
await useAxios.post<Category>('/admin/categories', payload)
export const updateCategory = async (id: number, payload: Partial<CategoryInput>) =>
await useAxios.put<Category>(`/admin/categories/${id}`, payload)
+2 -4
View File
@@ -31,13 +31,11 @@ function enterTest() {
.login-page__toggle {
position: absolute;
top: 16px;
left: 16px;
inset-inline-start: 16px;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 4px 10px 4px 6px;
border-radius: 9999px;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
@@ -11,7 +11,33 @@
@cancel="close"
@close="close"
>
<div v-if="isMobile" class="mb-3 text-base font-700">لیست کدهای کارت هدیه</div>
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="m-0 text-13px text-[var(--admin-text-secondary)]">
{{ codes.length }} کد — دانلود به صورت اکسل یا PDF
</p>
</div>
<a-dropdown
:trigger="['click']"
placement="bottomLeft"
:overlay-style="{ zIndex: SELECT_DROPDOWN_Z_INDEX }"
>
<BaseButton
type="default"
size="small"
icon="vuesax-linear:document-download"
:disabled="!codes.length"
>
دانلود
</BaseButton>
<template #overlay>
<a-menu @click="handleDownload">
<a-menu-item key="excel">اکسل (CSV)</a-menu-item>
<a-menu-item key="pdf">PDF</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<a-table
class="hidden md:block"
@@ -63,19 +89,25 @@
</template>
<script setup lang="ts">
import type { Giftcardcode } from '@/model/giftCard'
import type { Giftcard, Giftcardcode } from '@/model/giftCard'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import CustomTag from '@/components/CustomTag.vue'
import { useMobileDetailSheet } from '@/components/mobile/composables/useMobileDetailSheet'
import { isMobile } from '@core/utils/is'
import { metalLabel } from '@/utils/adminMetalLabels'
import { toJalali } from '@core/utils/useDataTime'
import GiftCardCodeMobileDetailSheet from './GiftCardCodeMobileDetailSheet.vue'
import GiftCardCodeMobileListItem from './GiftCardCodeMobileListItem.vue'
import {
downloadGiftCardCodesExcel,
downloadGiftCardCodesPdf,
type GiftCardCodesExportMeta
} from '../utils/exportGiftCardCodes'
import { SELECT_DROPDOWN_Z_INDEX } from '@/constants/overlayZIndex'
const props = defineProps<{
visible: boolean
codes: Giftcardcode[]
giftCard?: Giftcard | null
}>()
const emit = defineEmits<{
@@ -104,4 +136,24 @@ const columns = [
function close() {
visibleProxy.value = false
}
const exportMeta = computed<GiftCardCodesExportMeta>(() => ({
id: props.giftCard?.id,
unit: props.giftCard?.unit,
count: props.giftCard?.count,
customerName: (props.giftCard as Giftcard & { customer?: { name?: string } })?.customer?.name,
customerMobile: (props.giftCard as Giftcard & { customer?: { mobile?: string } })?.customer?.mobile,
createdAt: props.giftCard?.created_at
}))
function handleDownload({ key }: { key: string }) {
if (!props.codes.length) return
if (key === 'excel') {
downloadGiftCardCodesExcel(props.codes, exportMeta.value)
return
}
if (key === 'pdf') {
downloadGiftCardCodesPdf(props.codes, exportMeta.value)
}
}
</script>
+17 -5
View File
@@ -91,7 +91,11 @@
:name="infoParams.name"
:items="infoParams.items"
/>
<GiftCardCodesSheet v-model:visible="giftCardCodesVisible" :codes="selectedGiftCardCodes" />
<GiftCardCodesSheet
v-model:visible="giftCardCodesVisible"
:codes="selectedGiftCardCodes"
:gift-card="selectedGiftCard"
/>
</div>
</template>
@@ -178,6 +182,7 @@ const form = ref<GiftcardInput>({
let infoParams = reactive<Info>({})
const giftCardCodesVisible = ref<boolean>(false)
const selectedGiftCardCodes = ref<Giftcardcode[]>([])
const selectedGiftCard = ref<Giftcard | null>(null)
function info(val: Giftcard) {
// نمایش اطلاعات اصلی در BasicInfoModal
@@ -209,8 +214,10 @@ function info(val: Giftcard) {
value: usePrice(val.total ?? 0)
},
{
key: 'کد یکتا',
value: val.unique_code ?? '-'
key: val.giftcardcodes?.length > 1 ? 'کدهای یکتا' : 'کد یکتا',
value: val.giftcardcodes?.length
? val.giftcardcodes.map((c) => c.code).join('، ')
: (val.unique_code ?? '-')
},
{
key: 'تعداد استفاده شده',
@@ -239,6 +246,7 @@ function info(val: Giftcard) {
function showGiftCardCodes(val: Giftcard) {
// نمایش کدهای کارت هدیه در مودال جداگانه
if (val.giftcardcodes && val.giftcardcodes.length > 0) {
selectedGiftCard.value = val
selectedGiftCardCodes.value = val.giftcardcodes
giftCardCodesVisible.value = true
} else {
@@ -341,11 +349,15 @@ const userInfoVisible = ref<boolean>(false)
//submit gift card
async function ok() {
if (loading.value) return
await giftCardEditorRef.value?.validateFields?.().then(async () => {
loading.value = true
try {
// تنظیم تاریخ انقضا به آخر همان روز (23:59:59) و تبدیل به میلادی
const formData = { ...form.value }
const formData = {
...form.value,
count: Number(form.value.count),
amount: Number(form.value.amount),
}
if (formData.expiry_date) {
// تبدیل به میلادی و تنظیم به آخر روز
formData.expiry_date = dayjs(formData.expiry_date)
@@ -0,0 +1,189 @@
import type { Giftcardcode } from '@/model/giftCard'
import { metalLabel } from '@/utils/adminMetalLabels'
import { toJalali } from '@core/utils/useDataTime'
export interface GiftCardCodesExportMeta {
id?: number
unit?: number
count?: number
customerName?: string
customerMobile?: string
createdAt?: string
}
interface ExportRow {
index: number
code: string
unit: string
amount: string
status: string
expiryDate: string
usedAt: string
assignedName: string
assignedMobile: string
}
const CSV_HEADERS = [
'ردیف',
'کد',
'نوع',
'مقدار',
'وضعیت',
'تاریخ انقضا',
'تاریخ استفاده',
'نام کاربر',
'موبایل کاربر'
]
function statusLabel(sts: number): string {
if (sts === 1) return 'فعال'
if (sts === 2) return 'استفاده شده'
return 'غیرفعال'
}
function unitLabel(unit: number): string {
if (unit === 2) return metalLabel.goldWalletShort()
if (unit === 3) return 'تخفیف'
return 'ریالی'
}
function amountLabel(code: Giftcardcode): string {
const suffix = code.unit === 2 ? 'گرم' : 'ریال'
return `${code.amount ?? '-'} ${suffix}`
}
function buildRows(codes: Giftcardcode[]): ExportRow[] {
return codes.map((code, index) => ({
index: index + 1,
code: code.code,
unit: unitLabel(code.unit),
amount: amountLabel(code),
status: statusLabel(code.sts),
expiryDate: code.expiry_date ? toJalali(code.expiry_date) : '-',
usedAt: code.used_at ? toJalali(code.used_at) : '-',
assignedName: code.assigned_to?.name ?? '-',
assignedMobile: code.assigned_to?.mobile ?? '-'
}))
}
function csvCell(value: unknown): string {
const text = value == null || value === '' ? '-' : String(value)
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`
}
return text
}
function buildFilename(meta: GiftCardCodesExportMeta, extension: string): string {
const idPart = meta.id ? `-${meta.id}` : ''
const datePart = new Date().toISOString().slice(0, 10)
return `giftcard-codes${idPart}-${datePart}.${extension}`
}
function downloadBlob(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
function buildSummary(meta: GiftCardCodesExportMeta): string[] {
const lines: string[] = []
if (meta.id) lines.push(`شناسه کارت هدیه: ${meta.id}`)
if (meta.unit != null) lines.push(`نوع: ${unitLabel(meta.unit)}`)
if (meta.count != null) lines.push(`تعداد کدها: ${meta.count}`)
if (meta.customerName) lines.push(`مشتری: ${meta.customerName}`)
if (meta.customerMobile) lines.push(`موبایل: ${meta.customerMobile}`)
if (meta.createdAt) lines.push(`تاریخ ثبت: ${toJalali(meta.createdAt)}`)
return lines
}
export function downloadGiftCardCodesExcel(codes: Giftcardcode[], meta: GiftCardCodesExportMeta = {}) {
const rows = buildRows(codes)
const lines = [
CSV_HEADERS.map(csvCell).join(','),
...rows.map((row) =>
[
row.index,
row.code,
row.unit,
row.amount,
row.status,
row.expiryDate,
row.usedAt,
row.assignedName,
row.assignedMobile
]
.map(csvCell)
.join(',')
)
]
const content = `\uFEFF${lines.join('\r\n')}`
downloadBlob(content, buildFilename(meta, 'csv'), 'text/csv;charset=utf-8')
}
export function downloadGiftCardCodesPdf(codes: Giftcardcode[], meta: GiftCardCodesExportMeta = {}) {
const rows = buildRows(codes)
const summary = buildSummary(meta)
const title = meta.id ? `لیست کدهای کارت هدیه #${meta.id}` : 'لیست کدهای کارت هدیه'
const tableRows = rows
.map(
(row) => `
<tr>
<td>${row.index}</td>
<td dir="ltr">${row.code}</td>
<td>${row.unit}</td>
<td>${row.amount}</td>
<td>${row.status}</td>
<td>${row.expiryDate}</td>
<td>${row.usedAt}</td>
<td>${row.assignedName}</td>
<td dir="ltr">${row.assignedMobile}</td>
</tr>`
)
.join('')
const html = `<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
body { font-family: Tahoma, Arial, sans-serif; margin: 24px; color: #111; }
h1 { font-size: 18px; margin: 0 0 8px; }
.summary { font-size: 12px; color: #555; margin-bottom: 16px; line-height: 1.8; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: right; }
th { background: #f5f5f5; }
</style>
</head>
<body>
<h1>${title}</h1>
${summary.length ? `<div class="summary">${summary.map((line) => `<div>${line}</div>`).join('')}</div>` : ''}
<table>
<thead>
<tr>
${CSV_HEADERS.map((header) => `<th>${header}</th>`).join('')}
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</body>
</html>`
const printWindow = window.open('', '_blank')
if (!printWindow) return
printWindow.document.open()
printWindow.document.write(html)
printWindow.document.close()
printWindow.focus()
window.setTimeout(() => {
printWindow.print()
printWindow.close()
}, 300)
}
@@ -5,6 +5,9 @@
دسته‌بندی محصولات
<AdminFieldHelp :text="productsCategoriesPageIntro" size="sm" />
</span>
<BaseButton type="primary" size="small" icon="vuesax-linear:add" @click="openAddModal">
افزودن دسته‌بندی جدید
</BaseButton>
<BaseButton :loading="syncing" @click="handleSync">
همگام‌سازی از محصولات
</BaseButton>
@@ -58,6 +61,37 @@
</a-table>
</a-spin>
</div>
<BasicActionSheet
v-model:visible="addVisible"
title="افزودن دسته‌بندی جدید"
modal-width="28rem"
drawer-height="auto"
:loading="adding"
:showButton="true"
:showCancel="true"
:showOk="true"
ok-text="ثبت"
cancel-text="انصراف"
@ok="submitAdd"
@cancel="closeAddModal"
@close="closeAddModal"
>
<p class="m-0 mb-3 text-13px text-[var(--admin-text-secondary)]">
نام دسته را وارد کنید؛ بعد از ثبت در لیست پایین و سلکت افزودن محصول قابل استفاده است.
</p>
<a-form ref="addFormRef" :model="addForm" layout="vertical" autocomplete="off">
<a-form-item label="نام دسته" name="name" :rules="inputRules()">
<BasicInput v-model:value="addForm.name" placeholder="مثلاً سکه، شمش، آبشده" />
</a-form-item>
<a-form-item label="نمایش محصولات در اپ" name="active">
<a-switch v-model:checked="addForm.active" checked-children="فعال" un-checked-children="مخفی" />
</a-form-item>
<a-form-item label="سلکت افزودن محصول" name="show_in_select" class="mb-0">
<a-switch v-model:checked="addForm.show_in_select" />
</a-form-item>
</a-form>
</BasicActionSheet>
</div>
</template>
@@ -66,14 +100,26 @@ import { message } from 'ant-design-vue'
import type { TableColumnType } from 'ant-design-vue'
import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'lodash'
import type { FormInstance } from 'ant-design-vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { getCategories, syncCategoriesFromProducts, updateCategory } from '@/api/category'
import { createCategory, getCategories, syncCategoriesFromProducts, updateCategory } from '@/api/category'
import type { Category } from '@/model/category'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import BasicInput from '@/components/BasicInput.vue'
import { productsCategoriesPageIntro } from '@/utils/adminHelpCopy'
import { inputRules } from '@/utils/inputRules'
const loading = ref(false)
const syncing = ref(false)
const adding = ref(false)
const addVisible = ref(false)
const addFormRef = ref<FormInstance>()
const addForm = ref({
name: '',
active: true,
show_in_select: true,
})
const savingOrder = ref(false)
const items = ref<Category[]>([])
const tableWrapRef = ref<HTMLElement | null>(null)
@@ -400,6 +446,52 @@ async function handleSync() {
await syncList(true)
}
function resetAddForm() {
addForm.value = {
name: '',
active: true,
show_in_select: true,
}
addFormRef.value?.clearValidate?.()
}
function openAddModal() {
resetAddForm()
addVisible.value = true
}
function closeAddModal() {
addVisible.value = false
}
async function submitAdd() {
try {
await addFormRef.value?.validateFields?.()
} catch {
return
}
adding.value = true
try {
const created = await createCategory({
name: addForm.value.name.trim(),
active: addForm.value.active ? 1 : 0,
show_in_select: addForm.value.show_in_select ? 1 : 0,
})
items.value = [...items.value, created].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id
)
message.success('دسته‌بندی با موفقیت ثبت شد')
closeAddModal()
updateTableScrollY()
} catch (e: any) {
console.error(e)
message.error(e?.response?.data?.message || 'خطا در ثبت دسته‌بندی')
} finally {
adding.value = false
}
}
onMounted(() => {
void fetchList()
updateTableScrollY()