Hồ Sơ Khách Hàng (Customer Profile)¶
Trang Hồ Sơ Khách Hàng (Customer Profile) cung cấp thông tin chi tiết toàn diện về từng khách hàng, bao gồm thông tin cá nhân, lịch sử đặt phòng, tương tác, loyalty membership, preferences và khả năng quản lý thống kê khách hàng dành cho Admin.
Tổng Quan¶
📸 Chụp từ: http://localhost:8082/crm/customer-profiles.html
Trang tổng quan hiển thị:
- Customer Header: Thông tin tổng quan với avatar, tên, email, số điện thoại
- Customer Type Tags: Phân loại khách hàng (Booking User, Guest User, Corporate, Group)
- Loyalty Tier Badge: Cấp độ thành viên loyalty (nếu có)
- Quick Stats Cards: 4 KPI cards chính (Total Bookings, Total Spent, Avg Stay, Satisfaction Score)
- Tabbed Interface: 5 tabs chi tiết (Customer Details, Booking History, Interaction Timeline, Loyalty Information, Admin Settings)
- Edit Profile Button: Chỉnh sửa thông tin khách hàng
Key Components¶
graph TD
A[Customer Profile Page] --> B[Header Section]
A --> C[Quick Stats]
A --> D[Tabbed Content]
B --> B1[Avatar & Name]
B --> B2[Customer Type]
B --> B3[Loyalty Tier]
B --> B4[Contact Info]
C --> C1[Total Bookings]
C --> C2[Total Spent]
C --> C3[Avg Stay Duration]
C --> C4[Satisfaction Score]
D --> D1[Customer Details Tab]
D --> D2[Booking History Tab]
D --> D3[Interaction Timeline Tab]
D --> D4[Loyalty Information Tab]
D --> D5[Admin Settings Tab]
Customer Header Section¶
📸 Chụp từ: http://localhost:8082/crm/customer-profiles.html
Header Information Display¶
Left Section: - Avatar: Icon 80x80px (desktop) hoặc 60px (mobile) - Customer Name: Họ tên đầy đủ (firstName + lastName) - Customer Type Tag: - BOOKING_USER (Blue) - GUEST_USER (Green) - CORPORATE (Purple) - GROUP (Orange) - Loyalty Tier Badge: Crown icon với tier name và color - Contact Details: - Email với MailOutlined icon - Phone với PhoneOutlined icon
Right Section: - Edit Profile Button: Primary button với EditOutlined icon - Desktop: "Edit Profile" - Mobile: "Edit" (rút gọn)
Mobile Optimization: - Avatar size giảm xuống 60px - Text size nhỏ hơn (16px title) - Email truncate nếu > 20 ký tự - Layout stack vertical trên màn hình nhỏ
Quick Stats Cards¶
📸 Chụp từ: http://localhost:8082/crm/customer-profiles.html
4 KPI Cards¶
Card 1: Total Bookings
{
title: "Total Bookings" | "Bookings" (mobile)
value: customer.totalBookings
prefix: CalendarOutlined icon | 📅 (mobile)
description: "Tổng số lần đặt phòng"
}
Card 2: Total Spent
{
title: "Total Spent" | "Spent" (mobile)
value: parseCurrencyFromAPI(customer.totalSpent).toVND()
prefix: DollarOutlined icon | 💰 (mobile)
description: "Tổng chi tiêu"
mobileFormat: "XXX K" (rút gọn nghìn)
}
Card 3: Average Stay Duration
{
title: "Avg Stay Duration" | "Avg Stay" (mobile)
value: customer.averageStayDuration
suffix: "nights" | "n" (mobile)
prefix: HomeOutlined icon | 🏠 (mobile)
description: "Thời gian lưu trú trung bình"
}
Card 4: Satisfaction Score
{
title: "Satisfaction Score" | "Rating" (mobile)
value: customer.satisfactionScore || 0
suffix: "/5"
prefix: StarOutlined icon | ⭐ (mobile)
valueColor:
- >= 4: Green (#3f8600)
- >= 3: Yellow (#faad14)
- < 3: Red (#ff4d4f)
description: "Điểm hài lòng"
}
Layout: - Desktop: Row với 4 columns (Col span={6}) - Mobile: 2x2 grid (Col xs={12}) - Gutter: 16px (desktop), 8px (mobile)
Tab 1: Customer Details¶
📸 Chụp từ: Click tab "Customer Details" hoặc "👤 Details" (mobile)
Personal Information Card¶
📸 Chụp từ: Tab "Customer Details" → Section "Personal Information"
Fields Displayed:
| Field | Type | Format | Example |
|---|---|---|---|
| Full Name | Text | firstName + lastName | Nguyễn Văn A |
| customer.email | nguyenvana@example.com | ||
| Phone | Phone | customer.phone | +84 123 456 789 |
| Date of Birth | Date | MMM DD, YYYY | Jan 15, 1990 |
| Gender | Select | MALE / FEMALE / OTHER | Male |
| Nationality | Text | customer.nationality | Vietnamese |
| Language | Text | customer.language | Vietnamese |
Component:
<Card title="Personal Information">
<Descriptions column={1} size="small">
<Descriptions.Item label="Full Name">
{customer.firstName} {customer.lastName}
</Descriptions.Item>
{/* ... other fields */}
</Descriptions>
</Card>
Contact Information Card¶
📸 Chụp từ: Tab "Customer Details" → Section "Contact Information"
Address Information:
Display Format:
Emergency Contact:
After address, có Divider và section "Emergency Contact":
| Field | Example |
|---|---|
| Name | Nguyễn Thị B |
| Phone | +84 987 654 321 |
| Relationship | Wife |
Identification Card¶
📸 Chụp từ: Tab "Customer Details" → Section "Identification"
ID Fields:
| Field | Type | Options |
|---|---|---|
| Document Type | Select | PASSPORT, ID_CARD, DRIVING_LICENSE |
| Document Number | Text | VN123456789 |
| Issue Date | Date | MMM DD, YYYY |
| Expiry Date | Date | MMM DD, YYYY |
| Issue Place | Text | Hanoi, Vietnam |
Mobile Format: - Column: 1 (stack vertical) - Date format: MM/DD/YY (rút gọn) - "Issue Place" ẩn trên mobile
Preferences Card¶
📸 Chụp từ: Tab "Customer Details" → Section "Preferences & Special Requirements"
Preference Fields:
| Field | Type | Options |
|---|---|---|
| Preferred Room Type | Select | STANDARD, DELUXE, SUITE, PRESIDENTIAL |
| Preferred Floor | Text | High floor, Low floor |
| Bed Type | Select | SINGLE, DOUBLE, TWIN, KING |
| Smoking | Boolean | Yes / No |
| Special Requests | Tags Array | Multiple tags |
Special Requests Display:
{customer.preferences.specialRequests.map((req, index) => (
<Tag key={index} size={isMobile ? 'small' : 'default'}>
{isMobile && req.length > 12 ? req.substring(0, 12) + '...' : req}
</Tag>
))}
Tab 2: Booking History¶
📸 Chụp từ: Click tab "Booking History" hoặc "📅 Bookings" (mobile)
Booking Table Columns¶
Column 1: Booking
render: (record: Booking) => (
<div>
<Text strong>{record.bookingNumber}</Text>
<br />
<Text type="secondary">{record.roomType}</Text>
</div>
)
Column 2: Dates
render: (record: Booking) => (
<div>
<div>{dayjs(record.checkInDate).format('MMM DD, YYYY')}</div>
<div>{dayjs(record.checkOutDate).format('MMM DD, YYYY')}</div>
<Text type="secondary">
{dayjs(record.checkOutDate).diff(dayjs(record.checkInDate), 'days')} nights
</Text>
</div>
)
Column 3: Amount
render: (record: Booking) => (
<Text strong>{parseCurrencyFromAPI(record.totalAmount).toVND()}</Text>
)
Column 4: Status
render: (record: Booking) => (
<Space direction="vertical" size="small">
<Tag color={getBookingStatusColor(record.status)}>
{record.status?.replace('_', ' ')}
</Tag>
<Tag>{record.channel}</Tag>
</Space>
)
Status Colors¶
| Status | Color | Meaning |
|---|---|---|
| CONFIRMED | Blue | Đặt phòng đã xác nhận |
| COMPLETED | Green | Hoàn thành |
| CANCELLED | Red | Đã hủy |
| NO_SHOW | Volcano | Khách không đến |
Channel Types¶
- DIRECT: Đặt trực tiếp
- OTA: Online Travel Agency
- PHONE: Đặt qua điện thoại
- WALK_IN: Walk-in booking
Table Pagination: - Desktop: 10 items per page, show size changer, quick jumper - Mobile: 5 items per page, simplified pagination - Show total: "X-Y of Z bookings"
Tab 3: Interaction Timeline¶
📸 Chụp từ: Click tab "Interaction Timeline" hoặc "⏰ Timeline" (mobile)
Timeline Component¶
Timeline Mode:
- Desktop: mode="left" (timeline bên trái)
- Mobile: Default mode (vertical center)
Timeline Item Structure:
<Timeline.Item
key={interaction.id}
dot={getInteractionIcon(interaction.type)}
color={interaction.type === 'COMPLAINT' ? 'red' : 'blue'}
>
{/* Content */}
</Timeline.Item>
Interaction Types & Icons¶
| Type | Icon | Color | Meaning |
|---|---|---|---|
| MailOutlined | Blue | Email communication | |
| PHONE | PhoneOutlined | Blue | Phone call |
| SMS | MailOutlined | Blue | SMS message |
| COMPLAINT | ExclamationCircleOutlined | Red | Khiếu nại |
| FEEDBACK | StarOutlined | Blue | Phản hồi |
| BOOKING | CalendarOutlined | Blue | Đặt phòng |
Interaction Display¶
📸 Chụp từ: Tab "Interaction Timeline" → Xem từng interaction
Top Section:
<Space>
<Text strong>{interaction.subject}</Text>
<Tag color={direction === 'INBOUND' ? 'green' : 'blue'}>
{interaction.direction}
</Tag>
<Tag>{interaction.type}</Tag>
</Space>
Description:
Bottom Metadata:
<Text type="secondary" style={{ fontSize: '12px' }}>
{dayjs(createdAt).format('MMM DD, YYYY HH:mm')}
{staffName && ` • by ${staffName}`}
{rating && (
<>
{' • '}
{Array.from({ length: rating }, (_, i) => (
<StarOutlined key={i} style={{ color: '#faad14' }} />
))}
</>
)}
</Text>
Direction Types: - INBOUND (Green): Khách hàng liên hệ vào - OUTBOUND (Blue): Khách sạn liên hệ ra
Tab 4: Loyalty Information¶
📸 Chụp từ: Click tab "Loyalty Information" hoặc "👑 Loyalty" (mobile)
Conditional Display:
{customer.loyaltyMember && (
<TabPane tab="Loyalty Information" key="loyalty">
{/* Loyalty content */}
</TabPane>
)}
Tab này chỉ hiện khi customer.loyaltyMember tồn tại.
Loyalty Status Card¶
📸 Chụp từ: Tab "Loyalty Information" → "Loyalty Status" card
Center Display:
<div style={{ textAlign: 'center' }}>
<Avatar
size={isMobile ? 60 : 80}
icon={<TrophyOutlined />}
style={{ backgroundColor: tier.color }}
/>
<Title level={4}>{tier.name}</Title>
<Text type="secondary">Level {tier.level}</Text>
</div>
Tier Information:
| Field | Display | Example |
|---|---|---|
| Tier Name | Title | Gold Member |
| Tier Level | Text | Level 3 |
| Tier Color | Avatar bg | #FFD700 |
| Membership ID | Description | LM-2024-001234 |
| Current Points | Description (bold) | 25,000 points |
| Lifetime Points | Description | 150,000 points |
| Status | Tag | ACTIVE (Green) / INACTIVE (Red) |
Mobile Format: - Membership ID truncate: "LM-2024-0012..." (12 chars) - Points format: "25K" instead of "25,000"
Points Progress Card¶
📸 Chụp từ: Tab "Loyalty Information" → "Points Progress" card
Progress Bar:
<Progress
percent={75}
strokeColor={tier.color}
showInfo={!isMobile}
size={isMobile ? 'small' : 'default'}
/>
Current vs Next Tier:
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Current: {currentPoints.toLocaleString()}</Text>
<Text type="secondary">Next: {nextTierPoints.toLocaleString()}</Text>
</div>
View Point History Button:
<Button
block
onClick={() => navigate(`/dashboard/loyalty/members/${loyaltyMemberId}/transactions`)}
size={isMobile ? 'small' : 'middle'}
>
{isMobile ? 'Point History' : 'View Point History'}
</Button>
Tab 5: Admin Settings¶
📸 Chụp từ: Click tab "Admin Settings" hoặc "⚙️ Admin" (mobile)
Permission Check:
const isAdmin = user?.role === 'ADMIN' ||
user?.role === 'SUPER_ADMIN' ||
user?.permissions?.includes('crm.customer.update_stats');
{isAdmin && (
<TabPane tab="Admin Settings" key="admin">
{/* Admin content */}
</TabPane>
)}
Tab này chỉ hiện với Admin users.
Customer Statistics Management¶
📸 Chụp từ: Tab "Admin Settings" → "Customer Statistics Management" card
Warning Banner:
<Text type="secondary">
This admin interface allows you to manually adjust customer statistics
and booking totals. All changes are logged for audit purposes.
</Text>
Current Stats Display:
Row 1: 3 Statistic Cards
<Row gutter={16}>
<Col xs={24} sm={8}>
<Statistic
title="Current Total Spent"
value={parseCurrencyFromAPI(customer.totalSpent).toVND()}
prefix={<DollarOutlined />}
/>
</Col>
<Col xs={24} sm={8}>
<Statistic
title="Current Total Bookings"
value={customer.totalBookings}
prefix={<CalendarOutlined />}
/>
</Col>
<Col xs={24} sm={8}>
<Statistic
title="Last Booking"
value={dayjs(customer.lastBookingDate).format('MMM DD, YYYY')}
prefix={<ClockCircleOutlined />}
/>
</Col>
</Row>
Update Stats Button:
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => setStatsModalVisible(true)}
block={isMobile}
danger
>
{isMobile ? 'Update Stats' : 'Update Customer Statistics'}
</Button>
Update Stats Modal¶
📸 Chụp từ: Click "Update Customer Statistics" button
Warning Alert:
<div style={{ backgroundColor: '#fff7e6', border: '1px solid #ffd666' }}>
<Space>
<ExclamationCircleOutlined style={{ color: '#fa8c16' }} />
<Text>
<strong>Admin Only:</strong> This action will permanently modify
customer statistics and is logged for audit.
</Text>
</Space>
</div>
Form Fields:
Field 1: Booking Amount
<Form.Item
name="bookingAmount"
label="Booking Amount to Add (VND)"
rules={[
{ required: true },
{ pattern: /^\d+(\.\d{1,2})?$/ }
]}
extra="Enter the amount to add to customer's total spent (e.g., 2500000.50)"
>
<Input
placeholder="2500000.50"
prefix="₫"
/>
</Form.Item>
Field 2: Reason
<Form.Item
name="reason"
label="Reason for Adjustment"
rules={[{ required: true }]}
>
<Select>
<Option value="booking_completion">Booking Completion</Option>
<Option value="manual_adjustment">Manual Adjustment</Option>
<Option value="system_correction">System Correction</Option>
<Option value="refund_reversal">Refund Reversal</Option>
<Option value="data_migration">Data Migration</Option>
<Option value="other">Other</Option>
</Select>
</Form.Item>
Field 3: Additional Notes
<Form.Item
name="notes"
label="Additional Notes"
rules={[{ max: 500 }]}
>
<Input.TextArea
rows={isMobile ? 2 : 3}
placeholder="Optional: Add any additional notes..."
showCount
maxLength={500}
/>
</Form.Item>
Success Info:
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Text style={{ color: '#52c41a' }}>
✓ This action will increase total bookings by 1 and update
the last booking date to current time.
✓ All changes are recorded in audit logs with your user ID and timestamp.
</Text>
</div>
Submit Action:
updateStatsMutation.mutate({
bookingAmount: values.bookingAmount,
reason: values.reason || 'Manual stats adjustment via admin interface',
notes: values.notes,
});
Success Response:
onSuccess: (data) => {
message.success(`Stats updated successfully! Audit ID: ${data.auditId}`);
setStatsModalVisible(false);
queryClient.invalidateQueries({ queryKey: ['customer'] });
}
Edit Profile Modal¶
📸 Chụp từ: Click "Edit Profile" button
Modal Configuration:
<Modal
title={isMobile ? 'Edit Profile' : 'Edit Customer Profile'}
open={editModalVisible}
onOk={handleUpdateCustomer}
onCancel={() => setEditModalVisible(false)}
confirmLoading={updateCustomerMutation.isPending}
width={isMobile ? '95vw' : 800}
/>
Personal Information Section¶
📸 Chụp từ: Edit Profile Modal → Personal section
Row 1: Name Fields
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item
name="firstName"
label="First Name"
rules={[{ required: true, message: 'Please enter first name' }]}
>
<Input />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="lastName"
label="Last Name"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Col>
</Row>
Row 2: Contact Fields
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item
name="email"
label="Email"
rules={[
{ required: true },
{ type: 'email', message: 'Please enter valid email' }
]}
>
<Input />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="phone" label="Phone" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
</Row>
Row 3: Additional Info
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item name="dateOfBirth" label="Date of Birth">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="gender" label="Gender">
<Select allowClear>
<Option value="MALE">Male</Option>
<Option value="FEMALE">Female</Option>
<Option value="OTHER">Other</Option>
</Select>
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="nationality" label="Nationality">
<Input />
</Form.Item>
</Col>
</Row>
Address Information Section¶
📸 Chụp từ: Edit Profile Modal → Address section (after divider)
Street Address:
<Row gutter={16}>
<Col span={24}>
<Form.Item name="street" label="Street Address">
<Input />
</Form.Item>
</Col>
</Row>
City, State, Country:
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item name="city" label="City">
<Input />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="state" label={isMobile ? 'State' : 'State/Province'}>
<Input />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="country" label="Country">
<Input />
</Form.Item>
</Col>
</Row>
Emergency Contact Section¶
📸 Chụp từ: Edit Profile Modal → Emergency Contact section
<Divider>{isMobile ? 'Emergency' : 'Emergency Contact'}</Divider>
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item name="emergencyContactName" label="Contact Name">
<Input />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="emergencyContactPhone" label="Contact Phone">
<Input />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="emergencyContactRelationship" label="Relationship">
<Input />
</Form.Item>
</Col>
</Row>
Preferences Section¶
📸 Chụp từ: Edit Profile Modal → Preferences section
<Divider>Preferences</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="roomType" label="Preferred Room Type">
<Select allowClear>
<Option value="STANDARD">Standard</Option>
<Option value="DELUXE">Deluxe</Option>
<Option value="SUITE">Suite</Option>
<Option value="PRESIDENTIAL">Presidential</Option>
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="bedType" label="Bed Type">
<Select allowClear>
<Option value="SINGLE">Single Bed</Option>
<Option value="DOUBLE">Double Bed</Option>
<Option value="TWIN">Twin Beds</Option>
<Option value="KING">King Bed</Option>
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="smoking" label="Smoking" valuePropName="checked">
<Switch checkedChildren="Smoking" unCheckedChildren="Non-smoking" />
</Form.Item>
</Col>
</Row>
Submit Handler:
const handleUpdateCustomer = async () => {
const values = await form.validateFields();
updateCustomerMutation.mutate({
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
phone: values.phone,
dateOfBirth: values.dateOfBirth?.format('YYYY-MM-DD'),
gender: values.gender,
nationality: values.nationality,
language: values.language,
address: {
street: values.street,
city: values.city,
state: values.state,
country: values.country,
postalCode: values.postalCode,
},
identification: {
type: values.identificationType,
number: values.identificationNumber,
},
emergencyContact: {
name: values.emergencyContactName,
phone: values.emergencyContactPhone,
relationship: values.emergencyContactRelationship,
},
preferences: {
roomType: values.roomType,
floor: values.floor,
bedType: values.bedType,
smoking: values.smoking,
},
});
};
Mobile Optimization¶
📸 Chụp từ: http://localhost:8082/crm/customer-profiles.html (trên mobile device)
Mobile-Specific Features¶
Responsive Breakpoints:
const isMobile = useIsMobile(); // Hook to detect mobile
// Used throughout component:
{isMobile ? 'Short Text' : 'Long Descriptive Text'}
Size Adjustments:
| Element | Desktop | Mobile |
|---|---|---|
| Avatar | 80px | 60px |
| Title Level | 2 | 4 |
| Title Font | Default | 16px |
| Card Size | default | small |
| Button Size | middle | small |
| Tag Size | default | small |
| Form Size | middle | small |
| Table Page Size | 10 | 5 |
Layout Changes:
- Quick Stats Cards
- Desktop: 4 columns (span={6})
-
Mobile: 2x2 grid (xs={12})
-
Contact Info
- Desktop: Horizontal space
-
Mobile: Vertical stack
-
Tab Labels
- Desktop: Full text + icon
-
Mobile: Emoji + short text
- "Customer Details" → "👤 Details"
- "Booking History" → "📅 Bookings"
- "Interaction Timeline" → "⏰ Timeline"
- "Loyalty Information" → "👑 Loyalty"
- "Admin Settings" → "⚙️ Admin"
-
Text Truncation
- Email > 20 chars: Truncate with "..."
- Membership ID: Show first 12 chars
-
Tags: Truncate > 12 chars
-
Icon Replacement
- Desktop: Ant Design icons
-
Mobile: Emoji icons (📧, 📞, 🏠, ⭐, 💰, etc.)
-
Number Formatting
- Desktop: Full number with locale (25,000)
-
Mobile: Abbreviated (25K)
-
Modal Adjustments
- Desktop: 800px width
- Mobile: 95vw width, max height 70vh, overflow scroll
API Endpoints¶
Fetch Customer Details¶
Endpoint:
Response:
interface Customer {
id: string
type: 'BOOKING_USER' | 'GUEST_USER' | 'CORPORATE' | 'GROUP'
firstName: string
lastName: string
email: string
phone: string
dateOfBirth?: string
gender?: 'MALE' | 'FEMALE' | 'OTHER'
nationality?: string
language?: string
address?: {
street: string
city: string
state: string
country: string
postalCode: string
}
identification?: {
type: 'PASSPORT' | 'ID_CARD' | 'DRIVING_LICENSE'
number: string
issueDate?: string
expiryDate?: string
issuePlace?: string
}
preferences?: {
roomType?: string
floor?: string
bedType?: string
smoking?: boolean
specialRequests?: string[]
}
emergencyContact?: {
name: string
phone: string
relationship: string
}
loyaltyMember?: {
id: string
membershipId: string
currentPoints: number
lifetimePoints: number
tier: {
name: string
level: number
color: string
}
status: string
}
totalBookings: number
totalSpent: string
lastBookingDate?: string
averageStayDuration: number
satisfactionScore?: number
createdAt: string
updatedAt: string
}
Fetch Customer Bookings¶
Endpoint:
Response:
interface Booking {
id: string
bookingNumber: string
checkInDate: string
checkOutDate: string
roomType: string
totalAmount: string
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'
channel: 'DIRECT' | 'OTA' | 'PHONE' | 'WALK_IN'
guestCount: number
specialRequests?: string
}[]
Fetch Customer Interactions¶
Endpoint:
Response:
interface Interaction {
id: string
type: 'EMAIL' | 'PHONE' | 'SMS' | 'COMPLAINT' | 'FEEDBACK' | 'BOOKING'
direction: 'INBOUND' | 'OUTBOUND'
subject: string
description: string
createdAt: string
staffName?: string
rating?: number
}[]
Update Customer¶
Endpoint:
Request Body:
{
firstName: string
lastName: string
email: string
phone: string
dateOfBirth?: string
gender?: 'MALE' | 'FEMALE' | 'OTHER'
nationality?: string
language?: string
address?: {
street: string
city: string
state: string
country: string
postalCode: string
}
identification?: {
type: 'PASSPORT' | 'ID_CARD' | 'DRIVING_LICENSE'
number: string
}
emergencyContact?: {
name: string
phone: string
relationship: string
}
preferences?: {
roomType?: string
floor?: string
bedType?: string
smoking?: boolean
}
}
Success Response:
Update Customer Stats (Admin Only)¶
Endpoint:
Request Body:
{
bookingAmount: string // Decimal format: "2500000.50"
reason: string // Reason for adjustment
notes?: string // Optional notes (max 500 chars)
}
Success Response:
Error Response:
Data Flow¶
sequenceDiagram
participant U as User
participant C as Component
participant API as API Gateway
participant CRM as CRM Service
participant DB as Database
U->>C: Open customer profile page
C->>API: GET /api/crm/tenants/{tenantId}/customers/{customerId}
API->>CRM: Forward request
CRM->>DB: Query customer data
DB-->>CRM: Return customer
CRM-->>API: Return customer
API-->>C: Return customer
C->>API: GET /api/crm/tenants/{tenantId}/customers/{customerId}/bookings
API->>CRM: Forward request
CRM->>DB: Query bookings
DB-->>CRM: Return bookings
CRM-->>API: Return bookings
API-->>C: Return bookings
C->>API: GET /api/crm/tenants/{tenantId}/customers/{customerId}/interactions
API->>CRM: Forward request
CRM->>DB: Query interactions
DB-->>CRM: Return interactions
CRM-->>API: Return interactions
API-->>C: Return interactions
C-->>U: Display complete profile
U->>C: Click "Edit Profile"
C-->>U: Show edit modal with current data
U->>C: Submit changes
C->>API: PATCH /api/crm/tenants/{tenantId}/customers/{customerId}
API->>CRM: Forward update
CRM->>DB: Update customer
DB-->>CRM: Confirm update
CRM-->>API: Return updated customer
API-->>C: Return updated customer
C->>C: Invalidate query cache
C->>API: Refetch customer data
API-->>C: Return fresh data
C-->>U: Show success message & updated profile
Query & State Management¶
React Query Hooks¶
Customer Details Query:
const { data: customer, isLoading } = useQuery({
queryKey: ['customer', user?.tenantId, customerId],
queryFn: async () => {
const response = await apiClient.instance.get(
`/api/crm/tenants/${user?.tenantId}/customers/${customerId}`
);
return response.data as Customer;
},
enabled: !!user?.tenantId && !!customerId,
});
Bookings Query:
const { data: bookings } = useQuery({
queryKey: ['customer-bookings', user?.tenantId, customerId],
queryFn: async () => {
const response = await apiClient.instance.get(
`/api/crm/tenants/${user?.tenantId}/customers/${customerId}/bookings`
);
return response.data as Booking[];
},
enabled: !!user?.tenantId && !!customerId,
});
Interactions Query:
const { data: interactions } = useQuery({
queryKey: ['customer-interactions', user?.tenantId, customerId],
queryFn: async () => {
const response = await apiClient.instance.get(
`/api/crm/tenants/${user?.tenantId}/customers/${customerId}/interactions`
);
return response.data as Interaction[];
},
enabled: !!user?.tenantId && !!customerId,
});
Mutations¶
Update Customer Mutation:
const updateCustomerMutation = useMutation({
mutationFn: async (customerData: any) => {
const response = await apiClient.instance.patch(
`/api/crm/tenants/${user?.tenantId}/customers/${customerId}`,
customerData
);
return response.data;
},
onSuccess: () => {
message.success('Customer updated successfully!');
setEditModalVisible(false);
queryClient.invalidateQueries({ queryKey: ['customer'] });
},
onError: (error: any) => {
message.error(error.response?.data?.message || 'Failed to update customer');
},
});
Update Stats Mutation (Admin):
const updateStatsMutation = useMutation({
mutationFn: async (statsData: any) => {
const response = await apiClient.instance.patch(
`/api/crm/tenants/${user?.tenantId}/customers/${customerId}/stats`,
{
bookingAmount: statsData.bookingAmount,
reason: statsData.reason,
notes: statsData.notes,
}
);
return response.data;
},
onSuccess: (data) => {
message.success(`Stats updated successfully! Audit ID: ${data.auditId}`);
setStatsModalVisible(false);
statsForm.resetFields();
queryClient.invalidateQueries({ queryKey: ['customer'] });
},
onError: (error: any) => {
message.error(error.response?.data?.message || 'Failed to update stats');
},
});
Helper Functions¶
Status Color Mapping¶
const getCustomerTypeColor = (type: string) => {
switch (type) {
case 'BOOKING_USER': return 'blue';
case 'GUEST_USER': return 'green';
case 'CORPORATE': return 'purple';
case 'GROUP': return 'orange';
default: return 'default';
}
};
const getBookingStatusColor = (status: string) => {
switch (status) {
case 'CONFIRMED': return 'blue';
case 'COMPLETED': return 'green';
case 'CANCELLED': return 'red';
case 'NO_SHOW': return 'volcano';
default: return 'default';
}
};
Icon Mapping¶
const getInteractionIcon = (type: string) => {
switch (type) {
case 'EMAIL': return <MailOutlined />;
case 'PHONE': return <PhoneOutlined />;
case 'SMS': return <MailOutlined />;
case 'COMPLAINT': return <ExclamationCircleOutlined />;
case 'FEEDBACK': return <StarOutlined />;
case 'BOOKING': return <CalendarOutlined />;
default: return <ClockCircleOutlined />;
}
};
Currency Formatting¶
// Using parseCurrencyFromAPI utility
parseCurrencyFromAPI(customer.totalSpent).toVND()
// Output: "2,500,000 ₫"
// Mobile shortened format
{isMobile ?
`${Math.round(parseCurrencyFromAPI(customer.totalSpent).toNumber() / 1000)}K` :
parseCurrencyFromAPI(customer.totalSpent).toVND()
}
// Mobile output: "2500K"
Permissions & Access Control¶
Admin-Only Features¶
Stats Management Tab:
const isAdmin = user?.role === 'ADMIN' ||
user?.role === 'SUPER_ADMIN' ||
user?.permissions?.includes('crm.customer.update_stats');
Visible to:
- Role: ADMIN
- Role: SUPER_ADMIN
- Permission: crm.customer.update_stats
Edit Permission¶
Edit Profile Button:
<RequirePermission resource="customer" action="update" fallback={null}>
<Button type="primary" icon={<EditOutlined />} onClick={handleEditCustomer}>
Edit Profile
</Button>
</RequirePermission>
Required Permission:
- Resource: customer
- Action: update
Tips & Best Practices¶
💡 Tip 1: Comprehensive Customer View - Xem toàn bộ customer journey từ lần đặt đầu tiên đến hiện tại - Phân tích booking patterns và preferences để personalize service - Sử dụng satisfaction score để identify at-risk customers
💡 Tip 2: Leverage Loyalty Data - Check loyalty tier để apply appropriate benefits - View current points để suggest redemption opportunities - Track lifetime points để recognize top-spending customers
💡 Tip 3: Interaction Timeline - Review interaction history trước khi contact customer - Identify complaints và unresolved issues - Track staff interactions để ensure consistency
💡 Tip 4: Admin Stats Management - Only use khi có lý do chính đáng (system correction, data migration) - Always document trong "Reason" và "Notes" fields - Review audit logs định kỳ để detect unauthorized changes
💡 Tip 5: Mobile Accessibility - Profile page fully responsive cho mobile staff access - Emoji icons giúp nhận diện nhanh trên màn hình nhỏ - Simplified pagination và truncated text optimize mobile UX
💡 Tip 6: Data Quality - Keep emergency contact updated cho safety compliance - Maintain accurate preferences để improve guest satisfaction - Update identification documents trước expiry date
FAQs¶
1. Làm thế nào để import customer từ external source?¶
Câu trả lời:
Hiện tại chưa có bulk import UI, nhưng có thể sử dụng API:
POST /api/crm/tenants/{tenantId}/customers/bulk-import
Body:
{
"customers": [
{
"type": "BOOKING_USER",
"firstName": "Nguyen",
"lastName": "Van A",
"email": "nguyenvana@example.com",
"phone": "+84123456789",
...
},
...
]
}
Best Practice: - Validate data trước khi import (email format, phone format) - Check duplicates bằng email hoặc phone - Import theo batches (500-1000 records/batch) - Log errors và retry failed imports
2. Satisfaction score được tính như thế nào?¶
Câu trả lời:
Satisfaction score được aggregate từ: - Guest feedback ratings (after checkout) - Review scores từ các platforms (Booking.com, Google, TripAdvisor) - Complaint resolution quality - Service interaction ratings
Calculation Formula:
satisfactionScore = (
sum(feedbackRatings) +
sum(reviewScores) +
complaintResolutionScore
) / totalInteractions
// Normalized to 0-5 scale
Improvement Actions: - Score < 3: Immediate manager review required - Score 3-4: Monitor closely, identify issues - Score > 4: Maintain quality, recognize in loyalty program
3. Làm thế nào để merge duplicate customers?¶
Câu trả lời:
Identify Duplicates: - Same email with different names - Same phone with different emails - Similar name với typos
Merge Process: 1. Admin identifies duplicates manually 2. Review both profiles carefully 3. Use Admin Stats Management để consolidate: - Add booking amounts from duplicate to primary - Reason: "Data consolidation - merge duplicate" - Notes: "Merged from customer ID: XXX" 4. Manually transfer loyalty points (if different programs) 5. Update booking history references 6. Deactivate duplicate profile
Future Enhancement: - Automatic duplicate detection algorithm - Merge wizard với conflict resolution UI - Audit trail của merged customers
4. Customer preferences có ảnh hưởng đến booking process không?¶
Câu trả lời:
Yes! Preferences được sử dụng trong:
1. Booking Creation: - Auto-suggest preferred room type - Pre-fill floor preference - Default bed type selection - Highlight smoking/non-smoking preference
2. Room Assignment: - Priority matching cho repeat guests - Alert staff nếu không match preferences - Upgrade suggestions based on preferences
3. Marketing: - Targeted promotions cho preferred room types - Personalized email campaigns - Special offers aligned with preferences
Example Workflow:
// During booking creation
if (customer.preferences?.roomType === 'SUITE') {
suggestedRoom = availableSuites[0];
showUpgradeOffer = false; // Already preferred type
} else {
suggestedRoom = customer.preferences?.roomType || 'STANDARD';
showUpgradeOffer = true; // Upsell opportunity
}
5. Admin có thể revert stats adjustment không?¶
Câu trả lời:
Không thể auto-revert, nhưng có thể adjust ngược lại:
Revert Process: 1. Find audit log entry (auditId from success message) 2. Review original adjustment:
Audit ID: AUD-2024-001234
Date: 2024-01-22 10:30:00
User: admin@hotel.com
Action: Added 2,500,000 VND
Reason: Booking completion
- Create reverse adjustment:
- Booking Amount:
-2500000.00(negative value) - Reason: "Revert adjustment AUD-2024-001234"
-
Notes: "Original adjustment was incorrect - reason: [explain]"
-
Submit adjustment
Audit Trail:
AUD-2024-001234: +2,500,000 VND (Booking completion)
AUD-2024-001235: -2,500,000 VND (Revert AUD-2024-001234)
Net Impact: 0 VND
Prevention: - Double-check amount before submitting - Require manager approval for adjustments > 10M VND - Regular audit log reviews - Access control (only ADMIN/SUPER_ADMIN)
6. Loyalty member data có sync với loyalty system không?¶
Câu trả lời:
Yes, real-time sync!
Data Flow:
Synced Fields:
- loyaltyMember.currentPoints - Real-time balance
- loyaltyMember.lifetimePoints - Cumulative total
- loyaltyMember.tier - Current tier (auto-upgrade)
- loyaltyMember.status - ACTIVE/INACTIVE/SUSPENDED
Update Triggers: - Point earn/redeem: Instant update - Tier upgrade: Instant update - Status change: Instant update
Refresh Mechanism:
// React Query auto-refetch on window focus
refetchOnWindowFocus: true
// Manual refresh button (if needed)
<Button onClick={() => queryClient.invalidateQueries(['customer'])}>
Refresh
</Button>
Click "View Point History":
navigate(`/dashboard/loyalty/members/${customer.loyaltyMember.id}/transactions`)
// Opens detailed point transaction history
Liên Kết Liên Quan¶
- Khách Hàng Nâng Cao - Danh sách khách hàng
- Phân Tích Khách Hàng - Analytics dashboard
- Chương Trình Loyalty - Loyalty program management
- Thành Viên Loyalty - Loyalty member list
- Phản Hồi - Feedback management
- Marketing Dashboard - Marketing campaigns
- Phân Khúc Khách Hàng - Customer segmentation
Cập nhật lần cuối: 2025-01-29 Phiên bản: 1.0 Tác giả: Claude Code Documentation Team