[CLAUDE] Supplier: Mig 62 expand 30-field + seed 4 NCC + FE form 2-app
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m7s

Phase A of the NCC/TP Excel-import backlog (anh Kiet FDC). Supplier
entity 9 -> 27 fields to hold the 30-column supplier database.

Backend:
- Supplier +18 nullable cols; SupplierType +CaHai=6 (append-only);
  new nullable SupplierStatus enum.
- Mig 62 ExpandSupplierFields (3-file): AddColumn x18 + guarded un-cram
  Sql (clear legacy crammed Note on the 4 seeded rows, exact-match).
  No new table (89), reversible Down.
- Seed fill-nulls-if-exists: populate 30 fields for TRUONGGIANG/TANPHU/
  TGN/DONGDUONG from Excel; idempotent, never clobbers existing cols.
- DTO + Create/Update commands + validators (MaximumLength-only, no
  NotEmpty so open-auth Create minimal payload stays valid) + 2 query
  projections.

Frontend (fe-admin + fe-user, byte-identical SHA-mirror):
- types/master.ts +18 fields +SupplierStatus; SuppliersPage form 4
  section groups + Status select + 3 NAS links via new shared PathLink
  (extracted from PE HoSoLink render+Copy). All new fields optional.

Excel upload auto-map = Phase B (deferred). Tests test-after (UAT mode).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-06 16:06:11 +07:00
parent 6d217f77ed
commit 778fc98b2f
21 changed files with 7440 additions and 23 deletions

View File

@ -0,0 +1,68 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
// Đổi đường dẫn Windows → URL file:// (O:\DATA\x → file:///O:/DATA/x · \\srv\share → file://srv/share).
function toFileUrl(p: string): string {
const slashed = p.trim().replace(/\\/g, '/')
const url = slashed.startsWith('//') ? 'file:' + slashed : 'file:///' + slashed
return encodeURI(url)
}
// Link đường dẫn hồ sơ / NAS dùng chung (trích từ PeDetailTabs).
// - Link web (http/https, vd SharePoint) → bấm mở thẳng tab mới.
// - Đường dẫn ổ cứng/ổ mạng (O:\…, \\server) → render LINK file:// để BẤM-THỬ mở File
// Explorer (chạy nếu máy/trình duyệt cho phép: Edge bật policy IntranetFileLinksEnabled
// qua GPO + host trong Intranet Zone) + nút Copy dự phòng (default Chrome/Edge CHẶN
// https→file:// → bấm no-op → khi đó dùng Copy dán vào File Explorer, máy đã map ổ mạng
// là mở ngay).
export function PathLink({ path }: { path: string }) {
const [copied, setCopied] = useState(false)
const trimmed = path.trim()
if (/^https?:\/\//i.test(trimmed)) {
return (
<a
href={path}
target="_blank"
rel="noopener noreferrer"
className="break-all text-sm text-brand-600 hover:underline"
>
{path}
</a>
)
}
const copy = async () => {
try {
await navigator.clipboard.writeText(path)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error('Không copy được — vui lòng bôi đen đường dẫn rồi Ctrl+C.')
}
}
return (
<div className="flex max-w-2xl items-start gap-2">
<a
href={toFileUrl(path)}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 flex-1 break-all rounded bg-slate-50 px-2 py-1 text-[13px] text-brand-700 ring-1 ring-slate-200 transition hover:bg-brand-50 hover:underline"
title="Bấm mở trong File Explorer (cần Edge + IT bật policy). Không mở được thì bấm Copy rồi dán vào File Explorer."
>
{path}
</a>
<Button
type="button"
variant="outline"
onClick={copy}
className="h-8 shrink-0 px-2.5 text-xs"
title="Copy đường dẫn rồi dán vào File Explorer (This PC)"
>
{copied ? '✓ Đã copy' : 'Copy'}
</Button>
</div>
)
}

View File

