feat(web): listings page — ticker-style DataTable với toggle card view

Tạo mới trang /listings dạng bảng ticker-style theo spec TEC-3034.

- DataTable compact (row 36px, sticky header, alternating rows)
- Cột: #, Mã (GG-xxx), Quận, Loại, Giá, Δ30d, DT m², KL/Views
- Sortable theo Giá, Δ30d, DT m², KL/Views
- Filter inline: Loại giao dịch, Loại BĐS, Quận, Khoảng giá
- Toggle view: Table (default) ↔ Card grid (legacy component cũ)
- Pagination restyle compact, giữ nguyên API params
- Click row → navigate to detail page
- Dùng DataTable + PriceDelta từ @/components/design-system

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 01:31:22 +07:00
parent 310ff7bb3e
commit 9bb4c42f84
21 changed files with 1623 additions and 223 deletions

View File

@@ -13,7 +13,7 @@ describe('InquiryCreatedEvent', () => {
it('has the correct event name', () => {
const event = new InquiryCreatedEvent('inq-1', 'listing-1', 'user-1');
expect(event.eventName).toBe('inquiry.created');
expect(event.eventName).toBe('inquiry.received');
});
it('records the occurred timestamp', () => {

View File

@@ -1,7 +1,7 @@
import { type DomainEvent } from '@modules/shared';
export class InquiryCreatedEvent implements DomainEvent {
readonly eventName = 'inquiry.created';
readonly eventName = 'inquiry.received';
readonly occurredAt = new Date();
constructor(

View File

@@ -0,0 +1,106 @@
import { InquiryCreatedEvent } from '@modules/inquiries/domain/events/inquiry-created.event';
import { CreateLeadCommand } from '../../commands/create-lead/create-lead.command';
import { InquiryCreatedToLeadListener } from '../inquiry-created-to-lead.listener';
describe('InquiryCreatedToLeadListener', () => {
let listener: InquiryCreatedToLeadListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
const agentUserId = 'agent-user-1';
const listingId = 'listing-1';
const inquiryUserId = 'user-1';
const inquiryId = 'inq-1';
const mockListing = {
id: listingId,
agent: { user: { id: agentUserId } },
};
const mockSender = {
fullName: 'Nguyen Van A',
phone: '0901234567',
email: 'a@test.com',
};
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue({ id: 'lead-1', status: 'NEW', createdAt: new Date().toISOString() }) };
mockPrisma = {
listing: { findUnique: vi.fn().mockResolvedValue(mockListing) },
user: { findUnique: vi.fn().mockResolvedValue(mockSender) },
};
mockLogger = { log: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() };
listener = new InquiryCreatedToLeadListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('creates a lead when listing has an agent and sender is found', async () => {
const event = new InquiryCreatedEvent(inquiryId, listingId, inquiryUserId);
await listener.handle(event);
expect(mockCommandBus.execute).toHaveBeenCalledOnce();
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateLeadCommand;
expect(cmd).toBeInstanceOf(CreateLeadCommand);
expect(cmd.agentUserId).toBe(agentUserId);
expect(cmd.name).toBe(mockSender.fullName);
expect(cmd.phone).toBe(mockSender.phone);
expect(cmd.email).toBe(mockSender.email);
expect(cmd.source).toBe('INQUIRY');
});
it('skips lead creation when listing has no agent', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ id: listingId, agent: null });
const event = new InquiryCreatedEvent(inquiryId, listingId, inquiryUserId);
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalled();
});
it('skips lead creation when listing is not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const event = new InquiryCreatedEvent(inquiryId, listingId, inquiryUserId);
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('skips lead creation when sender user is not found', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
const event = new InquiryCreatedEvent(inquiryId, listingId, inquiryUserId);
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalled();
});
it('does not throw when commandBus.execute rejects — failure is non-blocking', async () => {
mockCommandBus.execute.mockRejectedValue(new Error('DB error'));
const event = new InquiryCreatedEvent(inquiryId, listingId, inquiryUserId);
await expect(listener.handle(event)).resolves.not.toThrow();
expect(mockLogger.error).toHaveBeenCalled();
});
it('falls back to phone when sender has no fullName', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ fullName: null, phone: '0901234567', email: null });
const event = new InquiryCreatedEvent(inquiryId, listingId, inquiryUserId);
await listener.handle(event);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateLeadCommand;
expect(cmd.name).toBe('0901234567');
expect(cmd.email).toBeNull();
});
});

