gift card export, new category, login theme fix
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user