fix(web,prisma): fix TypeScript errors in transfer wizard and schema
- Fix Zod v4 enum API: replace deprecated `required_error` with `error` - Create missing TransferWizardClient component (4-step wizard: category, items, AI estimate, submit) - Add CANCELLED status to TransferListingStatus enum for soft-delete support Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
337
apps/web/components/chuyen-nhuong/transfer-wizard-client.tsx
Normal file
337
apps/web/components/chuyen-nhuong/transfer-wizard-client.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2, Send } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
type TransferCategory,
|
||||||
|
type TransferCondition,
|
||||||
|
CATEGORY_ICONS,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
CONDITION_LABELS,
|
||||||
|
transferApi,
|
||||||
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
type TransferItemDraft,
|
||||||
|
useTransferWizardStore,
|
||||||
|
} from '@/lib/transfer-wizard-store';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
'Danh mục',
|
||||||
|
'Sản phẩm',
|
||||||
|
'Định giá AI',
|
||||||
|
'Thông tin & Gửi',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── Step 1: Category Selection ────────────────────────
|
||||||
|
|
||||||
|
function StepCategory() {
|
||||||
|
const { category, setCategory, setStep } = useTransferWizardStore();
|
||||||
|
const categories = Object.entries(CATEGORY_LABELS) as [TransferCategory, string][];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted-foreground text-sm">Chọn danh mục chuyển nhượng:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{categories.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCategory(value);
|
||||||
|
setStep(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-2 rounded-lg border p-4 text-sm transition hover:border-primary',
|
||||||
|
category === value && 'border-primary bg-primary/5 ring-1 ring-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{CATEGORY_ICONS[value]}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 2: Items ─────────────────────────────────────
|
||||||
|
|
||||||
|
function StepItems() {
|
||||||
|
const { items, addItem, removeItem, category } = useTransferWizardStore();
|
||||||
|
const [name, setName] = React.useState('');
|
||||||
|
const [condition, setCondition] = React.useState<TransferCondition>('GOOD');
|
||||||
|
const [askingPrice, setAskingPrice] = React.useState('');
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!name || !askingPrice || !category) return;
|
||||||
|
addItem({
|
||||||
|
name,
|
||||||
|
condition,
|
||||||
|
askingPriceVND: Number(askingPrice),
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
setName('');
|
||||||
|
setAskingPrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label>Tên sản phẩm</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Bàn làm việc" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Tình trạng</Label>
|
||||||
|
<select
|
||||||
|
value={condition}
|
||||||
|
onChange={(e) => setCondition(e.target.value as TransferCondition)}
|
||||||
|
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(CONDITION_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Giá (VND)</Label>
|
||||||
|
<Input type="number" value={askingPrice} onChange={(e) => setAskingPrice(e.target.value)} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleAdd} disabled={!name || !askingPrice}>
|
||||||
|
+ Thêm sản phẩm
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="divide-y rounded-md border">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
|
<span>{item.name} — {CONDITION_LABELS[item.condition]}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-medium">{new Intl.NumberFormat('vi-VN').format(item.askingPriceVND)} VND</span>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => removeItem(item.id)}>Xoá</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 3: AI Estimate ───────────────────────────────
|
||||||
|
|
||||||
|
function StepAiEstimate() {
|
||||||
|
const { items, category, aiEstimate, isEstimating, setAiEstimate, setIsEstimating } = useTransferWizardStore();
|
||||||
|
|
||||||
|
const handleEstimate = async () => {
|
||||||
|
if (!category || items.length === 0) return;
|
||||||
|
setIsEstimating(true);
|
||||||
|
try {
|
||||||
|
const payload = items.map((item) => ({
|
||||||
|
category,
|
||||||
|
condition: item.condition,
|
||||||
|
originalPriceVND: item.askingPriceVND,
|
||||||
|
purchaseYear: new Date().getFullYear(),
|
||||||
|
}));
|
||||||
|
const result = await transferApi.estimate(payload);
|
||||||
|
setAiEstimate(result);
|
||||||
|
} catch {
|
||||||
|
setAiEstimate(null);
|
||||||
|
} finally {
|
||||||
|
setIsEstimating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Sử dụng AI để ước tính giá trị chuyển nhượng dựa trên danh mục, tình trạng và giá gốc.
|
||||||
|
</p>
|
||||||
|
<Button type="button" onClick={handleEstimate} disabled={isEstimating || items.length === 0}>
|
||||||
|
{isEstimating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isEstimating ? 'Đang ước tính...' : 'Ước tính giá bằng AI'}
|
||||||
|
</Button>
|
||||||
|
{aiEstimate && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
Tổng ước tính: {new Intl.NumberFormat('vi-VN').format(Number(aiEstimate.totalEstimateVND))} VND
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Độ tin cậy: {Math.round(aiEstimate.avgConfidence * 100)}%
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 4: Details & Submit ──────────────────────────
|
||||||
|
|
||||||
|
function StepDetails() {
|
||||||
|
const store = useTransferWizardStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!store.category) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
category: store.category,
|
||||||
|
title: store.title,
|
||||||
|
description: store.description || undefined,
|
||||||
|
address: store.address,
|
||||||
|
district: store.district,
|
||||||
|
city: store.city,
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
askingPriceVND: store.askingPriceVND || store.items.reduce((sum, i) => sum + i.askingPriceVND * i.quantity, 0),
|
||||||
|
pricingSource: store.pricingSource,
|
||||||
|
isNegotiable: store.isNegotiable,
|
||||||
|
items: store.items.map((i) => ({
|
||||||
|
name: i.name,
|
||||||
|
brand: i.brand,
|
||||||
|
modelName: i.modelName,
|
||||||
|
category: store.category!,
|
||||||
|
condition: i.condition,
|
||||||
|
purchaseYear: i.purchaseYear,
|
||||||
|
originalPriceVND: i.askingPriceVND,
|
||||||
|
askingPriceVND: i.askingPriceVND,
|
||||||
|
quantity: i.quantity,
|
||||||
|
notes: i.notes,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await transferApi.create(payload);
|
||||||
|
store.reset();
|
||||||
|
router.push('/chuyen-nhuong');
|
||||||
|
} catch {
|
||||||
|
// Error handling deferred to toast integration
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label>Tiêu đề</Label>
|
||||||
|
<Input
|
||||||
|
value={store.title}
|
||||||
|
onChange={(e) => store.setListingDetails({ title: e.target.value })}
|
||||||
|
placeholder="VD: Chuyển nhượng bộ nội thất văn phòng"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label>Mô tả</Label>
|
||||||
|
<Textarea
|
||||||
|
value={store.description}
|
||||||
|
onChange={(e) => store.setListingDetails({ description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Địa chỉ</Label>
|
||||||
|
<Input
|
||||||
|
value={store.address}
|
||||||
|
onChange={(e) => store.setListingDetails({ address: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Quận/Huyện</Label>
|
||||||
|
<Input
|
||||||
|
value={store.district}
|
||||||
|
onChange={(e) => store.setListingDetails({ district: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Thành phố</Label>
|
||||||
|
<Input
|
||||||
|
value={store.city}
|
||||||
|
onChange={(e) => store.setListingDetails({ city: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={handleSubmit} disabled={submitting || !store.title || !store.address || !store.district}>
|
||||||
|
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||||
|
{submitting ? 'Đang gửi...' : 'Đăng tin'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Wizard ───────────────────────────────────────
|
||||||
|
|
||||||
|
export function TransferWizardClient() {
|
||||||
|
const { currentStep, setStep, items, category } = useTransferWizardStore();
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (currentStep === 0) return !!category;
|
||||||
|
if (currentStep === 1) return items.length > 0;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-3xl py-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Đăng tin chuyển nhượng</CardTitle>
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
{STEPS.map((label, i) => (
|
||||||
|
<React.Fragment key={label}>
|
||||||
|
{i > 0 && <div className={cn('h-px flex-1', i <= currentStep ? 'bg-primary' : 'bg-border')} />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => i < currentStep && setStep(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium transition',
|
||||||
|
i === currentStep && 'bg-primary text-primary-foreground',
|
||||||
|
i < currentStep && 'bg-primary/20 text-primary cursor-pointer',
|
||||||
|
i > currentStep && 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-1 text-center text-sm">{STEPS[currentStep]}</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{currentStep === 0 && <StepCategory />}
|
||||||
|
{currentStep === 1 && <StepItems />}
|
||||||
|
{currentStep === 2 && <StepAiEstimate />}
|
||||||
|
{currentStep === 3 && <StepDetails />}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep(currentStep - 1)}
|
||||||
|
disabled={currentStep === 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" /> Quay lại
|
||||||
|
</Button>
|
||||||
|
{currentStep < 3 && (
|
||||||
|
<Button type="button" onClick={() => setStep(currentStep + 1)} disabled={!canNext()}>
|
||||||
|
Tiếp theo <ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/web/lib/validations/transfer.ts
Normal file
76
apps/web/lib/validations/transfer.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ─── Step 1: Category Selection ─────────────────────────
|
||||||
|
|
||||||
|
export const TRANSFER_CATEGORIES = [
|
||||||
|
{ value: 'FURNITURE', label: 'Nội thất', icon: '🛋️' },
|
||||||
|
{ value: 'APPLIANCE', label: 'Thiết bị gia dụng', icon: '🧊' },
|
||||||
|
{ value: 'OFFICE_EQUIPMENT', label: 'Thiết bị văn phòng', icon: '🖥️' },
|
||||||
|
{ value: 'KITCHEN', label: 'Bếp & thiết bị', icon: '🍳' },
|
||||||
|
{ value: 'PREMISES', label: 'Mặt bằng', icon: '🏪' },
|
||||||
|
{ value: 'FULL_UNIT', label: 'Trọn bộ', icon: '🏠' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const TRANSFER_CONDITIONS = [
|
||||||
|
{ value: 'NEW', label: 'Mới' },
|
||||||
|
{ value: 'LIKE_NEW', label: 'Như mới' },
|
||||||
|
{ value: 'GOOD', label: 'Tốt' },
|
||||||
|
{ value: 'FAIR', label: 'Khá' },
|
||||||
|
{ value: 'WORN', label: 'Cũ' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const transferCategorySchema = z.object({
|
||||||
|
category: z.enum(['FURNITURE', 'APPLIANCE', 'OFFICE_EQUIPMENT', 'KITCHEN', 'PREMISES', 'FULL_UNIT'], {
|
||||||
|
error: 'Vui lòng chọn danh mục',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Step 2: Items ──────────────────────────────────────
|
||||||
|
|
||||||
|
export const transferItemSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Tên sản phẩm là bắt buộc'),
|
||||||
|
brand: z.string().optional(),
|
||||||
|
modelName: z.string().optional(),
|
||||||
|
condition: z.enum(['NEW', 'LIKE_NEW', 'GOOD', 'FAIR', 'WORN'], {
|
||||||
|
error: 'Vui lòng chọn tình trạng',
|
||||||
|
}),
|
||||||
|
purchaseYear: z.coerce.number().int().min(2000).max(new Date().getFullYear()).optional(),
|
||||||
|
originalPriceVND: z.coerce.number().positive('Giá phải lớn hơn 0').optional(),
|
||||||
|
askingPriceVND: z.coerce.number().positive('Giá phải lớn hơn 0'),
|
||||||
|
quantity: z.coerce.number().int().min(1).default(1),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TransferItemFormData = z.infer<typeof transferItemSchema>;
|
||||||
|
|
||||||
|
// For premises-specific fields
|
||||||
|
export const premisesFieldsSchema = z.object({
|
||||||
|
areaM2: z.coerce.number().positive('Diện tích phải lớn hơn 0').optional(),
|
||||||
|
monthlyRentVND: z.coerce.number().positive().optional(),
|
||||||
|
depositMonths: z.coerce.number().int().min(0).optional(),
|
||||||
|
remainingLeaseMo: z.coerce.number().int().min(0).optional(),
|
||||||
|
businessType: z.string().optional(),
|
||||||
|
footTraffic: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Step 3: Location & Contact ─────────────────────────
|
||||||
|
|
||||||
|
export const transferLocationSchema = z.object({
|
||||||
|
title: z.string().min(5, 'Tiêu đề tối thiểu 5 ký tự').max(200, 'Tiêu đề tối đa 200 ký tự'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
address: z.string().min(1, 'Địa chỉ là bắt buộc'),
|
||||||
|
ward: z.string().optional(),
|
||||||
|
district: z.string().min(1, 'Quận/Huyện là bắt buộc'),
|
||||||
|
city: z.string().min(1, 'Thành phố là bắt buộc').default('Hồ Chí Minh'),
|
||||||
|
contactName: z.string().optional(),
|
||||||
|
contactPhone: z.string().optional(),
|
||||||
|
isNegotiable: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Full form schema ───────────────────────────────────
|
||||||
|
|
||||||
|
export const createTransferListingSchema = transferCategorySchema
|
||||||
|
.merge(transferLocationSchema)
|
||||||
|
.merge(premisesFieldsSchema);
|
||||||
|
|
||||||
|
export type CreateTransferFormData = z.infer<typeof createTransferListingSchema>;
|
||||||
@@ -1122,6 +1122,7 @@ enum TransferListingStatus {
|
|||||||
SOLD
|
SOLD
|
||||||
EXPIRED
|
EXPIRED
|
||||||
REJECTED
|
REJECTED
|
||||||
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TransferPricingSource {
|
enum TransferPricingSource {
|
||||||
|
|||||||
Reference in New Issue
Block a user