Some checks failed
Deploy / Build API Image (push) Failing after 19s
Deploy / Build Web Image (push) Failing after 11s
Security Scanning / Trivy Filesystem Scan (push) Failing after 27s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 37s
Deploy / Build AI Services Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 47s
Security Scanning / Trivy Scan — Web Image (push) Failing after 31s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 32s
Deploy / Smoke Test Staging (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
User directive: avoid emojis for UI chrome; keep the icon language consistent with the rest of the design system (shadcn + lucide-react). Swaps ----- - lib/listing-personas.ts — Persona emojis (👨👩👧🏡🚇🧑💻🌳📈🛡️🏥) → Lucide icons (Baby, Home, TrainFront, Laptop, Trees, TrendingUp, Shield, HeartPulse). Persona type now carries `icon: LucideIcon`. - components/neighborhood/types.ts — POI_CATEGORY_CONFIG emojis (🏫🏥🚇🛒🍽️🌳) → Lucide (GraduationCap, Stethoscope, TrainFront, ShoppingBag, UtensilsCrossed, Trees). Config type tightened to `icon: LucideIcon`. - components/neighborhood/neighborhood-poi-map.tsx — filter pills now render <config.icon h-3.5 w-3.5>. Map markers were text-emoji (el.textContent = config.icon); replaced with hard-coded inline SVG strings per category (POI_MARKER_SVG) since lucide-static isn't installed. Marker bumped 28px → 32px for larger hit target. Popup now shows only the property name + category label (no emoji prefix). closeButton: true + closeOnClick: true for better dismissibility. - listing-detail-client.tsx — PersonaFitCard now renders <p.icon h-4 w-4 aria-hidden>. - transfer / chuyen-nhuong files — category icons (🛋️🧊🖥️🍳🛍️🏠) migrated to Lucide (Sofa, Refrigerator, Monitor, ChefHat, Store, Home) with type `icon: LucideIcon`. - Small replacements: inquiries page 📭 → Inbox; kyc page ✓ → Check. POI popup click fix ------------------- The inner SVG inside each POI marker was capturing pointer events before Mapbox's marker-click handler saw them, so clicking a marker did nothing. Explicit `innerSvg.style.pointerEvents = 'none'` lets clicks reach the wrapping .poi-marker div that setPopup() is bound to. Verified via DOM dispatch: click → popup opens with property name + category + distance + × close. Verification ------------ - Grep across the 4 scoped files for emoji code points → 0 hits. - pnpm -w test: 624/624 green. - Typecheck: no new errors in touched files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
7.7 KiB
TypeScript
216 lines
7.7 KiB
TypeScript
'use client';
|
|
|
|
import { Inbox } from 'lucide-react';
|
|
import * as React from 'react';
|
|
import { InquiryDetailDialog } from '@/components/inquiries/inquiry-detail-dialog';
|
|
import { InquiryRow, InquiryStatusBadge } from '@/components/inquiries/inquiry-row';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Select } from '@/components/ui/select';
|
|
import { useMyInquiries } from '@/lib/hooks/use-inquiries';
|
|
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
|
|
|
type ReadFilter = 'all' | 'unread' | 'read';
|
|
|
|
export default function InquiriesPage() {
|
|
const [page, setPage] = React.useState(1);
|
|
const [readFilter, setReadFilter] = React.useState<ReadFilter>('all');
|
|
const [selectedInquiry, setSelectedInquiry] = React.useState<InquiryReadDto | null>(null);
|
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
|
|
|
const { data: result, isLoading: loading } = useMyInquiries({ page, limit: 20 });
|
|
|
|
// Client-side filter for read/unread since API doesn't support it directly
|
|
const filteredData = React.useMemo(() => {
|
|
if (!result) return [];
|
|
if (readFilter === 'all') return result.data;
|
|
if (readFilter === 'unread') return result.data.filter((i) => !i.isRead);
|
|
return result.data.filter((i) => i.isRead);
|
|
}, [result, readFilter]);
|
|
|
|
const stats = React.useMemo(() => {
|
|
if (!result) return { total: 0, unread: 0, read: 0 };
|
|
return {
|
|
total: result.total,
|
|
unread: result.data.filter((i) => !i.isRead).length,
|
|
read: result.data.filter((i) => i.isRead).length,
|
|
};
|
|
}, [result]);
|
|
|
|
const handleSelectInquiry = (inquiry: InquiryReadDto) => {
|
|
setSelectedInquiry(inquiry);
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Quản lý liên hệ</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Xem và phản hồi các yêu cầu tư vấn từ khách hàng
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Tổng liên hệ</CardDescription>
|
|
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Chưa đọc</CardDescription>
|
|
<CardTitle className="text-xl text-blue-600">
|
|
{loading ? '...' : stats.unread}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Đã đọc</CardDescription>
|
|
<CardTitle className="text-xl text-green-600">
|
|
{loading ? '...' : stats.read}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-3">
|
|
<Select
|
|
value={readFilter}
|
|
onChange={(e) => {
|
|
setReadFilter(e.target.value as ReadFilter);
|
|
setPage(1);
|
|
}}
|
|
className="w-40"
|
|
>
|
|
<option value="all">Tất cả</option>
|
|
<option value="unread">Chưa đọc</option>
|
|
<option value="read">Đã đọc</option>
|
|
</Select>
|
|
<span className="text-sm text-muted-foreground">
|
|
{filteredData.length} liên hệ
|
|
</span>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{loading ? (
|
|
<div className="flex min-h-[300px] items-center justify-center">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
|
</div>
|
|
) : filteredData.length === 0 ? (
|
|
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
|
<Inbox className="mb-3 h-12 w-12" aria-hidden="true" />
|
|
<p>Chưa có liên hệ nào</p>
|
|
<p className="text-xs mt-1">
|
|
Khi khách hàng gửi yêu cầu tư vấn, chúng sẽ xuất hiện ở đây
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Mobile card view */}
|
|
<div className="space-y-3 sm:hidden">
|
|
{filteredData.map((inquiry) => (
|
|
<Card
|
|
key={inquiry.id}
|
|
className="cursor-pointer transition-shadow hover:shadow-md"
|
|
onClick={() => handleSelectInquiry(inquiry)}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">{inquiry.userName}</p>
|
|
<p className="text-xs text-muted-foreground">{inquiry.userPhone}</p>
|
|
</div>
|
|
<InquiryStatusBadge isRead={inquiry.isRead} />
|
|
</div>
|
|
<p className="mt-2 text-sm text-muted-foreground line-clamp-1">
|
|
{inquiry.listingTitle}
|
|
</p>
|
|
<p className="mt-1 text-sm line-clamp-2">{inquiry.message}</p>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
{new Date(inquiry.createdAt).toLocaleDateString('vi-VN', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Desktop table view */}
|
|
<Card className="hidden sm:block">
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left">
|
|
<th className="p-3 font-medium">Khách hàng</th>
|
|
<th className="p-3 font-medium">Tin đăng</th>
|
|
<th className="hidden p-3 font-medium sm:table-cell">Nội dung</th>
|
|
<th className="p-3 font-medium text-center">Trạng thái</th>
|
|
<th className="p-3 font-medium text-right">Ngày gửi</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredData.map((inquiry) => (
|
|
<InquiryRow
|
|
key={inquiry.id}
|
|
inquiry={inquiry}
|
|
onSelect={handleSelectInquiry}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{result && result.totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
>
|
|
Trước
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
Trang {result.page} / {result.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= result.totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Tiếp
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detail Dialog */}
|
|
<InquiryDetailDialog
|
|
inquiry={selectedInquiry}
|
|
open={dialogOpen}
|
|
onOpenChange={(open) => {
|
|
setDialogOpen(open);
|
|
if (!open) setSelectedInquiry(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|