[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
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:
68
fe-user/src/components/ui/PathLink.tsx
Normal file
68
fe-user/src/components/ui/PathLink.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 lý & 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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user