@ -11,10 +11,17 @@ import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { PathLink } from '@/components/ui/PathLink'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { MenuKeys } from '@/lib/menuKeys'
import { SupplierType, SupplierTypeLabel, type Paged, type Supplier } from '@/types/master'
import {
SupplierStatusLabel,
SupplierType,
SupplierTypeLabel,
type Paged,
type Supplier,
} from '@/types/master'
type FormState = {
id?: string
@ -27,11 +34,34 @@ type FormState = {
address: string
contactPerson: string
note: string
packageCategory: string
officeAddress: string
mailingAddress: string
fax: string
bankAccount: string
secondaryBankAccount: string
legalRepresentative: string
legalRepTitle: string
authorizationNote: string
linkGuq: string
linkGpkd: string
linkHsnl: string
contactTitle: string
contactPhone: string
mailRecipient: string
referralSource: string
ownerPmh: string
status: string
}
const emptyForm: FormState = {
code: '', name: '', type: SupplierType.NhaCungCap,
taxCode: '', phone: '', email: '', address: '', contactPerson: '', note: '',
packageCategory: '', officeAddress: '', mailingAddress: '', fax: '',
bankAccount: '', secondaryBankAccount: '', legalRepresentative: '', legalRepTitle: '',
authorizationNote: '', linkGuq: '', linkGpkd: '', linkHsnl: '',
contactTitle: '', contactPhone: '', mailRecipient: '', referralSource: '', ownerPmh: '',
status: '',
}
export function SuppliersPage() {
@ -68,6 +98,24 @@ export function SuppliersPage() {
address: data.address || null,
contactPerson: data.contactPerson || null,
note: data.note || null,
packageCategory: data.packageCategory || null,
officeAddress: data.officeAddress || null,
mailingAddress: data.mailingAddress || null,
fax: data.fax || null,
bankAccount: data.bankAccount || null,
secondaryBankAccount: data.secondaryBankAccount || null,
legalRepresentative: data.legalRepresentative || null,
legalRepTitle: data.legalRepTitle || null,
authorizationNote: data.authorizationNote || null,
linkGuq: data.linkGuq || null,
linkGpkd: data.linkGpkd || null,
linkHsnl: data.linkHsnl || null,
contactTitle: data.contactTitle || null,
contactPhone: data.contactPhone || null,
mailRecipient: data.mailRecipient || null,
referralSource: data.referralSource || null,
ownerPmh: data.ownerPmh || null,
status: data.status ? Number(data.status) : null,
}
if (data.id) {
await api.put(`/suppliers/${data.id}`, payload)
@ -103,6 +151,15 @@ export function SuppliersPage() {
id: s.id, code: s.code, name: s.name, type: s.type,
taxCode: s.taxCode ?? '', phone: s.phone ?? '', email: s.email ?? '',
address: s.address ?? '', contactPerson: s.contactPerson ?? '', note: s.note ?? '',
packageCategory: s.packageCategory ?? '', officeAddress: s.officeAddress ?? '',
mailingAddress: s.mailingAddress ?? '', fax: s.fax ?? '',
bankAccount: s.bankAccount ?? '', secondaryBankAccount: s.secondaryBankAccount ?? '',
legalRepresentative: s.legalRepresentative ?? '', legalRepTitle: s.legalRepTitle ?? '',
authorizationNote: s.authorizationNote ?? '', linkGuq: s.linkGuq ?? '',
linkGpkd: s.linkGpkd ?? '', linkHsnl: s.linkHsnl ?? '',
contactTitle: s.contactTitle ?? '', contactPhone: s.contactPhone ?? '',
mailRecipient: s.mailRecipient ?? '', referralSource: s.referralSource ?? '',
ownerPmh: s.ownerPmh ?? '', status: s.status != null ? String(s.status) : '',
})
setOpen(true)
}
@ -246,6 +303,114 @@ export function SuppliersPage() {
<Label>Ghi chú</Label>
<Textarea rows={3} value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} />
</div>
<div className="col-span-2 mt-2 border-t pt-3 text-sm font-semibold text-slate-600">
Đnh danh
</div>
<div className="col-span-2 space-y-1.5">
<Label>Gói thầu</Label>
<Input value={form.packageCategory} onChange={e => setForm({ ...form, packageCategory: e.target.value })} />
</div>
<div className="col-span-2 mt-2 border-t pt-3 text-sm font-semibold text-slate-600">
Pháp &amp; Hợp đng
</div>
<div className="col-span-2 space-y-1.5">
<Label>Đa chỉ văn phòng</Label>
<Input value={form.officeAddress} onChange={e => setForm({ ...form, officeAddress: e.target.value })} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Đa chỉ gửi thư</Label>
<Input value={form.mailingAddress} onChange={e => setForm({ ...form, mailingAddress: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Fax</Label>
<Input value={form.fax} onChange={e => setForm({ ...form, fax: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Người đi diện pháp luật</Label>
<Input
value={form.legalRepresentative}
onChange={e => setForm({ ...form, legalRepresentative: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Chức vụ đi diện</Label>
<Input value={form.legalRepTitle} onChange={e => setForm({ ...form, legalRepTitle: e.target.value })} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Số tài khoản ngân hàng</Label>
<Input value={form.bankAccount} onChange={e => setForm({ ...form, bankAccount: e.target.value })} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Số tài khoản phụ</Label>
<Input
value={form.secondaryBankAccount}
onChange={e => setForm({ ...form, secondaryBankAccount: e.target.value })}
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Giấy y quyền (ghi chú)</Label>
<Textarea
rows={2}
value={form.authorizationNote}
onChange={e => setForm({ ...form, authorizationNote: e.target.value })}
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Link GUQ</Label>
<Input value={form.linkGuq} onChange={e => setForm({ ...form, linkGuq: e.target.value })} />
{form.linkGuq.trim() && <PathLink path={form.linkGuq} />}
</div>
<div className="col-span-2 space-y-1.5">
<Label>Link GPKD</Label>
<Input value={form.linkGpkd} onChange={e => setForm({ ...form, linkGpkd: e.target.value })} />
{form.linkGpkd.trim() && <PathLink path={form.linkGpkd} />}
</div>
<div className="col-span-2 space-y-1.5">
<Label>Link HSNL</Label>
<Input value={form.linkHsnl} onChange={e => setForm({ ...form, linkHsnl: e.target.value })} />
{form.linkHsnl.trim() && <PathLink path={form.linkHsnl} />}
</div>
<div className="col-span-2 mt-2 border-t pt-3 text-sm font-semibold text-slate-600">
Liên hệ
</div>
<div className="space-y-1.5">
<Label>Chức vụ người liên hệ</Label>
<Input value={form.contactTitle} onChange={e => setForm({ ...form, contactTitle: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>SĐT người liên hệ</Label>
<Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Người nhận thư / SĐT</Label>
<Input value={form.mailRecipient} onChange={e => setForm({ ...form, mailRecipient: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Nguồn giới thiệu</Label>
<Input value={form.referralSource} onChange={e => setForm({ ...form, referralSource: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Người phụ trách (PMH)</Label>
<Input value={form.ownerPmh} onChange={e => setForm({ ...form, ownerPmh: e.target.value })} />
</div>
<div className="col-span-2 mt-2 border-t pt-3 text-sm font-semibold text-slate-600">
Tình trạng
</div>
<div className="space-y-1.5">
<Label>Tình trạng hiện tại</Label>
<Select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
<option value=""> Chưa phân loại </option>
{Object.entries(SupplierStatusLabel).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
</form>
</Dialog>
</div>

View File

@ -14,6 +14,7 @@ export const SupplierType = {
ToDoi: 3,
DonViDichVu: 4,
ChuDauTu: 5,
CaHai: 6,
} as const
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
@ -24,6 +25,21 @@ export const SupplierTypeLabel: Record<SupplierType, string> = {
3: 'Tổ đội',
4: 'Đơn vị dịch vụ',
5: 'Chủ đầu tư',
6: 'Cả hai (NCC & Thầu phụ)',
}
export const SupplierStatus = {
DangHoatDong: 1,
Blacklist: 2,
NgungHopTac: 3,
} as const
export type SupplierStatus = typeof SupplierStatus[keyof typeof SupplierStatus]
export const SupplierStatusLabel: Record<SupplierStatus, string> = {
1: 'Đang hoạt động',
2: 'Blacklist',
3: 'Ngừng hợp tác',
}
export type Supplier = {
@ -37,6 +53,28 @@ export type Supplier = {
address: string | null
contactPerson: string | null
note: string | null
// Định danh
packageCategory: string | null
// Pháp lý & Hợp đồng
officeAddress: string | null
mailingAddress: string | null
fax: string | null
bankAccount: string | null
secondaryBankAccount: string | null
legalRepresentative: string | null
legalRepTitle: string | null
authorizationNote: string | null
linkGuq: string | null
linkGpkd: string | null
linkHsnl: string | null
// Liên hệ
contactTitle: string | null
contactPhone: string | null
mailRecipient: string | null
referralSource: string | null
ownerPmh: string | null
// Tình trạng
status: SupplierStatus | null
createdAt: string
updatedAt: string | null
}