[CLAUDE] FE-Admin+FE-User: S35 Phase 1.5 Item 3-FE — inline forms 5 satellite cookie-cutter
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m51s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m51s
Pattern 12-ter (within-module N-satellite) × Pattern 16-bis (cross-app mirror) cumulative
6×. FE inline expandable form Add/Edit/Delete cho 5 satellite EmployeeProfile (WorkHistory
+ Education + FamilyRelation + Skill + Document) — close Phase 1.5 backlog Item 3 FE side.
## Scope (Implementer Case 2 ~30K spawn)
- Section component extend optional `actions?` prop (backward-compat 100%)
- 5 inline form components per app: WorkHistory/Education/FamilyRelation/Skill/Document
- 3 DRY helpers: FormField + FormFooter + RowActions (45 usage site)
- DRY helper `invalidate()` line 268 — 15 mutations × 1 shared = clean
- 5 Input types append types/employee.ts (CreateEmployeeWorkHistoryInput + 4 more)
- 15 useMutation per app (5 sat × 3 verb) = 30 total cross-app
- Wire BE 15 endpoint S34 ready: POST/PUT/DELETE /api/employees/{id}/{satellite-path}
- gotcha #44 mitigation ACTIVE: per-action policy Hrm_HoSo.{Create|Update|Delete}
## Stats
- fe-admin EmployeesListPage.tsx 573 → 1200 LOC (+627, +110%)
- fe-user EmployeesListPage.tsx mirror SHA256 IDENTICAL `802d01fd1ee79925`
- 2 types file +53 LOC each, SHA256 IDENTICAL `db29156a61af76e9`
- npm build × 2 PASS (fe-admin 31.57s + fe-user 23.39s, 0 TS error)
- BE files NOT touched (15 endpoint scaffold S34 ready)
- Test gate 130 PASS baseline preserve (FE-only)
## Multi-agent ROI S35 ~70K
- Implementer Case 2 ~30K (FE cookie-cutter scaffold)
- Investigator ~8K (G-H2 BE CRUD pre-flight, defer Plan G-H2 next chunk)
- Reviewer pre-commit ~15K Cat 1 wire BE PERFECT + em main verify Cat 2-6 PASS
- CICD warm-up ~15K (Run #241 baseline verified + standby S35 push)
Smart Friend 7× clean cumulative S22+S25+S29×2+S33×2+S35.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,17 +1,15 @@
|
|||||||
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
|
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
|
||||||
// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5):
|
// Phase 10.1 G-H1 Phase 1.5 (S35 Plan B-WrapPlus1) — inline forms 5 satellite cookie-cutter.
|
||||||
// - Read-only mọi section (Edit Header defer Phase 1.5)
|
// Pattern 12-ter × 16-bis 6th reinforcement (S35). 5 inline forms × 2 app mirror SHA256 IDENTICAL.
|
||||||
// - 6 section render inline trong right panel qua `<details>` HTML native
|
|
||||||
// - NO separate DetailTabs component, NO satellite CRUD form
|
|
||||||
// Pattern 16-bis 4-place mirror cross-app (4th reinforcement S33).
|
|
||||||
// URL params: id (selected), q (search), status, deptId
|
// URL params: id (selected), q (search), status, deptId
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { UserCircle2, Search, Plus, X } from 'lucide-react'
|
import { UserCircle2, Search, Plus, X, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -33,6 +31,16 @@ import {
|
|||||||
EmployeeDocumentTypeLabel,
|
EmployeeDocumentTypeLabel,
|
||||||
type EmployeeListItem,
|
type EmployeeListItem,
|
||||||
type EmployeeDetail,
|
type EmployeeDetail,
|
||||||
|
type EmployeeWorkHistoryDto,
|
||||||
|
type EmployeeEducationDto,
|
||||||
|
type EmployeeFamilyRelationDto,
|
||||||
|
type EmployeeSkillDto,
|
||||||
|
type EmployeeDocumentDto,
|
||||||
|
type CreateEmployeeWorkHistoryInput,
|
||||||
|
type CreateEmployeeEducationInput,
|
||||||
|
type CreateEmployeeFamilyRelationInput,
|
||||||
|
type CreateEmployeeSkillInput,
|
||||||
|
type CreateEmployeeDocumentInput,
|
||||||
} from '@/types/employee'
|
} from '@/types/employee'
|
||||||
|
|
||||||
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
|
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
|
||||||
@ -239,9 +247,121 @@ export function EmployeesListPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Inline 6-section read-only detail ==========
|
// ========== Inline 6-section detail with CRUD for satellites ==========
|
||||||
|
|
||||||
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
|
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const employeeId = detail.id
|
||||||
|
|
||||||
|
// State for inline add/edit forms (5 satellite)
|
||||||
|
const [addingWorkHistory, setAddingWorkHistory] = useState(false)
|
||||||
|
const [editingWorkHistoryId, setEditingWorkHistoryId] = useState<string | null>(null)
|
||||||
|
const [addingEducation, setAddingEducation] = useState(false)
|
||||||
|
const [editingEducationId, setEditingEducationId] = useState<string | null>(null)
|
||||||
|
const [addingFamilyRelation, setAddingFamilyRelation] = useState(false)
|
||||||
|
const [editingFamilyRelationId, setEditingFamilyRelationId] = useState<string | null>(null)
|
||||||
|
const [addingSkill, setAddingSkill] = useState(false)
|
||||||
|
const [editingSkillId, setEditingSkillId] = useState<string | null>(null)
|
||||||
|
const [addingDocument, setAddingDocument] = useState(false)
|
||||||
|
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['employee-detail', employeeId] })
|
||||||
|
|
||||||
|
// ===== WorkHistory mutations =====
|
||||||
|
const createWorkHistory = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeWorkHistoryInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/work-history`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm quá trình công tác.'); invalidate(); setAddingWorkHistory(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateWorkHistory = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeWorkHistoryInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/work-history/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật quá trình công tác.'); invalidate(); setEditingWorkHistoryId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteWorkHistory = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/work-history/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá quá trình công tác.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Education mutations =====
|
||||||
|
const createEducation = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeEducationInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/education`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm quá trình đào tạo.'); invalidate(); setAddingEducation(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateEducation = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeEducationInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/education/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật quá trình đào tạo.'); invalidate(); setEditingEducationId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteEducation = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/education/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá quá trình đào tạo.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== FamilyRelation mutations =====
|
||||||
|
const createFamilyRelation = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeFamilyRelationInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/family-relations`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm thân nhân.'); invalidate(); setAddingFamilyRelation(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateFamilyRelation = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeFamilyRelationInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/family-relations/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật thân nhân.'); invalidate(); setEditingFamilyRelationId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteFamilyRelation = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/family-relations/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá thân nhân.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Skill mutations =====
|
||||||
|
const createSkill = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeSkillInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/skills`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm kỹ năng.'); invalidate(); setAddingSkill(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateSkill = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeSkillInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/skills/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật kỹ năng.'); invalidate(); setEditingSkillId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteSkill = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/skills/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá kỹ năng.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Document mutations =====
|
||||||
|
const createDocument = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeDocumentInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/documents`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm hồ sơ.'); invalidate(); setAddingDocument(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateDocument = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeDocumentInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/documents/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật hồ sơ.'); invalidate(); setEditingDocumentId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteDocument = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/documents/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá hồ sơ.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
@ -376,13 +496,36 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 2: Công tác */}
|
{/* Section 2: Công tác */}
|
||||||
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}>
|
<Section
|
||||||
{detail.workHistories.length === 0 ? (
|
title={`2. Quá trình công tác (${detail.workHistories.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingWorkHistory(true); setEditingWorkHistoryId(null) }} disabled={addingWorkHistory}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingWorkHistory && (
|
||||||
|
<WorkHistoryForm
|
||||||
|
onSave={p => createWorkHistory.mutate(p)}
|
||||||
|
onCancel={() => setAddingWorkHistory(false)}
|
||||||
|
isPending={createWorkHistory.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.workHistories.length === 0 && !addingWorkHistory ? (
|
||||||
<EmptyHint text="Chưa có quá trình công tác nào." />
|
<EmptyHint text="Chưa có quá trình công tác nào." />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{detail.workHistories.map(w => (
|
{detail.workHistories.map(w => (
|
||||||
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
|
editingWorkHistoryId === w.id ? (
|
||||||
|
<WorkHistoryForm
|
||||||
|
key={w.id}
|
||||||
|
initial={w}
|
||||||
|
onSave={p => updateWorkHistory.mutate({ satId: w.id, payload: p })}
|
||||||
|
onCancel={() => setEditingWorkHistoryId(null)}
|
||||||
|
isPending={updateWorkHistory.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={w.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="font-medium text-slate-900">{w.companyName}</div>
|
<div className="font-medium text-slate-900">{w.companyName}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
@ -394,20 +537,48 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
{w.companyAddress && <div className="text-xs text-slate-500">Địa chỉ: {w.companyAddress}</div>}
|
{w.companyAddress && <div className="text-xs text-slate-500">Địa chỉ: {w.companyAddress}</div>}
|
||||||
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
|
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
|
||||||
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500">Lý do nghỉ: {w.resignReason}</div>}
|
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500">Lý do nghỉ: {w.resignReason}</div>}
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingWorkHistoryId(w.id); setAddingWorkHistory(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá quá trình công tác tại "${w.companyName}"?`)) deleteWorkHistory.mutate(w.id) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 3: Đào tạo */}
|
{/* Section 3: Đào tạo */}
|
||||||
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}>
|
<Section
|
||||||
{detail.educations.length === 0 ? (
|
title={`3. Quá trình đào tạo (${detail.educations.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingEducation(true); setEditingEducationId(null) }} disabled={addingEducation}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingEducation && (
|
||||||
|
<EducationForm
|
||||||
|
onSave={p => createEducation.mutate(p)}
|
||||||
|
onCancel={() => setAddingEducation(false)}
|
||||||
|
isPending={createEducation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.educations.length === 0 && !addingEducation ? (
|
||||||
<EmptyHint text="Chưa có quá trình đào tạo nào." />
|
<EmptyHint text="Chưa có quá trình đào tạo nào." />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{detail.educations.map(ed => (
|
{detail.educations.map(ed => (
|
||||||
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
|
editingEducationId === ed.id ? (
|
||||||
|
<EducationForm
|
||||||
|
key={ed.id}
|
||||||
|
initial={ed}
|
||||||
|
onSave={p => updateEducation.mutate({ satId: ed.id, payload: p })}
|
||||||
|
onCancel={() => setEditingEducationId(null)}
|
||||||
|
isPending={updateEducation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={ed.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="font-medium text-slate-900">{ed.schoolName}</div>
|
<div className="font-medium text-slate-900">{ed.schoolName}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
@ -422,45 +593,81 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</div>
|
</div>
|
||||||
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
|
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
|
||||||
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
|
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingEducationId(ed.id); setAddingEducation(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá quá trình đào tạo tại "${ed.schoolName}"?`)) deleteEducation.mutate(ed.id) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 4: Thân nhân */}
|
{/* Section 4: Thân nhân */}
|
||||||
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}>
|
<Section
|
||||||
{detail.familyRelations.length === 0 ? (
|
title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingFamilyRelation(true); setEditingFamilyRelationId(null) }} disabled={addingFamilyRelation}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingFamilyRelation && (
|
||||||
|
<FamilyRelationForm
|
||||||
|
onSave={p => createFamilyRelation.mutate(p)}
|
||||||
|
onCancel={() => setAddingFamilyRelation(false)}
|
||||||
|
isPending={createFamilyRelation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.familyRelations.length === 0 && !addingFamilyRelation ? (
|
||||||
<EmptyHint text="Chưa có thông tin thân nhân." />
|
<EmptyHint text="Chưa có thông tin thân nhân." />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<div className="space-y-2">
|
||||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Họ tên</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Quan hệ</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Năm sinh</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Nghề nghiệp</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">SĐT</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{detail.familyRelations.map(f => (
|
{detail.familyRelations.map(f => (
|
||||||
<tr key={f.id} className="border-b border-slate-100">
|
editingFamilyRelationId === f.id ? (
|
||||||
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td>
|
<FamilyRelationForm
|
||||||
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td>
|
key={f.id}
|
||||||
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td>
|
initial={f}
|
||||||
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td>
|
onSave={p => updateFamilyRelation.mutate({ satId: f.id, payload: p })}
|
||||||
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td>
|
onCancel={() => setEditingFamilyRelationId(null)}
|
||||||
</tr>
|
isPending={updateFamilyRelation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={f.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-5">
|
||||||
|
<div><span className="text-xs text-slate-500">Họ tên: </span><span className="font-medium text-slate-800">{f.fullName}</span></div>
|
||||||
|
<div><span className="text-xs text-slate-500">Quan hệ: </span>{FamilyRelationKindLabel[f.relationship]}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Năm sinh: </span>{f.birthYear ?? '—'}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Nghề: </span>{f.occupation ?? '—'}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">SĐT: </span>{f.phone ?? '—'}</div>
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingFamilyRelationId(f.id); setAddingFamilyRelation(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá thân nhân "${f.fullName}"?`)) deleteFamilyRelation.mutate(f.id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 5: Kỹ năng */}
|
{/* Section 5: Kỹ năng */}
|
||||||
<Section title={`5. Kỹ năng (${detail.skills.length})`}>
|
<Section
|
||||||
{detail.skills.length === 0 ? (
|
title={`5. Kỹ năng (${detail.skills.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingSkill && (
|
||||||
|
<SkillForm
|
||||||
|
onSave={p => createSkill.mutate(p)}
|
||||||
|
onCancel={() => setAddingSkill(false)}
|
||||||
|
isPending={createSkill.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.skills.length === 0 && !addingSkill ? (
|
||||||
<EmptyHint text="Chưa có thông tin kỹ năng." />
|
<EmptyHint text="Chưa có thông tin kỹ năng." />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -474,13 +681,27 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{group.map(s => (
|
{group.map(s => (
|
||||||
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm">
|
editingSkillId === s.id ? (
|
||||||
|
<SkillForm
|
||||||
|
key={s.id}
|
||||||
|
initial={s}
|
||||||
|
onSave={p => updateSkill.mutate({ satId: s.id, payload: p })}
|
||||||
|
onCancel={() => setEditingSkillId(null)}
|
||||||
|
isPending={updateSkill.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<li key={s.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 pr-16 text-sm">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<span className="font-medium text-slate-800">{s.name}</span>
|
<span className="font-medium text-slate-800">{s.name}</span>
|
||||||
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
|
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
|
||||||
</div>
|
</div>
|
||||||
{s.languageId && <div className="text-xs text-slate-500">Mã: {s.languageId}</div>}
|
{s.languageId && <div className="text-xs text-slate-500">Mã: {s.languageId}</div>}
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingSkillId(s.id); setAddingSkill(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá kỹ năng "${s.name}"?`)) deleteSkill.mutate(s.id) }}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -491,34 +712,51 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 6: Hồ sơ */}
|
{/* Section 6: Hồ sơ */}
|
||||||
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}>
|
<Section
|
||||||
{detail.documents.length === 0 ? (
|
title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingDocument(true); setEditingDocumentId(null) }} disabled={addingDocument}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingDocument && (
|
||||||
|
<DocumentForm
|
||||||
|
onSave={p => createDocument.mutate(p)}
|
||||||
|
onCancel={() => setAddingDocument(false)}
|
||||||
|
isPending={createDocument.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.documents.length === 0 && !addingDocument ? (
|
||||||
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
|
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<div className="space-y-2">
|
||||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Loại</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Tên file</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Ngày cấp</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Ngày hết hạn</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{detail.documents.map(doc => (
|
{detail.documents.map(doc => (
|
||||||
<tr key={doc.id} className="border-b border-slate-100">
|
editingDocumentId === doc.id ? (
|
||||||
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td>
|
<DocumentForm
|
||||||
<td className="py-2 pr-3">
|
key={doc.id}
|
||||||
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">
|
initial={doc}
|
||||||
{doc.fileName}
|
onSave={p => updateDocument.mutate({ satId: doc.id, payload: p })}
|
||||||
</a>
|
onCancel={() => setEditingDocumentId(null)}
|
||||||
</td>
|
isPending={updateDocument.isPending}
|
||||||
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.issueDate)}</td>
|
/>
|
||||||
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.expiryDate)}</td>
|
) : (
|
||||||
</tr>
|
<div key={doc.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-4">
|
||||||
|
<div><span className="text-xs text-slate-500">Loại: </span>{EmployeeDocumentTypeLabel[doc.documentType]}</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-500">Tên file: </span>
|
||||||
|
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">{doc.fileName}</a>
|
||||||
|
</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Ngày cấp: </span>{fmtDate(doc.issueDate)}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Hết hạn: </span>{fmtDate(doc.expiryDate)}</div>
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingDocumentId(doc.id); setAddingDocument(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá hồ sơ "${doc.fileName}"?`)) deleteDocument.mutate(doc.id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@ -530,14 +768,403 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Helpers ==========
|
// ========== Inline forms for 5 satellite (cookie-cutter pattern 12-ter) ==========
|
||||||
|
|
||||||
function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
const nullable = (s: string) => s.trim() || null
|
||||||
|
const nullableNumber = (s: string): number | null => {
|
||||||
|
const n = Number(s)
|
||||||
|
return s.trim() === '' || isNaN(n) ? null : n
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkHistoryForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeWorkHistoryDto
|
||||||
|
onSave: (p: CreateEmployeeWorkHistoryInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
companyName: initial?.companyName ?? '',
|
||||||
|
companyAddress: initial?.companyAddress ?? '',
|
||||||
|
industry: initial?.industry ?? '',
|
||||||
|
fromDate: initial?.fromDate ?? '',
|
||||||
|
toDate: initial?.toDate ?? '',
|
||||||
|
jobTitle: initial?.jobTitle ?? '',
|
||||||
|
jobDescription: initial?.jobDescription ?? '',
|
||||||
|
resignReason: initial?.resignReason ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
companyName: form.companyName.trim(),
|
||||||
|
companyAddress: nullable(form.companyAddress),
|
||||||
|
industry: nullable(form.industry),
|
||||||
|
fromDate: form.fromDate || null,
|
||||||
|
toDate: form.toDate || null,
|
||||||
|
jobTitle: nullable(form.jobTitle),
|
||||||
|
jobDescription: nullable(form.jobDescription),
|
||||||
|
resignReason: nullable(form.resignReason),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Tên công ty *">
|
||||||
|
<Input value={form.companyName} onChange={e => setForm({ ...form, companyName: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Địa chỉ công ty">
|
||||||
|
<Input value={form.companyAddress} onChange={e => setForm({ ...form, companyAddress: e.target.value })} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngành nghề">
|
||||||
|
<Input value={form.industry} onChange={e => setForm({ ...form, industry: e.target.value })} maxLength={100} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Chức vụ">
|
||||||
|
<Input value={form.jobTitle} onChange={e => setForm({ ...form, jobTitle: e.target.value })} maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Từ ngày">
|
||||||
|
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Đến ngày">
|
||||||
|
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormField label="Mô tả công việc">
|
||||||
|
<Textarea value={form.jobDescription} onChange={e => setForm({ ...form, jobDescription: e.target.value })} maxLength={2000} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Lý do nghỉ">
|
||||||
|
<Input value={form.resignReason} onChange={e => setForm({ ...form, resignReason: e.target.value })} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EducationForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeEducationDto
|
||||||
|
onSave: (p: CreateEmployeeEducationInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
schoolName: initial?.schoolName ?? '',
|
||||||
|
major: initial?.major ?? '',
|
||||||
|
degreeLevel: initial?.degreeLevel != null ? String(initial.degreeLevel) : '',
|
||||||
|
educationMode: initial?.educationMode != null ? String(initial.educationMode) : '',
|
||||||
|
gradeLevel: initial?.gradeLevel != null ? String(initial.gradeLevel) : '',
|
||||||
|
fromDate: initial?.fromDate ?? '',
|
||||||
|
toDate: initial?.toDate ?? '',
|
||||||
|
certificateIssueDate: initial?.certificateIssueDate ?? '',
|
||||||
|
notes: initial?.notes ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
schoolName: form.schoolName.trim(),
|
||||||
|
major: nullable(form.major),
|
||||||
|
degreeLevel: nullableNumber(form.degreeLevel),
|
||||||
|
educationMode: nullableNumber(form.educationMode),
|
||||||
|
gradeLevel: nullableNumber(form.gradeLevel),
|
||||||
|
fromDate: form.fromDate || null,
|
||||||
|
toDate: form.toDate || null,
|
||||||
|
certificateIssueDate: form.certificateIssueDate || null,
|
||||||
|
notes: nullable(form.notes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Tên trường *">
|
||||||
|
<Input value={form.schoolName} onChange={e => setForm({ ...form, schoolName: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Chuyên ngành">
|
||||||
|
<Input value={form.major} onChange={e => setForm({ ...form, major: e.target.value })} maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Bằng cấp">
|
||||||
|
<Select value={form.degreeLevel} onChange={e => setForm({ ...form, degreeLevel: e.target.value })}>
|
||||||
|
<option value="">— Chọn —</option>
|
||||||
|
<option value="1">Cao đẳng</option>
|
||||||
|
<option value="2">Đại học</option>
|
||||||
|
<option value="3">Thạc sĩ</option>
|
||||||
|
<option value="4">Tiến sĩ</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Hình thức">
|
||||||
|
<Select value={form.educationMode} onChange={e => setForm({ ...form, educationMode: e.target.value })}>
|
||||||
|
<option value="">— Chọn —</option>
|
||||||
|
<option value="1">Chính quy</option>
|
||||||
|
<option value="2">Tại chức</option>
|
||||||
|
<option value="3">Từ xa</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Xếp loại">
|
||||||
|
<Select value={form.gradeLevel} onChange={e => setForm({ ...form, gradeLevel: e.target.value })}>
|
||||||
|
<option value="">— Chọn —</option>
|
||||||
|
<option value="1">Trung bình</option>
|
||||||
|
<option value="2">Khá</option>
|
||||||
|
<option value="3">Giỏi</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngày cấp bằng">
|
||||||
|
<Input type="date" value={form.certificateIssueDate} onChange={e => setForm({ ...form, certificateIssueDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Từ ngày">
|
||||||
|
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Đến ngày">
|
||||||
|
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormField label="Ghi chú">
|
||||||
|
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FamilyRelationForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeFamilyRelationDto
|
||||||
|
onSave: (p: CreateEmployeeFamilyRelationInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
fullName: initial?.fullName ?? '',
|
||||||
|
relationship: initial?.relationship != null ? String(initial.relationship) : '1',
|
||||||
|
birthYear: initial?.birthYear != null ? String(initial.birthYear) : '',
|
||||||
|
occupation: initial?.occupation ?? '',
|
||||||
|
currentAddress: initial?.currentAddress ?? '',
|
||||||
|
phone: initial?.phone ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
fullName: form.fullName.trim(),
|
||||||
|
relationship: Number(form.relationship),
|
||||||
|
birthYear: nullableNumber(form.birthYear),
|
||||||
|
occupation: nullable(form.occupation),
|
||||||
|
currentAddress: nullable(form.currentAddress),
|
||||||
|
phone: nullable(form.phone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Họ tên *">
|
||||||
|
<Input value={form.fullName} onChange={e => setForm({ ...form, fullName: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Quan hệ *">
|
||||||
|
<Select value={form.relationship} onChange={e => setForm({ ...form, relationship: e.target.value })} required>
|
||||||
|
<option value="1">Cha</option>
|
||||||
|
<option value="2">Mẹ</option>
|
||||||
|
<option value="3">Vợ/Chồng</option>
|
||||||
|
<option value="4">Con</option>
|
||||||
|
<option value="5">Anh/Chị/Em ruột</option>
|
||||||
|
<option value="99">Khác</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Năm sinh">
|
||||||
|
<Input type="number" min={1900} max={2026} value={form.birthYear} onChange={e => setForm({ ...form, birthYear: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Nghề nghiệp">
|
||||||
|
<Input value={form.occupation} onChange={e => setForm({ ...form, occupation: e.target.value })} maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="SĐT">
|
||||||
|
<Input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} maxLength={20} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Địa chỉ hiện tại">
|
||||||
|
<Input value={form.currentAddress} onChange={e => setForm({ ...form, currentAddress: e.target.value })} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeSkillDto
|
||||||
|
onSave: (p: CreateEmployeeSkillInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
kind: initial?.kind != null ? String(initial.kind) : '1',
|
||||||
|
name: initial?.name ?? '',
|
||||||
|
languageId: initial?.languageId ?? '',
|
||||||
|
level: initial?.level ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
kind: Number(form.kind),
|
||||||
|
name: form.name.trim(),
|
||||||
|
languageId: nullable(form.languageId),
|
||||||
|
level: nullable(form.level),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Loại *">
|
||||||
|
<Select value={form.kind} onChange={e => setForm({ ...form, kind: e.target.value })} required>
|
||||||
|
<option value="1">Kỹ năng vi tính</option>
|
||||||
|
<option value="2">Ngoại ngữ</option>
|
||||||
|
<option value="3">Kỹ năng khác</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Tên kỹ năng *">
|
||||||
|
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Mã ngôn ngữ">
|
||||||
|
<Input value={form.languageId} onChange={e => setForm({ ...form, languageId: e.target.value })} maxLength={10} placeholder="vi/en/ja..." />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Trình độ">
|
||||||
|
<Input value={form.level} onChange={e => setForm({ ...form, level: e.target.value })} maxLength={200} placeholder="B1/IELTS 6.5/N2..." />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeDocumentDto
|
||||||
|
onSave: (p: CreateEmployeeDocumentInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
documentType: initial?.documentType != null ? String(initial.documentType) : '1',
|
||||||
|
fileName: initial?.fileName ?? '',
|
||||||
|
filePath: initial?.filePath ?? '',
|
||||||
|
fileSize: initial?.fileSize != null ? String(initial.fileSize) : '0',
|
||||||
|
contentType: initial?.contentType ?? 'application/octet-stream',
|
||||||
|
issueDate: initial?.issueDate ?? '',
|
||||||
|
expiryDate: initial?.expiryDate ?? '',
|
||||||
|
notes: initial?.notes ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
documentType: Number(form.documentType),
|
||||||
|
fileName: form.fileName.trim(),
|
||||||
|
filePath: form.filePath.trim(),
|
||||||
|
fileSize: Number(form.fileSize) || 0,
|
||||||
|
contentType: form.contentType.trim(),
|
||||||
|
issueDate: form.issueDate || null,
|
||||||
|
expiryDate: form.expiryDate || null,
|
||||||
|
notes: nullable(form.notes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Loại hồ sơ *">
|
||||||
|
<Select value={form.documentType} onChange={e => setForm({ ...form, documentType: e.target.value })} required>
|
||||||
|
<option value="1">CMND/CCCD</option>
|
||||||
|
<option value="2">Hộ chiếu</option>
|
||||||
|
<option value="3">Bằng cấp</option>
|
||||||
|
<option value="4">Chứng chỉ</option>
|
||||||
|
<option value="5">HĐLĐ</option>
|
||||||
|
<option value="99">Khác</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Tên file *">
|
||||||
|
<Input value={form.fileName} onChange={e => setForm({ ...form, fileName: e.target.value })} required maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Đường dẫn file *">
|
||||||
|
<Input value={form.filePath} onChange={e => setForm({ ...form, filePath: e.target.value })} required maxLength={1000} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Content-Type *">
|
||||||
|
<Input value={form.contentType} onChange={e => setForm({ ...form, contentType: e.target.value })} required maxLength={100} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Kích thước (bytes)">
|
||||||
|
<Input type="number" min={0} value={form.fileSize} onChange={e => setForm({ ...form, fileSize: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngày cấp">
|
||||||
|
<Input type="date" value={form.issueDate} onChange={e => setForm({ ...form, issueDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngày hết hạn">
|
||||||
|
<Input type="date" value={form.expiryDate} onChange={e => setForm({ ...form, expiryDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormField label="Ghi chú">
|
||||||
|
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Form helpers ==========
|
||||||
|
|
||||||
|
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-700">{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFooter({ onCancel, isPending, isEdit }: { onCancel: () => void; isPending: boolean; isEdit: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2 border-t border-brand-100 pt-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>Huỷ</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={isPending}>
|
||||||
|
{isPending ? 'Đang lưu...' : (isEdit ? 'Cập nhật' : 'Thêm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="absolute right-2 top-2 flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="rounded p-1 text-slate-400 transition hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
title="Sửa"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="rounded p-1 text-slate-400 transition hover:bg-red-50 hover:text-red-600"
|
||||||
|
title="Xoá"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Layout helpers (read-only) ==========
|
||||||
|
|
||||||
|
function Section({ title, children, defaultOpen, actions }: { title: string; children: React.ReactNode; defaultOpen?: boolean; actions?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
|
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
|
||||||
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
|
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
|
||||||
<span>{title}</span>
|
<span className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform">▶</span>
|
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform">▶</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
</span>
|
||||||
|
{actions && (
|
||||||
|
<span onClick={e => e.stopPropagation()}>
|
||||||
|
{actions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
|
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@ -232,6 +232,60 @@ export type EmployeeDocumentDto = {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Satellite Create/Update Input Commands (S35 Plan B-WrapPlus1) ==========
|
||||||
|
// Mirror BE record Command field shape (EmployeeSatelliteFeatures.cs). UpdateInput
|
||||||
|
// reuse CreateInput shape (DRY) — handler set `id` separately at API call site.
|
||||||
|
|
||||||
|
export type CreateEmployeeWorkHistoryInput = {
|
||||||
|
companyName: string
|
||||||
|
companyAddress: string | null
|
||||||
|
industry: string | null
|
||||||
|
fromDate: string | null
|
||||||
|
toDate: string | null
|
||||||
|
jobTitle: string | null
|
||||||
|
jobDescription: string | null
|
||||||
|
resignReason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeEducationInput = {
|
||||||
|
schoolName: string
|
||||||
|
major: string | null
|
||||||
|
degreeLevel: number | null
|
||||||
|
educationMode: number | null
|
||||||
|
gradeLevel: number | null
|
||||||
|
fromDate: string | null
|
||||||
|
toDate: string | null
|
||||||
|
certificateIssueDate: string | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeFamilyRelationInput = {
|
||||||
|
fullName: string
|
||||||
|
relationship: number
|
||||||
|
birthYear: number | null
|
||||||
|
occupation: string | null
|
||||||
|
currentAddress: string | null
|
||||||
|
phone: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeSkillInput = {
|
||||||
|
kind: number
|
||||||
|
name: string
|
||||||
|
languageId: string | null
|
||||||
|
level: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeDocumentInput = {
|
||||||
|
documentType: number
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
contentType: string
|
||||||
|
issueDate: string | null
|
||||||
|
expiryDate: string | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Detail (full + 5 satellite collection) ==========
|
// ========== Detail (full + 5 satellite collection) ==========
|
||||||
|
|
||||||
export type EmployeeDetail = {
|
export type EmployeeDetail = {
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
|
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
|
||||||
// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5):
|
// Phase 10.1 G-H1 Phase 1.5 (S35 Plan B-WrapPlus1) — inline forms 5 satellite cookie-cutter.
|
||||||
// - Read-only mọi section (Edit Header defer Phase 1.5)
|
// Pattern 12-ter × 16-bis 6th reinforcement (S35). 5 inline forms × 2 app mirror SHA256 IDENTICAL.
|
||||||
// - 6 section render inline trong right panel qua `<details>` HTML native
|
|
||||||
// - NO separate DetailTabs component, NO satellite CRUD form
|
|
||||||
// Pattern 16-bis 4-place mirror cross-app (4th reinforcement S33).
|
|
||||||
// URL params: id (selected), q (search), status, deptId
|
// URL params: id (selected), q (search), status, deptId
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { UserCircle2, Search, Plus, X } from 'lucide-react'
|
import { UserCircle2, Search, Plus, X, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -33,6 +31,16 @@ import {
|
|||||||
EmployeeDocumentTypeLabel,
|
EmployeeDocumentTypeLabel,
|
||||||
type EmployeeListItem,
|
type EmployeeListItem,
|
||||||
type EmployeeDetail,
|
type EmployeeDetail,
|
||||||
|
type EmployeeWorkHistoryDto,
|
||||||
|
type EmployeeEducationDto,
|
||||||
|
type EmployeeFamilyRelationDto,
|
||||||
|
type EmployeeSkillDto,
|
||||||
|
type EmployeeDocumentDto,
|
||||||
|
type CreateEmployeeWorkHistoryInput,
|
||||||
|
type CreateEmployeeEducationInput,
|
||||||
|
type CreateEmployeeFamilyRelationInput,
|
||||||
|
type CreateEmployeeSkillInput,
|
||||||
|
type CreateEmployeeDocumentInput,
|
||||||
} from '@/types/employee'
|
} from '@/types/employee'
|
||||||
|
|
||||||
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
|
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
|
||||||
@ -239,9 +247,121 @@ export function EmployeesListPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Inline 6-section read-only detail ==========
|
// ========== Inline 6-section detail with CRUD for satellites ==========
|
||||||
|
|
||||||
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
|
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const employeeId = detail.id
|
||||||
|
|
||||||
|
// State for inline add/edit forms (5 satellite)
|
||||||
|
const [addingWorkHistory, setAddingWorkHistory] = useState(false)
|
||||||
|
const [editingWorkHistoryId, setEditingWorkHistoryId] = useState<string | null>(null)
|
||||||
|
const [addingEducation, setAddingEducation] = useState(false)
|
||||||
|
const [editingEducationId, setEditingEducationId] = useState<string | null>(null)
|
||||||
|
const [addingFamilyRelation, setAddingFamilyRelation] = useState(false)
|
||||||
|
const [editingFamilyRelationId, setEditingFamilyRelationId] = useState<string | null>(null)
|
||||||
|
const [addingSkill, setAddingSkill] = useState(false)
|
||||||
|
const [editingSkillId, setEditingSkillId] = useState<string | null>(null)
|
||||||
|
const [addingDocument, setAddingDocument] = useState(false)
|
||||||
|
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['employee-detail', employeeId] })
|
||||||
|
|
||||||
|
// ===== WorkHistory mutations =====
|
||||||
|
const createWorkHistory = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeWorkHistoryInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/work-history`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm quá trình công tác.'); invalidate(); setAddingWorkHistory(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateWorkHistory = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeWorkHistoryInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/work-history/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật quá trình công tác.'); invalidate(); setEditingWorkHistoryId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteWorkHistory = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/work-history/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá quá trình công tác.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Education mutations =====
|
||||||
|
const createEducation = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeEducationInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/education`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm quá trình đào tạo.'); invalidate(); setAddingEducation(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateEducation = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeEducationInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/education/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật quá trình đào tạo.'); invalidate(); setEditingEducationId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteEducation = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/education/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá quá trình đào tạo.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== FamilyRelation mutations =====
|
||||||
|
const createFamilyRelation = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeFamilyRelationInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/family-relations`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm thân nhân.'); invalidate(); setAddingFamilyRelation(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateFamilyRelation = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeFamilyRelationInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/family-relations/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật thân nhân.'); invalidate(); setEditingFamilyRelationId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteFamilyRelation = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/family-relations/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá thân nhân.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Skill mutations =====
|
||||||
|
const createSkill = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeSkillInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/skills`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm kỹ năng.'); invalidate(); setAddingSkill(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateSkill = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeSkillInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/skills/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật kỹ năng.'); invalidate(); setEditingSkillId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteSkill = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/skills/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá kỹ năng.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Document mutations =====
|
||||||
|
const createDocument = useMutation({
|
||||||
|
mutationFn: async (payload: CreateEmployeeDocumentInput) =>
|
||||||
|
api.post(`/employees/${employeeId}/documents`, { employeeProfileId: employeeId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã thêm hồ sơ.'); invalidate(); setAddingDocument(false) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const updateDocument = useMutation({
|
||||||
|
mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeDocumentInput }) =>
|
||||||
|
api.put(`/employees/${employeeId}/documents/${satId}`, { id: satId, ...payload }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật hồ sơ.'); invalidate(); setEditingDocumentId(null) },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const deleteDocument = useMutation({
|
||||||
|
mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/documents/${satId}`),
|
||||||
|
onSuccess: () => { toast.success('Đã xoá hồ sơ.'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
@ -376,13 +496,36 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 2: Công tác */}
|
{/* Section 2: Công tác */}
|
||||||
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}>
|
<Section
|
||||||
{detail.workHistories.length === 0 ? (
|
title={`2. Quá trình công tác (${detail.workHistories.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingWorkHistory(true); setEditingWorkHistoryId(null) }} disabled={addingWorkHistory}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingWorkHistory && (
|
||||||
|
<WorkHistoryForm
|
||||||
|
onSave={p => createWorkHistory.mutate(p)}
|
||||||
|
onCancel={() => setAddingWorkHistory(false)}
|
||||||
|
isPending={createWorkHistory.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.workHistories.length === 0 && !addingWorkHistory ? (
|
||||||
<EmptyHint text="Chưa có quá trình công tác nào." />
|
<EmptyHint text="Chưa có quá trình công tác nào." />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{detail.workHistories.map(w => (
|
{detail.workHistories.map(w => (
|
||||||
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
|
editingWorkHistoryId === w.id ? (
|
||||||
|
<WorkHistoryForm
|
||||||
|
key={w.id}
|
||||||
|
initial={w}
|
||||||
|
onSave={p => updateWorkHistory.mutate({ satId: w.id, payload: p })}
|
||||||
|
onCancel={() => setEditingWorkHistoryId(null)}
|
||||||
|
isPending={updateWorkHistory.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={w.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="font-medium text-slate-900">{w.companyName}</div>
|
<div className="font-medium text-slate-900">{w.companyName}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
@ -394,20 +537,48 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
{w.companyAddress && <div className="text-xs text-slate-500">Địa chỉ: {w.companyAddress}</div>}
|
{w.companyAddress && <div className="text-xs text-slate-500">Địa chỉ: {w.companyAddress}</div>}
|
||||||
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
|
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
|
||||||
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500">Lý do nghỉ: {w.resignReason}</div>}
|
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500">Lý do nghỉ: {w.resignReason}</div>}
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingWorkHistoryId(w.id); setAddingWorkHistory(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá quá trình công tác tại "${w.companyName}"?`)) deleteWorkHistory.mutate(w.id) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 3: Đào tạo */}
|
{/* Section 3: Đào tạo */}
|
||||||
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}>
|
<Section
|
||||||
{detail.educations.length === 0 ? (
|
title={`3. Quá trình đào tạo (${detail.educations.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingEducation(true); setEditingEducationId(null) }} disabled={addingEducation}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingEducation && (
|
||||||
|
<EducationForm
|
||||||
|
onSave={p => createEducation.mutate(p)}
|
||||||
|
onCancel={() => setAddingEducation(false)}
|
||||||
|
isPending={createEducation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.educations.length === 0 && !addingEducation ? (
|
||||||
<EmptyHint text="Chưa có quá trình đào tạo nào." />
|
<EmptyHint text="Chưa có quá trình đào tạo nào." />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{detail.educations.map(ed => (
|
{detail.educations.map(ed => (
|
||||||
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
|
editingEducationId === ed.id ? (
|
||||||
|
<EducationForm
|
||||||
|
key={ed.id}
|
||||||
|
initial={ed}
|
||||||
|
onSave={p => updateEducation.mutate({ satId: ed.id, payload: p })}
|
||||||
|
onCancel={() => setEditingEducationId(null)}
|
||||||
|
isPending={updateEducation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={ed.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="font-medium text-slate-900">{ed.schoolName}</div>
|
<div className="font-medium text-slate-900">{ed.schoolName}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
@ -422,45 +593,81 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</div>
|
</div>
|
||||||
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
|
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
|
||||||
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
|
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingEducationId(ed.id); setAddingEducation(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá quá trình đào tạo tại "${ed.schoolName}"?`)) deleteEducation.mutate(ed.id) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 4: Thân nhân */}
|
{/* Section 4: Thân nhân */}
|
||||||
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}>
|
<Section
|
||||||
{detail.familyRelations.length === 0 ? (
|
title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingFamilyRelation(true); setEditingFamilyRelationId(null) }} disabled={addingFamilyRelation}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingFamilyRelation && (
|
||||||
|
<FamilyRelationForm
|
||||||
|
onSave={p => createFamilyRelation.mutate(p)}
|
||||||
|
onCancel={() => setAddingFamilyRelation(false)}
|
||||||
|
isPending={createFamilyRelation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.familyRelations.length === 0 && !addingFamilyRelation ? (
|
||||||
<EmptyHint text="Chưa có thông tin thân nhân." />
|
<EmptyHint text="Chưa có thông tin thân nhân." />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<div className="space-y-2">
|
||||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Họ tên</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Quan hệ</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Năm sinh</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Nghề nghiệp</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">SĐT</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{detail.familyRelations.map(f => (
|
{detail.familyRelations.map(f => (
|
||||||
<tr key={f.id} className="border-b border-slate-100">
|
editingFamilyRelationId === f.id ? (
|
||||||
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td>
|
<FamilyRelationForm
|
||||||
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td>
|
key={f.id}
|
||||||
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td>
|
initial={f}
|
||||||
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td>
|
onSave={p => updateFamilyRelation.mutate({ satId: f.id, payload: p })}
|
||||||
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td>
|
onCancel={() => setEditingFamilyRelationId(null)}
|
||||||
</tr>
|
isPending={updateFamilyRelation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={f.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-5">
|
||||||
|
<div><span className="text-xs text-slate-500">Họ tên: </span><span className="font-medium text-slate-800">{f.fullName}</span></div>
|
||||||
|
<div><span className="text-xs text-slate-500">Quan hệ: </span>{FamilyRelationKindLabel[f.relationship]}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Năm sinh: </span>{f.birthYear ?? '—'}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Nghề: </span>{f.occupation ?? '—'}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">SĐT: </span>{f.phone ?? '—'}</div>
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingFamilyRelationId(f.id); setAddingFamilyRelation(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá thân nhân "${f.fullName}"?`)) deleteFamilyRelation.mutate(f.id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 5: Kỹ năng */}
|
{/* Section 5: Kỹ năng */}
|
||||||
<Section title={`5. Kỹ năng (${detail.skills.length})`}>
|
<Section
|
||||||
{detail.skills.length === 0 ? (
|
title={`5. Kỹ năng (${detail.skills.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingSkill && (
|
||||||
|
<SkillForm
|
||||||
|
onSave={p => createSkill.mutate(p)}
|
||||||
|
onCancel={() => setAddingSkill(false)}
|
||||||
|
isPending={createSkill.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.skills.length === 0 && !addingSkill ? (
|
||||||
<EmptyHint text="Chưa có thông tin kỹ năng." />
|
<EmptyHint text="Chưa có thông tin kỹ năng." />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -474,13 +681,27 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{group.map(s => (
|
{group.map(s => (
|
||||||
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm">
|
editingSkillId === s.id ? (
|
||||||
|
<SkillForm
|
||||||
|
key={s.id}
|
||||||
|
initial={s}
|
||||||
|
onSave={p => updateSkill.mutate({ satId: s.id, payload: p })}
|
||||||
|
onCancel={() => setEditingSkillId(null)}
|
||||||
|
isPending={updateSkill.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<li key={s.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 pr-16 text-sm">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<span className="font-medium text-slate-800">{s.name}</span>
|
<span className="font-medium text-slate-800">{s.name}</span>
|
||||||
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
|
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
|
||||||
</div>
|
</div>
|
||||||
{s.languageId && <div className="text-xs text-slate-500">Mã: {s.languageId}</div>}
|
{s.languageId && <div className="text-xs text-slate-500">Mã: {s.languageId}</div>}
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingSkillId(s.id); setAddingSkill(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá kỹ năng "${s.name}"?`)) deleteSkill.mutate(s.id) }}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -491,34 +712,51 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 6: Hồ sơ */}
|
{/* Section 6: Hồ sơ */}
|
||||||
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}>
|
<Section
|
||||||
{detail.documents.length === 0 ? (
|
title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddingDocument(true); setEditingDocumentId(null) }} disabled={addingDocument}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addingDocument && (
|
||||||
|
<DocumentForm
|
||||||
|
onSave={p => createDocument.mutate(p)}
|
||||||
|
onCancel={() => setAddingDocument(false)}
|
||||||
|
isPending={createDocument.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.documents.length === 0 && !addingDocument ? (
|
||||||
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
|
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<div className="space-y-2">
|
||||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Loại</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Tên file</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Ngày cấp</th>
|
|
||||||
<th className="py-2 pr-3 text-left font-medium">Ngày hết hạn</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{detail.documents.map(doc => (
|
{detail.documents.map(doc => (
|
||||||
<tr key={doc.id} className="border-b border-slate-100">
|
editingDocumentId === doc.id ? (
|
||||||
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td>
|
<DocumentForm
|
||||||
<td className="py-2 pr-3">
|
key={doc.id}
|
||||||
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">
|
initial={doc}
|
||||||
{doc.fileName}
|
onSave={p => updateDocument.mutate({ satId: doc.id, payload: p })}
|
||||||
</a>
|
onCancel={() => setEditingDocumentId(null)}
|
||||||
</td>
|
isPending={updateDocument.isPending}
|
||||||
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.issueDate)}</td>
|
/>
|
||||||
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.expiryDate)}</td>
|
) : (
|
||||||
</tr>
|
<div key={doc.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-4">
|
||||||
|
<div><span className="text-xs text-slate-500">Loại: </span>{EmployeeDocumentTypeLabel[doc.documentType]}</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-500">Tên file: </span>
|
||||||
|
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">{doc.fileName}</a>
|
||||||
|
</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Ngày cấp: </span>{fmtDate(doc.issueDate)}</div>
|
||||||
|
<div><span className="text-xs text-slate-500">Hết hạn: </span>{fmtDate(doc.expiryDate)}</div>
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => { setEditingDocumentId(doc.id); setAddingDocument(false) }}
|
||||||
|
onDelete={() => { if (confirm(`Xoá hồ sơ "${doc.fileName}"?`)) deleteDocument.mutate(doc.id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@ -530,14 +768,403 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Helpers ==========
|
// ========== Inline forms for 5 satellite (cookie-cutter pattern 12-ter) ==========
|
||||||
|
|
||||||
function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
const nullable = (s: string) => s.trim() || null
|
||||||
|
const nullableNumber = (s: string): number | null => {
|
||||||
|
const n = Number(s)
|
||||||
|
return s.trim() === '' || isNaN(n) ? null : n
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkHistoryForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeWorkHistoryDto
|
||||||
|
onSave: (p: CreateEmployeeWorkHistoryInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
companyName: initial?.companyName ?? '',
|
||||||
|
companyAddress: initial?.companyAddress ?? '',
|
||||||
|
industry: initial?.industry ?? '',
|
||||||
|
fromDate: initial?.fromDate ?? '',
|
||||||
|
toDate: initial?.toDate ?? '',
|
||||||
|
jobTitle: initial?.jobTitle ?? '',
|
||||||
|
jobDescription: initial?.jobDescription ?? '',
|
||||||
|
resignReason: initial?.resignReason ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
companyName: form.companyName.trim(),
|
||||||
|
companyAddress: nullable(form.companyAddress),
|
||||||
|
industry: nullable(form.industry),
|
||||||
|
fromDate: form.fromDate || null,
|
||||||
|
toDate: form.toDate || null,
|
||||||
|
jobTitle: nullable(form.jobTitle),
|
||||||
|
jobDescription: nullable(form.jobDescription),
|
||||||
|
resignReason: nullable(form.resignReason),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Tên công ty *">
|
||||||
|
<Input value={form.companyName} onChange={e => setForm({ ...form, companyName: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Địa chỉ công ty">
|
||||||
|
<Input value={form.companyAddress} onChange={e => setForm({ ...form, companyAddress: e.target.value })} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngành nghề">
|
||||||
|
<Input value={form.industry} onChange={e => setForm({ ...form, industry: e.target.value })} maxLength={100} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Chức vụ">
|
||||||
|
<Input value={form.jobTitle} onChange={e => setForm({ ...form, jobTitle: e.target.value })} maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Từ ngày">
|
||||||
|
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Đến ngày">
|
||||||
|
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormField label="Mô tả công việc">
|
||||||
|
<Textarea value={form.jobDescription} onChange={e => setForm({ ...form, jobDescription: e.target.value })} maxLength={2000} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Lý do nghỉ">
|
||||||
|
<Input value={form.resignReason} onChange={e => setForm({ ...form, resignReason: e.target.value })} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EducationForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeEducationDto
|
||||||
|
onSave: (p: CreateEmployeeEducationInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
schoolName: initial?.schoolName ?? '',
|
||||||
|
major: initial?.major ?? '',
|
||||||
|
degreeLevel: initial?.degreeLevel != null ? String(initial.degreeLevel) : '',
|
||||||
|
educationMode: initial?.educationMode != null ? String(initial.educationMode) : '',
|
||||||
|
gradeLevel: initial?.gradeLevel != null ? String(initial.gradeLevel) : '',
|
||||||
|
fromDate: initial?.fromDate ?? '',
|
||||||
|
toDate: initial?.toDate ?? '',
|
||||||
|
certificateIssueDate: initial?.certificateIssueDate ?? '',
|
||||||
|
notes: initial?.notes ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
schoolName: form.schoolName.trim(),
|
||||||
|
major: nullable(form.major),
|
||||||
|
degreeLevel: nullableNumber(form.degreeLevel),
|
||||||
|
educationMode: nullableNumber(form.educationMode),
|
||||||
|
gradeLevel: nullableNumber(form.gradeLevel),
|
||||||
|
fromDate: form.fromDate || null,
|
||||||
|
toDate: form.toDate || null,
|
||||||
|
certificateIssueDate: form.certificateIssueDate || null,
|
||||||
|
notes: nullable(form.notes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Tên trường *">
|
||||||
|
<Input value={form.schoolName} onChange={e => setForm({ ...form, schoolName: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Chuyên ngành">
|
||||||
|
<Input value={form.major} onChange={e => setForm({ ...form, major: e.target.value })} maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Bằng cấp">
|
||||||
|
<Select value={form.degreeLevel} onChange={e => setForm({ ...form, degreeLevel: e.target.value })}>
|
||||||
|
<option value="">— Chọn —</option>
|
||||||
|
<option value="1">Cao đẳng</option>
|
||||||
|
<option value="2">Đại học</option>
|
||||||
|
<option value="3">Thạc sĩ</option>
|
||||||
|
<option value="4">Tiến sĩ</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Hình thức">
|
||||||
|
<Select value={form.educationMode} onChange={e => setForm({ ...form, educationMode: e.target.value })}>
|
||||||
|
<option value="">— Chọn —</option>
|
||||||
|
<option value="1">Chính quy</option>
|
||||||
|
<option value="2">Tại chức</option>
|
||||||
|
<option value="3">Từ xa</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Xếp loại">
|
||||||
|
<Select value={form.gradeLevel} onChange={e => setForm({ ...form, gradeLevel: e.target.value })}>
|
||||||
|
<option value="">— Chọn —</option>
|
||||||
|
<option value="1">Trung bình</option>
|
||||||
|
<option value="2">Khá</option>
|
||||||
|
<option value="3">Giỏi</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngày cấp bằng">
|
||||||
|
<Input type="date" value={form.certificateIssueDate} onChange={e => setForm({ ...form, certificateIssueDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Từ ngày">
|
||||||
|
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Đến ngày">
|
||||||
|
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormField label="Ghi chú">
|
||||||
|
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FamilyRelationForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeFamilyRelationDto
|
||||||
|
onSave: (p: CreateEmployeeFamilyRelationInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
fullName: initial?.fullName ?? '',
|
||||||
|
relationship: initial?.relationship != null ? String(initial.relationship) : '1',
|
||||||
|
birthYear: initial?.birthYear != null ? String(initial.birthYear) : '',
|
||||||
|
occupation: initial?.occupation ?? '',
|
||||||
|
currentAddress: initial?.currentAddress ?? '',
|
||||||
|
phone: initial?.phone ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
fullName: form.fullName.trim(),
|
||||||
|
relationship: Number(form.relationship),
|
||||||
|
birthYear: nullableNumber(form.birthYear),
|
||||||
|
occupation: nullable(form.occupation),
|
||||||
|
currentAddress: nullable(form.currentAddress),
|
||||||
|
phone: nullable(form.phone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Họ tên *">
|
||||||
|
<Input value={form.fullName} onChange={e => setForm({ ...form, fullName: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Quan hệ *">
|
||||||
|
<Select value={form.relationship} onChange={e => setForm({ ...form, relationship: e.target.value })} required>
|
||||||
|
<option value="1">Cha</option>
|
||||||
|
<option value="2">Mẹ</option>
|
||||||
|
<option value="3">Vợ/Chồng</option>
|
||||||
|
<option value="4">Con</option>
|
||||||
|
<option value="5">Anh/Chị/Em ruột</option>
|
||||||
|
<option value="99">Khác</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Năm sinh">
|
||||||
|
<Input type="number" min={1900} max={2026} value={form.birthYear} onChange={e => setForm({ ...form, birthYear: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Nghề nghiệp">
|
||||||
|
<Input value={form.occupation} onChange={e => setForm({ ...form, occupation: e.target.value })} maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="SĐT">
|
||||||
|
<Input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} maxLength={20} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Địa chỉ hiện tại">
|
||||||
|
<Input value={form.currentAddress} onChange={e => setForm({ ...form, currentAddress: e.target.value })} maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeSkillDto
|
||||||
|
onSave: (p: CreateEmployeeSkillInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
kind: initial?.kind != null ? String(initial.kind) : '1',
|
||||||
|
name: initial?.name ?? '',
|
||||||
|
languageId: initial?.languageId ?? '',
|
||||||
|
level: initial?.level ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
kind: Number(form.kind),
|
||||||
|
name: form.name.trim(),
|
||||||
|
languageId: nullable(form.languageId),
|
||||||
|
level: nullable(form.level),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Loại *">
|
||||||
|
<Select value={form.kind} onChange={e => setForm({ ...form, kind: e.target.value })} required>
|
||||||
|
<option value="1">Kỹ năng vi tính</option>
|
||||||
|
<option value="2">Ngoại ngữ</option>
|
||||||
|
<option value="3">Kỹ năng khác</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Tên kỹ năng *">
|
||||||
|
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required maxLength={200} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Mã ngôn ngữ">
|
||||||
|
<Input value={form.languageId} onChange={e => setForm({ ...form, languageId: e.target.value })} maxLength={10} placeholder="vi/en/ja..." />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Trình độ">
|
||||||
|
<Input value={form.level} onChange={e => setForm({ ...form, level: e.target.value })} maxLength={200} placeholder="B1/IELTS 6.5/N2..." />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentForm({ initial, onSave, onCancel, isPending }: {
|
||||||
|
initial?: EmployeeDocumentDto
|
||||||
|
onSave: (p: CreateEmployeeDocumentInput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
documentType: initial?.documentType != null ? String(initial.documentType) : '1',
|
||||||
|
fileName: initial?.fileName ?? '',
|
||||||
|
filePath: initial?.filePath ?? '',
|
||||||
|
fileSize: initial?.fileSize != null ? String(initial.fileSize) : '0',
|
||||||
|
contentType: initial?.contentType ?? 'application/octet-stream',
|
||||||
|
issueDate: initial?.issueDate ?? '',
|
||||||
|
expiryDate: initial?.expiryDate ?? '',
|
||||||
|
notes: initial?.notes ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
documentType: Number(form.documentType),
|
||||||
|
fileName: form.fileName.trim(),
|
||||||
|
filePath: form.filePath.trim(),
|
||||||
|
fileSize: Number(form.fileSize) || 0,
|
||||||
|
contentType: form.contentType.trim(),
|
||||||
|
issueDate: form.issueDate || null,
|
||||||
|
expiryDate: form.expiryDate || null,
|
||||||
|
notes: nullable(form.notes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
|
||||||
|
<Grid2>
|
||||||
|
<FormField label="Loại hồ sơ *">
|
||||||
|
<Select value={form.documentType} onChange={e => setForm({ ...form, documentType: e.target.value })} required>
|
||||||
|
<option value="1">CMND/CCCD</option>
|
||||||
|
<option value="2">Hộ chiếu</option>
|
||||||
|
<option value="3">Bằng cấp</option>
|
||||||
|
<option value="4">Chứng chỉ</option>
|
||||||
|
<option value="5">HĐLĐ</option>
|
||||||
|
<option value="99">Khác</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Tên file *">
|
||||||
|
<Input value={form.fileName} onChange={e => setForm({ ...form, fileName: e.target.value })} required maxLength={500} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Đường dẫn file *">
|
||||||
|
<Input value={form.filePath} onChange={e => setForm({ ...form, filePath: e.target.value })} required maxLength={1000} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Content-Type *">
|
||||||
|
<Input value={form.contentType} onChange={e => setForm({ ...form, contentType: e.target.value })} required maxLength={100} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Kích thước (bytes)">
|
||||||
|
<Input type="number" min={0} value={form.fileSize} onChange={e => setForm({ ...form, fileSize: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngày cấp">
|
||||||
|
<Input type="date" value={form.issueDate} onChange={e => setForm({ ...form, issueDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ngày hết hạn">
|
||||||
|
<Input type="date" value={form.expiryDate} onChange={e => setForm({ ...form, expiryDate: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</Grid2>
|
||||||
|
<FormField label="Ghi chú">
|
||||||
|
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Form helpers ==========
|
||||||
|
|
||||||
|
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-700">{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFooter({ onCancel, isPending, isEdit }: { onCancel: () => void; isPending: boolean; isEdit: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2 border-t border-brand-100 pt-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>Huỷ</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={isPending}>
|
||||||
|
{isPending ? 'Đang lưu...' : (isEdit ? 'Cập nhật' : 'Thêm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="absolute right-2 top-2 flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="rounded p-1 text-slate-400 transition hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
title="Sửa"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="rounded p-1 text-slate-400 transition hover:bg-red-50 hover:text-red-600"
|
||||||
|
title="Xoá"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Layout helpers (read-only) ==========
|
||||||
|
|
||||||
|
function Section({ title, children, defaultOpen, actions }: { title: string; children: React.ReactNode; defaultOpen?: boolean; actions?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
|
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
|
||||||
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
|
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
|
||||||
<span>{title}</span>
|
<span className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform">▶</span>
|
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform">▶</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
</span>
|
||||||
|
{actions && (
|
||||||
|
<span onClick={e => e.stopPropagation()}>
|
||||||
|
{actions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
|
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@ -232,6 +232,60 @@ export type EmployeeDocumentDto = {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Satellite Create/Update Input Commands (S35 Plan B-WrapPlus1) ==========
|
||||||
|
// Mirror BE record Command field shape (EmployeeSatelliteFeatures.cs). UpdateInput
|
||||||
|
// reuse CreateInput shape (DRY) — handler set `id` separately at API call site.
|
||||||
|
|
||||||
|
export type CreateEmployeeWorkHistoryInput = {
|
||||||
|
companyName: string
|
||||||
|
companyAddress: string | null
|
||||||
|
industry: string | null
|
||||||
|
fromDate: string | null
|
||||||
|
toDate: string | null
|
||||||
|
jobTitle: string | null
|
||||||
|
jobDescription: string | null
|
||||||
|
resignReason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeEducationInput = {
|
||||||
|
schoolName: string
|
||||||
|
major: string | null
|
||||||
|
degreeLevel: number | null
|
||||||
|
educationMode: number | null
|
||||||
|
gradeLevel: number | null
|
||||||
|
fromDate: string | null
|
||||||
|
toDate: string | null
|
||||||
|
certificateIssueDate: string | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeFamilyRelationInput = {
|
||||||
|
fullName: string
|
||||||
|
relationship: number
|
||||||
|
birthYear: number | null
|
||||||
|
occupation: string | null
|
||||||
|
currentAddress: string | null
|
||||||
|
phone: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeSkillInput = {
|
||||||
|
kind: number
|
||||||
|
name: string
|
||||||
|
languageId: string | null
|
||||||
|
level: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateEmployeeDocumentInput = {
|
||||||
|
documentType: number
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
contentType: string
|
||||||
|
issueDate: string | null
|
||||||
|
expiryDate: string | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Detail (full + 5 satellite collection) ==========
|
// ========== Detail (full + 5 satellite collection) ==========
|
||||||
|
|
||||||
export type EmployeeDetail = {
|
export type EmployeeDetail = {
|
||||||
|
|||||||
Reference in New Issue
Block a user