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:
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user