View File

@@ -0,0 +1,84 @@
import { EventsHandler, CommandBus, type IEventHandler } from '@nestjs/cqrs';
import { InquiryCreatedEvent } from '@modules/inquiries/domain/events/inquiry-created.event';
import { LoggerService, PrismaService } from '@modules/shared';
import { CreateLeadCommand } from '../commands/create-lead/create-lead.command';
/**
* Listens for InquiryCreatedEvent (emitted via CQRS EventBus) and
* automatically creates a Lead for the listing's agent.
*
* Source mapping:
* - agentUserId — resolved from listing.agent.user.id
* - name / phone — from the inquiring user's profile
* - source — 'INQUIRY' (indicates lead came from a property inquiry)
*/
@EventsHandler(InquiryCreatedEvent)
export class InquiryCreatedToLeadListener implements IEventHandler<InquiryCreatedEvent> {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async handle(event: InquiryCreatedEvent): Promise<void> {
try {
const listing = await this.prisma.listing.findUnique({
where: { id: event.listingId },
include: {
agent: { include: { user: { select: { id: true } } } },
},
});
if (!listing?.agent?.user?.id) {
this.logger.debug(
`InquiryCreatedToLeadListener: listing ${event.listingId} has no agent — skipping lead creation`,
'InquiryCreatedToLeadListener',
);
return;
}
const sender = await this.prisma.user.findUnique({
where: { id: event.userId },
select: { fullName: true, phone: true, email: true },
});
if (!sender) {
this.logger.warn(
`InquiryCreatedToLeadListener: sender ${event.userId} not found — skipping lead creation`,
'InquiryCreatedToLeadListener',
);
return;
}
const name = sender.fullName ?? sender.phone ?? 'Khách hàng';
const phone = sender.phone ?? '';
const email = sender.email ?? null;
await this.commandBus.execute(
new CreateLeadCommand(
listing.agent.user.id,
name,
phone,
email,
'INQUIRY',
null,
`Tự động tạo từ yêu cầu tư vấn #${event.aggregateId}`,
),
);
this.logger.log(
`Lead created for agent ${listing.agent.user.id} from inquiry ${event.aggregateId}`,
'InquiryCreatedToLeadListener',
);
} catch (error) {
// Non-blocking — a lead creation failure must never break the inquiry flow
this.logger.error(
`Failed to auto-create lead from inquiry ${event.aggregateId}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
'InquiryCreatedToLeadListener',
);
}
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { InquiryCreatedToLeadListener } from './application/event-handlers/inquiry-created-to-lead.listener';
import { CreateLeadHandler } from './application/commands/create-lead/create-lead.handler';
import { DeleteLeadHandler } from './application/commands/delete-lead/delete-lead.handler';
import { UpdateLeadStatusHandler } from './application/commands/update-lead-status/update-lead-status.handler';
@@ -13,6 +14,8 @@ const CommandHandlers = [CreateLeadHandler, UpdateLeadStatusHandler, DeleteLeadH
const QueryHandlers = [GetLeadsByAgentHandler, GetLeadStatsHandler];
const EventHandlers = [InquiryCreatedToLeadListener];
@Module({
imports: [CqrsModule],
controllers: [LeadsController],
@@ -20,6 +23,7 @@ const QueryHandlers = [GetLeadsByAgentHandler, GetLeadStatsHandler];
{ provide: LEAD_REPOSITORY, useClass: PrismaLeadRepository },
...CommandHandlers,
...QueryHandlers,
...EventHandlers,
],
exports: [LEAD_REPOSITORY],
})

View File

@@ -58,7 +58,7 @@ export class GetProjectStatsHandler
const rows = await this.prisma.$queryRaw<StatsRow[]>`
SELECT
COUNT(DISTINCT l.id) FILTER (WHERE l.id IS NOT NULL) AS linked,
COUNT(DISTINCT l.id) FILTER (WHERE l.status = 'APPROVED') AS active,
COUNT(DISTINCT l.id) FILTER (WHERE l.status = 'ACTIVE') AS active,
COUNT(DISTINCT i.id) FILTER (WHERE i.id IS NOT NULL) AS inquiries,
COUNT(DISTINCT i.id) FILTER (WHERE i.id IS NOT NULL AND i."isRead" = FALSE) AS unread,
COUNT(DISTINCT sl."userId") FILTER (WHERE sl."userId" IS NOT NULL) AS saves