Files
imen_pishghaman/apps/admin/src/pages/MessagesPage.vue
T

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>