From 17c5f14e20e9a2c20346df2efa733abf2272f1dc Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 11 May 2026 10:48:44 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-PE:=20NCC=20table=20S=C4=90T+Emai?= =?UTF-8?q?l=20r=C3=B5=20r=C3=A0ng=20+=20validate=20format=20+=20S?= =?UTF-8?q?=E1=BB=91=20ti=E1=BB=81n=20format=20VND?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User Session 20 turn 4: NCC info cơ bản (SĐT + Email), ràng buộc format, input tiền VND format dấu chấm 1.000.000. FE-only mirror fe-admin + fe-user. 1. Helpers cấu hình ở top file (gần fmtMoney): - parseVnd(s): strip non-digit → number (0 nếu rỗng) - formatVndInput(n): n.toLocaleString('vi-VN') hoặc '' khi n=0 - PHONE_RE /^0\d{9,10}$/ — VN bắt đầu 0, 10-11 digits sau strip space/dash/dot - EMAIL_RE /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - isValidPhone(s) / isValidEmail(s) — empty OK (optional) 2. NCC inline table HangMucCard — bỏ cột "Liên hệ" tổng hợp (ContactName + Phone + Email gộp), thay bằng 2 cột riêng: Trước: NCC | Liên hệ | Điều khoản TT | File báo giá | Số tiền | Action Sau: NCC | SĐT | Email | Điều khoản TT | File báo giá | Số tiền | Action SĐT cell font-mono (đọc số rõ); Email cell truncate + title hover full. 3. AddSupplierDialog + EditSupplierDialog — validate phone + email: - Input type="tel" / type="email" + inputMode + placeholder example - border-red-300 khi invalid + dòng error text [10px] mt-0.5 red-600 - Disable nút Lưu/Thêm khi hasError = phoneError || emailError - ContactName + DisplayName + Note + PaymentTermText giữ (optional fields, backward compat — user nói "cơ bản thôi" áp cho display, dialog giữ đầy đủ field tránh churn schema) 4. QuoteDialog "Số tiền" input format VND: - type="text" inputMode="numeric" thay vì type="number" (để format dấu chấm ngàn không phá bởi browser number parser) - value={formatVndInput(form.thanhTien)} → display "1.000.000" - onChange={parseVnd(e.target.value)} → strip non-digit → state raw number - Suffix span "đ" tuyệt đối inset-y-0 right-3 pointer-events-none - Hint dưới input: "VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)" - Class font-mono text-right pr-12 (chừa chỗ suffix) Verify: - npm run build × fe-admin pass - npm run build × fe-user pass Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/components/pe/PeDetailTabs.tsx | 107 ++++++++++++++++---- fe-user/src/components/pe/PeDetailTabs.tsx | 107 ++++++++++++++++---- 2 files changed, 180 insertions(+), 34 deletions(-) diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 4af4413..c853b99 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -42,6 +42,19 @@ import type { Paged, Supplier } from '@/types/master' const fmtMoney = (v: number) => v.toLocaleString('vi-VN') +// Session 20 turn 4 — input helpers cho NCC/Quote inline form. +// VND format dùng convention VN dấu chấm ngàn (1.000.000). Strip non-digit +// khi parse user input → number. Empty/0 → empty string để placeholder hiện. +const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0 +const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '') + +// Validation cơ bản FE — empty OK (optional fields). BE FluentValidation +// chưa enforce, FE check để user nhập sai biết ngay. +const PHONE_RE = /^0\d{9,10}$/ // VN: bắt đầu 0, 10-11 digits sau khi strip space/dash/dot +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) +const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s) + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -1062,6 +1075,10 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on paymentTermText: '', note: '', }) + const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' + const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' + const hasError = !!(phoneError || emailError) + const mut = useMutation({ mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form), onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, @@ -1075,7 +1092,7 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on title="Thêm NCC vào phiếu" footer={<> - + } >
@@ -1092,8 +1109,29 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on
setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" />
setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" />
setForm({ ...form, contactName: e.target.value })} />
-
setForm({ ...form, contactPhone: e.target.value })} />
-
setForm({ ...form, contactEmail: e.target.value })} />
+
+ + setForm({ ...form, contactPhone: e.target.value })} + placeholder="0987654321" + className={phoneError ? 'border-red-300' : undefined} + /> + {phoneError &&

{phoneError}

} +
+
+ + setForm({ ...form, contactEmail: e.target.value })} + placeholder="name@example.com" + className={emailError ? 'border-red-300' : undefined} + /> + {emailError &&

{emailError}

} +
setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." />
@@ -1112,6 +1150,10 @@ function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: stri paymentTermText: row.paymentTermText ?? '', note: row.note ?? '', }) + const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' + const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' + const hasError = !!(phoneError || emailError) + const mut = useMutation({ mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form), onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, @@ -1124,15 +1166,36 @@ function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: stri title={`Sửa NCC — ${row.supplierName}`} footer={<> - + } >
setForm({ ...form, displayName: e.target.value })} />
setForm({ ...form, paymentTermText: e.target.value })} />
setForm({ ...form, contactName: e.target.value })} />
-
setForm({ ...form, contactPhone: e.target.value })} />
-
setForm({ ...form, contactEmail: e.target.value })} />
+
+ + setForm({ ...form, contactPhone: e.target.value })} + placeholder="0987654321" + className={phoneError ? 'border-red-300' : undefined} + /> + {phoneError &&

{phoneError}

} +
+
+ + setForm({ ...form, contactEmail: e.target.value })} + placeholder="name@example.com" + className={emailError ? 'border-red-300' : undefined} + /> + {emailError &&

{emailError}

} +
setForm({ ...form, note: e.target.value })} />
@@ -1331,7 +1394,8 @@ function HangMucCard({ NCC - Liên hệ + SĐT + Email Điều khoản TT File báo giá Số tiền @@ -1355,11 +1419,13 @@ function HangMucCard({ {s.displayName &&
{s.displayName}
} {s.note &&
{s.note}
} + + {s.contactPhone || } + - {s.contactName &&
{s.contactName}
} - {s.contactPhone &&
{s.contactPhone}
} - {s.contactEmail &&
{s.contactEmail}
} - {!s.contactName && !s.contactPhone && !s.contactEmail && } + {s.contactEmail + ? {s.contactEmail} + : } {s.paymentTermText ?? } @@ -1578,12 +1644,19 @@ function QuoteDialog({

Hạng mục: {itemName}

- setForm({ thanhTien: Number(e.target.value) })} - autoFocus - /> +
+ setForm({ thanhTien: parseVnd(e.target.value) })} + placeholder="0" + autoFocus + className="pr-12 font-mono text-right" + /> + đ +
+

VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)

diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 4af4413..c853b99 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -42,6 +42,19 @@ import type { Paged, Supplier } from '@/types/master' const fmtMoney = (v: number) => v.toLocaleString('vi-VN') +// Session 20 turn 4 — input helpers cho NCC/Quote inline form. +// VND format dùng convention VN dấu chấm ngàn (1.000.000). Strip non-digit +// khi parse user input → number. Empty/0 → empty string để placeholder hiện. +const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0 +const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '') + +// Validation cơ bản FE — empty OK (optional fields). BE FluentValidation +// chưa enforce, FE check để user nhập sai biết ngay. +const PHONE_RE = /^0\d{9,10}$/ // VN: bắt đầu 0, 10-11 digits sau khi strip space/dash/dot +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) +const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s) + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -1062,6 +1075,10 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on paymentTermText: '', note: '', }) + const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' + const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' + const hasError = !!(phoneError || emailError) + const mut = useMutation({ mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form), onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, @@ -1075,7 +1092,7 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on title="Thêm NCC vào phiếu" footer={<> - + } >
@@ -1092,8 +1109,29 @@ function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; on
setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" />
setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" />
setForm({ ...form, contactName: e.target.value })} />
-
setForm({ ...form, contactPhone: e.target.value })} />
-
setForm({ ...form, contactEmail: e.target.value })} />
+
+ + setForm({ ...form, contactPhone: e.target.value })} + placeholder="0987654321" + className={phoneError ? 'border-red-300' : undefined} + /> + {phoneError &&

{phoneError}

} +
+
+ + setForm({ ...form, contactEmail: e.target.value })} + placeholder="name@example.com" + className={emailError ? 'border-red-300' : undefined} + /> + {emailError &&

{emailError}

} +
setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." />
@@ -1112,6 +1150,10 @@ function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: stri paymentTermText: row.paymentTermText ?? '', note: row.note ?? '', }) + const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' + const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' + const hasError = !!(phoneError || emailError) + const mut = useMutation({ mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form), onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() }, @@ -1124,15 +1166,36 @@ function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: stri title={`Sửa NCC — ${row.supplierName}`} footer={<> - + } >
setForm({ ...form, displayName: e.target.value })} />
setForm({ ...form, paymentTermText: e.target.value })} />
setForm({ ...form, contactName: e.target.value })} />
-
setForm({ ...form, contactPhone: e.target.value })} />
-
setForm({ ...form, contactEmail: e.target.value })} />
+
+ + setForm({ ...form, contactPhone: e.target.value })} + placeholder="0987654321" + className={phoneError ? 'border-red-300' : undefined} + /> + {phoneError &&

{phoneError}

} +
+
+ + setForm({ ...form, contactEmail: e.target.value })} + placeholder="name@example.com" + className={emailError ? 'border-red-300' : undefined} + /> + {emailError &&

{emailError}

} +
setForm({ ...form, note: e.target.value })} />
@@ -1331,7 +1394,8 @@ function HangMucCard({ NCC - Liên hệ + SĐT + Email Điều khoản TT File báo giá Số tiền @@ -1355,11 +1419,13 @@ function HangMucCard({ {s.displayName &&
{s.displayName}
} {s.note &&
{s.note}
} + + {s.contactPhone || } + - {s.contactName &&
{s.contactName}
} - {s.contactPhone &&
{s.contactPhone}
} - {s.contactEmail &&
{s.contactEmail}
} - {!s.contactName && !s.contactPhone && !s.contactEmail && } + {s.contactEmail + ? {s.contactEmail} + : } {s.paymentTermText ?? } @@ -1578,12 +1644,19 @@ function QuoteDialog({

Hạng mục: {itemName}

- setForm({ thanhTien: Number(e.target.value) })} - autoFocus - /> +
+ setForm({ thanhTien: parseVnd(e.target.value) })} + placeholder="0" + autoFocus + className="pr-12 font-mono text-right" + /> + đ +
+

VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)