293 lines
11 KiB
Vue
293 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
type ContactMessageDto,
|
|
MESSAGE_STATUS_LABELS,
|
|
MESSAGE_STATUSES,
|
|
type MessageStatus,
|
|
type Paginated,
|
|
} from "@imen/shared";
|
|
import { message } from "ant-design-vue";
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import { useRoute } from "vue-router";
|
|
import { api, errorMessage } from "@/api/http";
|
|
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
|
import PageHeader from "@/components/PageHeader.vue";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { faNumber, jalali } from "@/utils/format";
|
|
|
|
const auth = useAuthStore();
|
|
const route = useRoute();
|
|
/** "ALL" or one status */
|
|
const status = ref<MessageStatus | "ALL">((route.query.status as MessageStatus) || "ALL");
|
|
const STATUS_OPTIONS = MESSAGE_STATUSES.map((s) => ({ value: s, label: MESSAGE_STATUS_LABELS[s] }));
|
|
const FILTER_OPTIONS = [{ value: "ALL", label: "همه" }, ...STATUS_OPTIONS];
|
|
const page = ref(1);
|
|
const rows = ref<ContactMessageDto[]>([]);
|
|
const total = ref(0);
|
|
const loading = ref(false);
|
|
const expandedIds = ref<number[]>([]);
|
|
const PAGE_SIZE = 20;
|
|
|
|
const totalPages = computed(() => Math.ceil(total.value / PAGE_SIZE) || 1);
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
try {
|
|
const res = await api<Paginated<ContactMessageDto>>("/admin/messages", {
|
|
query: { status: status.value === "ALL" ? undefined : status.value, page: page.value, pageSize: PAGE_SIZE },
|
|
});
|
|
rows.value = res.items;
|
|
total.value = res.total;
|
|
} catch (e) {
|
|
void message.error(errorMessage(e));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function setStatus(row: ContactMessageDto, s: MessageStatus) {
|
|
try {
|
|
await api(`/admin/messages/${row.id}`, { method: "PATCH", body: { status: s } });
|
|
row.status = s;
|
|
void message.success(`وضعیت پیام به «${MESSAGE_STATUS_LABELS[s]}» تغییر کرد`);
|
|
} catch (e) {
|
|
void message.error(errorMessage(e));
|
|
}
|
|
}
|
|
|
|
function toggleExpand(row: ContactMessageDto) {
|
|
const idx = expandedIds.value.indexOf(row.id);
|
|
if (idx > -1) {
|
|
expandedIds.value.splice(idx, 1);
|
|
} else {
|
|
expandedIds.value.push(row.id);
|
|
if (row.status === "NEW") {
|
|
void setStatus(row, "READ");
|
|
}
|
|
}
|
|
}
|
|
|
|
/** opening a new message marks it as read */
|
|
function onExpand(expanded: boolean, row: ContactMessageDto) {
|
|
if (expanded && row.status === "NEW") void setStatus(row, "READ");
|
|
}
|
|
|
|
async function remove(row: ContactMessageDto) {
|
|
try {
|
|
await api(`/admin/messages/${row.id}`, { method: "DELETE" });
|
|
void message.success("پیام حذف شد");
|
|
await load();
|
|
} catch (e) {
|
|
void message.error(errorMessage(e));
|
|
}
|
|
}
|
|
|
|
function onMobilePageChange(p: number) {
|
|
if (p < 1 || p > totalPages.value) return;
|
|
page.value = p;
|
|
void load();
|
|
}
|
|
|
|
const STATUS_COLORS: Record<MessageStatus, string> = { NEW: "orange", READ: "blue", ARCHIVED: "default" };
|
|
const columns = [
|
|
{ title: "نام", dataIndex: "name" },
|
|
{ title: "سازمان", dataIndex: "org" },
|
|
{ title: "تلفن", key: "phone", width: 150 },
|
|
{ title: "تاریخ", key: "date", width: 170 },
|
|
{ title: "وضعیت", key: "status", width: 140 },
|
|
{ title: "", key: "actions", width: 60 },
|
|
];
|
|
|
|
watch(status, () => {
|
|
page.value = 1;
|
|
void load();
|
|
});
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<template>
|
|
<PageHeader title="پیامهای تماس" :subtitle="`${faNumber(total)} پیام — استعلامها و درخواستهای ثبتشده از سایت`">
|
|
<div class="!hidden lg:!block">
|
|
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
|
|
</div>
|
|
</PageHeader>
|
|
|
|
<!-- Mobile Segmented Filter Row (Mobile Only) -->
|
|
<div class="mobile-filter-bar sticky top-[52px] z-20 bg-white/95 backdrop-blur-md -mx-3 px-3 py-2 border-b border-slate-100 flex items-center gap-1.5 overflow-x-auto no-scrollbar mb-3 lg:!hidden">
|
|
<button
|
|
v-for="opt in FILTER_OPTIONS"
|
|
:key="opt.value"
|
|
type="button"
|
|
class="px-3 py-1.5 rounded-full text-xs font-bold transition shrink-0 select-none cursor-pointer"
|
|
:class="status === opt.value ? 'bg-orange-600 text-white shadow-xs' : 'bg-slate-100 text-slate-600 active:bg-slate-200'"
|
|
@click="status = opt.value as any"
|
|
>
|
|
{{ opt.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Desktop Table View (lg and up) -->
|
|
<a-card class="!hidden lg:!block" :body-style="{ padding: 0 }">
|
|
<a-table
|
|
:columns="columns"
|
|
:data-source="rows"
|
|
:loading="loading"
|
|
row-key="id"
|
|
:scroll="{ x: 760 }"
|
|
:pagination="{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }"
|
|
@expand="onExpand"
|
|
>
|
|
<template #expandedRowRender="{ record }">
|
|
<p class="whitespace-pre-line leading-7">{{ record.message || "— بدون توضیح —" }}</p>
|
|
</template>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'phone'">
|
|
<a :href="`tel:${record.phone}`" dir="ltr" class="hover:text-orange-600 font-mono">{{ record.phone }}</a>
|
|
</template>
|
|
<template v-else-if="column.key === 'date'">{{ jalali(record.createdAt) }}</template>
|
|
<template v-else-if="column.key === 'status'">
|
|
<a-select
|
|
:value="record.status"
|
|
size="small"
|
|
class="w-28"
|
|
:options="STATUS_OPTIONS"
|
|
@change="(v: unknown) => setStatus(record as ContactMessageDto, v as MessageStatus)"
|
|
>
|
|
<template #suffixIcon><a-badge :color="STATUS_COLORS[record.status as MessageStatus]" /></template>
|
|
</a-select>
|
|
</template>
|
|
<template v-else-if="column.key === 'actions'">
|
|
<a-popconfirm
|
|
v-if="auth.isAdmin"
|
|
title="این پیام حذف شود؟"
|
|
ok-text="حذف"
|
|
cancel-text="انصراف"
|
|
@confirm="remove(record as ContactMessageDto)"
|
|
>
|
|
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
|
|
</a-popconfirm>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
</a-card>
|
|
|
|
<!-- Mobile Message Cards View (Mobile Only) -->
|
|
<div class="mobile-messages-view block lg:!hidden pb-20">
|
|
<a-spin :spinning="loading">
|
|
<a-empty v-if="!rows.length && !loading" description="پیامی با این وضعیت موجود نیست" class="py-12" />
|
|
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="record in rows"
|
|
:key="record.id"
|
|
class="bg-white rounded-xl border border-slate-200/80 p-3.5 shadow-2xs transition"
|
|
:class="{ 'border-orange-300 ring-2 ring-orange-100': record.status === 'NEW' }"
|
|
>
|
|
<!-- Top Row: Sender Info + Status Pill -->
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="font-bold text-[14px] text-slate-800">{{ record.name }}</span>
|
|
<span v-if="record.status === 'NEW'" class="size-2 rounded-full bg-orange-500 animate-pulse" />
|
|
</div>
|
|
<div v-if="record.org" class="text-xs text-slate-500 font-medium mt-0.5">
|
|
{{ record.org }}
|
|
</div>
|
|
</div>
|
|
|
|
<a-tag :color="STATUS_COLORS[record.status as MessageStatus]" class="!m-0 !text-[11px] !font-bold">
|
|
{{ MESSAGE_STATUS_LABELS[record.status as keyof typeof MESSAGE_STATUS_LABELS] }}
|
|
</a-tag>
|
|
</div>
|
|
|
|
<!-- Middle Row: Date & Call Button -->
|
|
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
|
|
<span class="text-[11px] text-slate-400 font-mono">
|
|
{{ jalali(record.createdAt) }}
|
|
</span>
|
|
|
|
<!-- Direct Call Button -->
|
|
<a
|
|
:href="'tel:' + record.phone"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200/70 font-bold text-xs active:bg-emerald-100 active:scale-95 transition"
|
|
title="تماس مستقیم با مشتری"
|
|
>
|
|
<Phone :size="13" class="text-emerald-600 shrink-0" />
|
|
<span dir="ltr" class="font-mono">{{ record.phone }}</span>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Message Body Preview & Full Content -->
|
|
<div class="mt-3 cursor-pointer" @click="toggleExpand(record)">
|
|
<div
|
|
v-if="!expandedIds.includes(record.id)"
|
|
class="text-xs text-slate-600 line-clamp-2 leading-relaxed bg-slate-50/70 p-2.5 rounded-lg border border-slate-100"
|
|
>
|
|
{{ record.message || "— بدون توضیح —" }}
|
|
<div class="text-[10px] text-orange-600 font-bold mt-1">مشاهده متن کامل پیام ←</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="text-xs text-slate-700 whitespace-pre-line leading-relaxed bg-orange-50/30 p-3 rounded-lg border border-orange-100"
|
|
>
|
|
{{ record.message || "— بدون توضیح —" }}
|
|
<div class="text-[10px] text-slate-400 font-medium mt-2">بستن متن پیام ↑</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer Actions: Status Changer + Delete -->
|
|
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-3">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-[11px] text-slate-400">وضعیت:</span>
|
|
<a-select
|
|
:value="record.status"
|
|
size="small"
|
|
class="w-28 !text-xs"
|
|
:options="STATUS_OPTIONS"
|
|
@change="(v: unknown) => setStatus(record, v as MessageStatus)"
|
|
/>
|
|
</div>
|
|
|
|
<a-popconfirm
|
|
v-if="auth.isAdmin"
|
|
title="این پیام حذف شود؟"
|
|
ok-text="حذف"
|
|
cancel-text="انصراف"
|
|
@confirm="remove(record)"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 active:bg-rose-200 transition"
|
|
title="حذف پیام"
|
|
>
|
|
<VuesaxIcon name="trash" :size="16" />
|
|
</button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile Pagination -->
|
|
<div v-if="totalPages > 1" class="flex items-center justify-between gap-3 pt-4 px-1">
|
|
<a-button
|
|
:disabled="page <= 1"
|
|
class="!flex-1 !h-10 !rounded-xl !font-medium"
|
|
@click="onMobilePageChange(page - 1)"
|
|
>
|
|
صفحه قبلی
|
|
</a-button>
|
|
<span class="text-xs text-slate-500 font-mono">
|
|
{{ page.toLocaleString("fa-IR") }} / {{ totalPages.toLocaleString("fa-IR") }}
|
|
</span>
|
|
<a-button
|
|
:disabled="page >= totalPages"
|
|
class="!flex-1 !h-10 !rounded-xl !font-medium"
|
|
@click="onMobilePageChange(page + 1)"
|
|
>
|
|
صفحه بعدی
|
|
</a-button>
|
|
</div>
|
|
</a-spin>
|
|
</div>
|
|
</template>
|