sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
@@ -0,0 +1,180 @@
// Admin-detail tab content for the cockpit: a numbered section panel (read view) plus a
// per-section editor dialog that persists via updateProjectDetail() (shallow content merge).
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Pencil } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
updateProjectDetail,
} from '@ump/shared';
import {
displayValue,
readContent,
type DetailField,
type DetailSection,
} from './detailConfig';
import { Field, FieldGrid, SectionPanel } from './DetailPrimitives';
type Content = Record<string, unknown>;
/** One administrative section, read view + an inline ✎ edit affordance (owner/admin only). */
export function DetailSectionPanel({
section,
content,
projectId,
canEdit,
}: {
section: DetailSection;
content: Content;
projectId: string;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
return (
<>
<SectionPanel
index={section.index}
title={section.title}
action={
canEdit ? (
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-primary" onClick={() => setEditing(true)}>
<Pencil size={14} /> Chỉnh sửa
</Button>
) : undefined
}
>
<FieldGrid>
{section.fields.map((f) => (
<Field
key={f.key}
label={f.label}
full={f.full}
value={displayValue(f, readContent(content, f.key))}
/>
))}
</FieldGrid>
</SectionPanel>
{editing && (
<DetailEditorDialog
section={section}
content={content}
projectId={projectId}
onClose={() => setEditing(false)}
/>
)}
</>
);
}
/** Coerce a draft string back to the type the BE/JSONB should hold. */
function coerce(field: DetailField, raw: string): unknown {
if (field.type === 'money' || field.type === 'number' || field.type === 'months') {
const n = Number(raw);
return raw.trim() === '' || Number.isNaN(n) ? '' : n;
}
return raw;
}
export function DetailEditorDialog({
section,
content,
projectId,
onClose,
}: {
section: DetailSection;
content: Content;
projectId: string;
onClose: () => void;
}) {
const qc = useQueryClient();
const [draft, setDraft] = useState<Record<string, string>>(() => {
const d: Record<string, string> = {};
for (const f of section.fields) {
const v = readContent(content, f.key);
d[f.key] = v === undefined || v === null ? '' : String(v);
}
return d;
});
const save = useMutation({
mutationFn: () => {
const patch: Record<string, unknown> = {};
for (const f of section.fields) patch[f.key] = coerce(f, draft[f.key] ?? '');
return updateProjectDetail(projectId, patch);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cockpit', projectId] });
toast.success('Đã lưu.');
onClose();
},
onError: () => toast.error('Lưu thất bại.'),
});
const set = (k: string, v: string) => setDraft((d) => ({ ...d, [k]: v }));
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="font-serif">
{section.index}. {section.title}
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 gap-3.5 py-2 sm:grid-cols-2">
{section.fields.map((f) => (
<div key={f.key} className={f.full || f.type === 'textarea' ? 'sm:col-span-2' : undefined}>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">{f.label}</Label>
{f.type === 'textarea' ? (
<Textarea className="min-h-20" value={draft[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} />
) : f.type === 'select' ? (
<Select value={draft[f.key] || undefined} onValueChange={(v) => set(f.key, v)}>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
{f.options?.map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={f.type === 'date' ? 'date' : f.type === 'money' || f.type === 'months' ? 'number' : 'text'}
value={draft[f.key] ?? ''}
onChange={(e) => set(f.key, e.target.value)}
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={save.isPending}>
Hủy
</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Lưu
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,147 @@
// Left metadata rail for the cockpit, mirroring the institutional "đề tài" detail sheet:
// project identity + workflow progress + labelled meta rows + PI/member avatars.
import {
Building2,
Coins,
FileText,
FlaskConical,
Hash,
Tag,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { CockpitBundle } from '@ump/shared';
import { asNum, asStr } from './cockpitConfig';
import { fmtMoney, readContent } from './detailConfig';
import { Bar, StatusBadge } from './CockpitWidgets';
/** Initials from a Vietnamese name: last 12 significant words (skips titles like ThS./DS.). */
function initials(name: string): string {
const parts = name
.replace(/\b(ThS|TS|PGS|GS|BS|DS|SVD\d*|CN|KS|ThS\.)\.?\b/gi, '')
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) return '?';
const last = parts[parts.length - 1];
return last.slice(0, 1).toUpperCase();
}
const AVATAR_TONES = [
'bg-sky-100 text-sky-700',
'bg-emerald-100 text-emerald-700',
'bg-violet-100 text-violet-700',
'bg-amber-100 text-amber-700',
'bg-rose-100 text-rose-700',
];
function Avatar({ name, i = 0, ring }: { name: string; i?: number; ring?: boolean }) {
return (
<span
title={name}
className={
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold ' +
AVATAR_TONES[i % AVATAR_TONES.length] +
(ring ? ' ring-2 ring-card' : '')
}
>
{initials(name)}
</span>
);
}
function MetaRow({ icon: Icon, label, children }: { icon: LucideIcon; label: string; children: ReactNode }) {
return (
<div className="flex items-start gap-2.5 py-2">
<Icon size={15} className="mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="font-sans text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-0.5 text-sm text-foreground">{children}</div>
</div>
</div>
);
}
export function CockpitSidebar({ bundle, datasetNav }: { bundle: CockpitBundle; datasetNav?: ReactNode }) {
const { project, members, milestones } = bundle;
const content = (project.content ?? {}) as Record<string, unknown>;
const code = project.code || asStr(readContent(content, 'maSo')) || '(chưa có mã số)';
const contractNo = asStr(readContent(content, 'soHopDong'));
const budget = project.budgetTotal ?? (Number(readContent(content, 'tongKinhPhi')) || 0);
const workflowPct = milestones.length
? Math.round(milestones.reduce((s, m) => s + asNum(m.progress), 0) / milestones.length)
: 0;
const status = asStr(readContent(content, 'trangThaiTienDo')) || 'Chưa có';
return (
<aside className="space-y-5 rounded-2xl border border-sidebar-border bg-sidebar p-5">
{/* identity */}
<div className="flex items-start gap-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<FlaskConical size={20} />
</span>
<div className="min-w-0">
<h2 className="font-serif text-sm font-semibold leading-snug text-foreground">
{project.title || '(Chưa đặt tên)'}
</h2>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{code} · {project.piName || '—'}
{contractNo ? ` · ${contractNo}` : ''}
</p>
</div>
</div>
{/* workflow progress */}
<div>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className="font-sans font-medium text-muted-foreground">Tiến đ quy trình</span>
<span className="tabular-nums font-semibold text-primary">{workflowPct}%</span>
</div>
<Bar value={workflowPct} />
</div>
{/* meta rows */}
<div className="divide-y divide-sidebar-border border-y border-sidebar-border">
<MetaRow icon={Hash} label="Mã đề tài">{code}</MetaRow>
<MetaRow icon={FileText} label="Số hợp đồng">{contractNo || '—'}</MetaRow>
<MetaRow icon={Building2} label="Khoa / Đơn vị">{asStr(readContent(content, 'khoaDonVi')) || '—'}</MetaRow>
<MetaRow icon={Tag} label="Loại đề tài">
{asStr(readContent(content, 'capDeTai')) || project.level || '—'}
</MetaRow>
<MetaRow icon={Coins} label="Tổng kinh phí">{fmtMoney(budget) || '—'}</MetaRow>
</div>
{/* dataset navigator — only rendered on the imaging-data tab */}
{datasetNav}
{/* status + assignee */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="font-sans text-xs font-medium text-muted-foreground">Tiến đ thực hiện</span>
<StatusBadge value={status} />
</div>
{members.length > 0 && (
<div>
<div className="mb-1.5 font-sans text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Thành viên nghiên cứu
</div>
<div className="flex -space-x-2">
{members.slice(0, 6).map((m, i) => (
<Avatar key={m.id} name={asStr(m.name)} i={i} ring />
))}
{members.length > 6 && (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground ring-2 ring-card">
+{members.length - 6}
</span>
)}
</div>
</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,226 @@
import { Pencil, ShieldCheck, Trash2, type LucideIcon } from 'lucide-react';
import {
Button,
Card,
CardContent,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
type EntityRow,
} from '@ump/shared';
import { asNum, asStr, fmt, pct, toneClass, type EntityConfig } from './cockpitConfig';
export function StatusBadge({ value }: { value: unknown }) {
if (value === undefined || value === null || value === '') return null;
return (
<span className={'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ' + toneClass(value)}>
{asStr(value)}
</span>
);
}
export function Stat({
icon: Icon,
label,
value,
sub,
accent,
}: {
icon: LucideIcon;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Icon size={15} className={accent} /> {label}
</div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</CardContent>
</Card>
);
}
export function Bar({ value, tone = 'bg-primary' }: { value: unknown; tone?: string }) {
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={'h-full rounded-full ' + tone} style={{ width: pct(value) + '%' }} />
</div>
);
}
export function EntityCard({
config,
item,
canEdit,
onEdit,
onDelete,
}: {
config: EntityConfig;
item: EntityRow;
canEdit: boolean;
onEdit: () => void;
onDelete: () => void;
}) {
const isNumField = (k: string) => config.fields.some((f) => f.key === k && f.type === 'number');
const sub = config.secondary
.map(([k, sfx]) => {
const v = item[k];
if (v === undefined || v === '' || v === null) return null;
return (isNumField(k) ? fmt(v) : asStr(v)) + (sfx || '');
})
.filter(Boolean);
const progressTone =
item.status === 'Trễ hạn' ? 'bg-rose-500' : item.status === 'Hoàn thành' ? 'bg-emerald-500' : 'bg-primary';
return (
<Card className="card-hover">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold leading-snug text-foreground">{asStr(item[config.primary]) || '—'}</div>
{sub.length > 0 && <div className="mt-0.5 text-xs text-muted-foreground">{sub.join(' · ')}</div>}
</div>
{config.badge && <StatusBadge value={item[config.badge]} />}
</div>
{config.accessKey && item[config.accessKey] ? (
<div className="mt-2 inline-flex items-center gap-1 rounded-md border border-violet-200 bg-violet-50 px-2 py-0.5 text-xs text-violet-700">
<ShieldCheck size={12} /> {asStr(item[config.accessKey])}
</div>
) : null}
{config.metrics && item.status !== 'Kế hoạch' && (
<div className="mt-3 flex flex-wrap gap-1.5">
{config.metrics.map(([k, lbl]) =>
asNum(item[k]) > 0 ? (
<span key={k} className="rounded-md border border-border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
{lbl} <b className="tabular-nums text-foreground">{asNum(item[k]).toFixed(2)}</b>
</span>
) : null,
)}
</div>
)}
{config.progress && (
<div className="mt-3">
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
<span className="truncate pr-2">{asStr(item.deliverable)}</span>
<span className="tabular-nums">{pct(item[config.progress])}%</span>
</div>
<Bar value={item[config.progress]} tone={progressTone} />
</div>
)}
<div className="mt-3 flex justify-end gap-1 border-t border-border pt-2">
<button
onClick={onEdit}
disabled={!canEdit}
title={canEdit ? 'Sửa' : 'Không có quyền'}
className={'p-1 ' + (canEdit ? 'text-muted-foreground hover:text-primary' : 'cursor-not-allowed text-muted-foreground/40')}
>
<Pencil size={15} />
</button>
<button
onClick={onDelete}
disabled={!canEdit}
title={canEdit ? 'Xóa' : 'Không có quyền'}
className={'p-1 ' + (canEdit ? 'text-muted-foreground hover:text-destructive' : 'cursor-not-allowed text-muted-foreground/40')}
>
<Trash2 size={15} />
</button>
</div>
</CardContent>
</Card>
);
}
export function EntityDrawer({
open,
isNew,
config,
draft,
onChange,
onSave,
onClose,
saving,
}: {
open: boolean;
isNew: boolean;
config: EntityConfig;
draft: Record<string, string>;
onChange: (key: string, value: string) => void;
onSave: () => void;
onClose: () => void;
saving: boolean;
}) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="font-serif">
{isNew ? 'Thêm ' : 'Sửa '}
{config.singular}
</DialogTitle>
</DialogHeader>
<div className="space-y-3.5 py-2">
{config.fields.map((f) => (
<div key={f.key}>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">{f.label}</Label>
{f.type === 'textarea' ? (
<Textarea className="min-h-20" value={draft[f.key] ?? ''} onChange={(e) => onChange(f.key, e.target.value)} />
) : f.type === 'select' ? (
<Select value={draft[f.key] || undefined} onValueChange={(v) => onChange(f.key, v)}>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
{f.options?.map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={f.type === 'number' ? 'number' : 'text'}
min={f.min}
max={f.max}
step={f.step}
value={draft[f.key] ?? ''}
onChange={(e) => onChange(f.key, e.target.value)}
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Hủy
</Button>
<Button onClick={onSave} disabled={saving}>
Lưu
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,67 @@
// Presentational primitives for the project-detail layout (Thông tin chung / Hợp đồng /
// Kinh phí / Thời gian / Nghiệm thu). Mirrors the admin detail page's visual language:
// a numbered section panel with a left accent bar, and a grid of small uppercase grey
// LABEL over VALUE fields. Pure presentation — no data fetching, no content keys.
import type { ReactNode } from 'react';
/** A numbered section panel: "I. Thông tin chung" with a primary accent bar, then content. */
export function SectionPanel({
index,
title,
action,
children,
}: {
index: string;
title: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="rounded-2xl border border-border bg-card p-5 sm:p-6">
<div className="mb-5 flex items-start justify-between gap-3">
<h2 className="flex items-center gap-2.5 font-serif text-base font-semibold text-foreground">
<span className="inline-block h-5 w-1 rounded-full bg-primary" aria-hidden />
{index}. {title}
</h2>
{action}
</div>
{children}
</section>
);
}
/** Responsive grid that lays fields out 1→2→4 columns like the admin page. */
export function FieldGrid({ children }: { children: ReactNode }) {
return <div className="grid grid-cols-1 gap-x-8 gap-y-5 sm:grid-cols-2 lg:grid-cols-4">{children}</div>;
}
/** A single LABEL / VALUE pair. Label is forced to sans + uppercase to beat the global serif. */
export function Field({
label,
value,
full,
mono,
}: {
label: string;
value?: ReactNode;
full?: boolean;
mono?: boolean;
}) {
const empty = value === undefined || value === null || value === '';
return (
<div className={full ? 'sm:col-span-2 lg:col-span-4' : undefined}>
<div className="font-sans text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div
className={
'mt-1 text-sm leading-relaxed ' +
(empty ? 'text-muted-foreground/60' : 'text-foreground') +
(mono ? ' font-mono' : '')
}
>
{empty ? '---' : value}
</div>
</div>
);
}
@@ -0,0 +1,454 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Crown, Mail, MoreHorizontal, Pencil, Search, Shield, ShieldCheck, Trash2, UserCheck, UserPlus, Users, X } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
cn,
createEntity,
deleteEntity,
updateEntity,
type CockpitEntity,
type EntityRow,
} from '@ump/shared';
import { ENTITIES, asStr } from './cockpitConfig';
import { EntityDrawer, StatusBadge } from './CockpitWidgets';
// Status vocabulary (the research_project_members.status values from cockpitConfig).
const STATUS = { active: 'Đang hoạt động', pending: 'Chờ xác nhận', paused: 'Tạm dừng' } as const;
const memberField = (key: string) => ENTITIES.members.fields.find((f) => f.key === key);
const ROLE_OPTIONS = memberField('role')?.options ?? [];
const ACCESS_OPTIONS = memberField('access')?.options ?? [];
const validEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const AVATAR_COLORS = ['bg-rose-500', 'bg-emerald-500', 'bg-sky-500', 'bg-violet-500', 'bg-amber-500', 'bg-teal-500'];
function avatar(seed: string): { initials: string; color: string } {
const s = seed.trim() || '?';
const parts = s.split(/\s+/).filter(Boolean);
const initials = (parts.length >= 2 ? parts[0][0] + parts[parts.length - 1][0] : s.slice(0, 2)).toUpperCase();
let h = 0;
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return { initials, color: AVATAR_COLORS[h % AVATAR_COLORS.length] };
}
/** "PI" access (Chủ nhiệm) gets the crown; everyone else a shield. */
const isPiAccess = (access: string) => /Chủ nhiệm|PI/i.test(access);
const TABS: Array<{ key: keyof typeof STATUS; label: string }> = [
{ key: 'active', label: 'Đang hoạt động' },
{ key: 'pending', label: 'Chờ xác nhận' },
{ key: 'paused', label: 'Tạm dừng' },
];
/**
* Team-management view for a research project (cockpit "Thành viên" tab) — TeamManagement.jsx
* reskinned to the UMP system. FE-only over the existing research_project_members entity CRUD:
* invite = a member row with status "Chờ xác nhận"; RBAC = the `access` field; who-can-edit is
* gated by canEdit (project owner / admin). No schema/backend change.
*/
export function TeamManagementView({
members,
projectId,
canEdit,
}: {
members: EntityRow[];
projectId: string;
canEdit: boolean;
}) {
const qc = useQueryClient();
const [tab, setTab] = useState<keyof typeof STATUS>('active');
const [query, setQuery] = useState('');
const [inviteOpen, setInviteOpen] = useState(false);
const [editing, setEditing] = useState<EntityRow | null>(null);
const [draft, setDraft] = useState<Record<string, string>>({});
const invalidate = () => qc.invalidateQueries({ queryKey: ['cockpit', projectId] });
const saveMut = useMutation({
mutationFn: (vars: { id?: string; data: Record<string, string> }) =>
vars.id
? updateEntity(projectId, 'members' as CockpitEntity, vars.id, vars.data)
: createEntity(projectId, 'members' as CockpitEntity, vars.data),
onSuccess: () => invalidate(),
onError: (e: unknown) => toast.error((e as { message?: string })?.message || 'Lưu thất bại'),
});
const delMut = useMutation({
mutationFn: (itemId: string) => deleteEntity(projectId, 'members' as CockpitEntity, itemId),
onSuccess: () => invalidate(),
onError: () => toast.error('Xóa thất bại'),
});
const patch = (id: string, data: Record<string, string>) => saveMut.mutate({ id, data });
const counts = useMemo(() => {
const c = { active: 0, pending: 0, paused: 0 } as Record<keyof typeof STATUS, number>;
for (const m of members) {
const s = asStr(m.status);
if (s === STATUS.active) c.active += 1;
else if (s === STATUS.pending) c.pending += 1;
else if (s === STATUS.paused) c.paused += 1;
}
return c;
}, [members]);
const q = query.trim().toLowerCase();
const visible = useMemo(
() =>
members
.filter((m) => asStr(m.status) === STATUS[tab])
.filter(
(m) =>
!q ||
asStr(m.name).toLowerCase().includes(q) ||
asStr(m.email).toLowerCase().includes(q) ||
asStr(m.role).toLowerCase().includes(q),
),
[members, tab, q],
);
const openEdit = (m: EntityRow) => {
setDraft(Object.fromEntries(ENTITIES.members.fields.map((f) => [f.key, asStr(m[f.key])])));
setEditing(m);
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<div>
<h2 className="font-serif text-lg font-semibold text-foreground">Thành viên nhóm</h2>
<p className="text-sm text-muted-foreground">Quản thành viên, vai trò quyền truy cập của đ tài.</p>
</div>
</div>
<Button onClick={() => setInviteOpen(true)} disabled={!canEdit} title={canEdit ? '' : 'Chỉ chủ nhiệm / quản trị mới mời được'}>
<UserPlus className="mr-1.5 h-4 w-4" /> Mời thành viên
</Button>
</div>
{/* Tabs + search */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
<div className="flex gap-1">
{TABS.map((tb) => (
<button
key={tb.key}
type="button"
onClick={() => setTab(tb.key)}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition',
tab === tb.key ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-muted',
)}
>
{tb.label}
<span className={cn('rounded-full px-1.5 text-xs', tab === tb.key ? 'bg-background/20' : 'bg-muted text-muted-foreground')}>
{counts[tb.key]}
</span>
</button>
))}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Tìm tên, email, vai trò" className="h-9 w-60 pl-8" />
</div>
</div>
{/* Table */}
{visible.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-14 text-center">
<Users className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">{query ? 'Không tìm thấy' : `Chưa có ${TABS.find((t) => t.key === tab)?.label.toLowerCase()}`}</p>
{!query && tab === 'pending' && canEdit && (
<Button variant="outline" size="sm" className="mt-3" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-1.5 h-4 w-4" /> Mời thành viên
</Button>
)}
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thành viên</TableHead>
<TableHead>Vai trò</TableHead>
<TableHead>Quyền truy cập (RBAC)</TableHead>
<TableHead>Trạng thái</TableHead>
<TableHead>Nhiệm vụ</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{visible.map((m) => {
const name = asStr(m.name) || asStr(m.email) || '(chưa đặt tên)';
const email = asStr(m.email);
const role = asStr(m.role);
const access = asStr(m.access);
const status = asStr(m.status);
const av = avatar(name);
return (
<TableRow key={m.id}>
<TableCell>
<div className="flex items-center gap-2.5">
<span className={cn('inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold text-white', av.color)}>
{av.initials}
</span>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{name}</div>
{email && <div className="truncate text-xs text-muted-foreground">{email}</div>}
</div>
</div>
</TableCell>
{/* Role — inline editable when permitted */}
<TableCell>
{canEdit ? (
<InlineSelect value={role} options={ROLE_OPTIONS} onChange={(v) => patch(m.id, { role: v })} placeholder="Vai trò" />
) : (
<span className="text-sm text-foreground">{role || '—'}</span>
)}
</TableCell>
{/* Access (RBAC) — the headline assignment */}
<TableCell>
{canEdit ? (
<InlineSelect value={access} options={ACCESS_OPTIONS} onChange={(v) => patch(m.id, { access: v })} placeholder="Quyền" icon />
) : (
<span className="inline-flex items-center gap-1 rounded-md border border-violet-200 bg-violet-50 px-2 py-0.5 text-xs text-violet-700">
{isPiAccess(access) ? <Crown size={12} /> : <ShieldCheck size={12} />} {access || '—'}
</span>
)}
</TableCell>
<TableCell>
<StatusBadge value={status} />
</TableCell>
<TableCell className="max-w-[220px]">
<span className="line-clamp-2 text-sm text-muted-foreground" title={asStr(m.tasks)}>
{asStr(m.tasks) || '—'}
</span>
</TableCell>
<TableCell className="text-right">
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{status !== STATUS.active && (
<DropdownMenuItem onClick={() => patch(m.id, { status: STATUS.active })}>
<UserCheck className="mr-2 h-4 w-4" /> {status === STATUS.pending ? 'Xác nhận tham gia' : 'Kích hoạt lại'}
</DropdownMenuItem>
)}
{status === STATUS.active && (
<DropdownMenuItem onClick={() => patch(m.id, { status: STATUS.paused })}>
<X className="mr-2 h-4 w-4" /> Tạm dừng
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openEdit(m)}>
<Pencil className="mr-2 h-4 w-4" /> Sửa đy đ
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => delMut.mutate(m.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {status === STATUS.pending ? 'Hủy lời mời' : 'Xóa khỏi nhóm'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{!canEdit && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Shield className="h-3.5 w-3.5" /> Chỉ chủ nhiệm đ tài hoặc quản trị viên mới chỉnh sửa đưc nhóm.
</p>
)}
<InviteDialog
open={inviteOpen}
saving={saveMut.isPending}
existingEmails={members.map((m) => asStr(m.email).toLowerCase()).filter(Boolean)}
onClose={() => setInviteOpen(false)}
onSend={(data) => {
saveMut.mutate(
{ data: { ...data, status: STATUS.pending } },
{
onSuccess: () => {
toast.success(`Đã mời ${data.email}`);
setInviteOpen(false);
setTab('pending');
},
},
);
}}
/>
{editing && (
<EntityDrawer
open
isNew={false}
config={ENTITIES.members}
draft={draft}
saving={saveMut.isPending}
onChange={(key, value) => setDraft((d) => ({ ...d, [key]: value }))}
onClose={() => setEditing(null)}
onSave={() =>
saveMut.mutate({ id: editing.id, data: draft }, { onSuccess: () => { toast.success('Đã lưu'); setEditing(null); } })
}
/>
)}
</div>
);
}
/** A compact inline select for editing a row's role / access in place. */
function InlineSelect({
value,
options,
onChange,
placeholder,
icon,
}: {
value: string;
options: string[];
onChange: (v: string) => void;
placeholder: string;
icon?: boolean;
}) {
const opts = value && !options.includes(value) ? [value, ...options] : options;
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger className="h-8 w-44 gap-1.5">
{icon && (isPiAccess(value) ? <Crown className="h-3.5 w-3.5 text-violet-600" /> : <ShieldCheck className="h-3.5 w-3.5 text-violet-600" />)}
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Invite-a-member modal — name + email + role + access; creates a "Chờ xác nhận" member row. */
function InviteDialog({
open,
saving,
existingEmails,
onClose,
onSend,
}: {
open: boolean;
saving: boolean;
existingEmails: string[];
onClose: () => void;
onSend: (data: { name: string; email: string; role: string; access: string }) => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState(ROLE_OPTIONS[2] ?? ROLE_OPTIONS[0] ?? '');
const [access, setAccess] = useState(ACCESS_OPTIONS[ACCESS_OPTIONS.length - 1] ?? '');
const [err, setErr] = useState('');
const submit = () => {
const e = email.trim().toLowerCase();
if (!name.trim()) return setErr('Vui lòng nhập họ tên.');
if (!validEmail(e)) return setErr('Email không hợp lệ.');
if (existingEmails.includes(e)) return setErr('Email này đã có trong nhóm hoặc đang chờ xác nhận.');
onSend({ name: name.trim(), email: e, role, access });
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
else {
setName('');
setEmail('');
setRole(ROLE_OPTIONS[2] ?? ROLE_OPTIONS[0] ?? '');
setAccess(ACCESS_OPTIONS[ACCESS_OPTIONS.length - 1] ?? '');
setErr('');
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-serif">Mời thành viên</DialogTitle>
</DialogHeader>
<div className="space-y-3.5 py-1">
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Họ tên</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); setErr(''); }} placeholder="VD: TS. Nguyễn Văn A" />
</div>
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Email</Label>
<div className="flex items-center gap-2 rounded-md border border-input px-2.5">
<Mail className="h-4 w-4 text-muted-foreground" />
<Input
value={email}
onChange={(e) => { setEmail(e.target.value); setErr(''); }}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder="name@ump.edu.vn"
className="border-0 px-0 shadow-none focus-visible:ring-0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Vai trò</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{ROLE_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Quyền truy cập</Label>
<Select value={access} onValueChange={setAccess}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{ACCESS_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
{err && <p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{err}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>Hủy</Button>
<Button onClick={submit} disabled={saving}>
<UserPlus className="mr-1.5 h-4 w-4" /> Gửi lời mời
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default TeamManagementView;
@@ -0,0 +1,208 @@
// Cockpit entity configuration — ported from the PIProjectCockpit artifact, field keys
// aligned to the be0 research_project_* tables. Drives the entity cards + the editor dialog.
import {
Boxes,
CalendarClock,
Cpu,
Database,
Users,
type LucideIcon,
} from 'lucide-react';
export type CockpitFieldType = 'text' | 'textarea' | 'number' | 'select';
export interface CockpitField {
key: string;
label: string;
type: CockpitFieldType;
options?: string[];
default?: string;
min?: number;
max?: number;
step?: number;
}
export interface EntityConfig {
icon: LucideIcon;
title: string;
singular: string;
accent: string;
primary: string;
secondary: [string, string][];
badge: string;
accessKey?: string;
metrics?: [string, string][];
progress?: string;
fields: CockpitField[];
}
const sel = (key: string, label: string, options: string[], def?: string): CockpitField => ({
key,
label,
type: 'select',
options,
default: def,
});
const txt = (key: string, label: string): CockpitField => ({ key, label, type: 'text' });
const area = (key: string, label: string): CockpitField => ({ key, label, type: 'textarea' });
const num = (key: string, label: string, extra: Partial<CockpitField> = {}): CockpitField => ({
key,
label,
type: 'number',
...extra,
});
const ACCESS_LABELS = ['Chủ nhiệm (PI)', 'Điều phối / Thư ký', 'Kỹ sư ML', 'CTV lâm sàng', 'Quan sát'];
export const ENTITIES: Record<string, EntityConfig> = {
members: {
icon: Users,
title: 'Thành viên',
singular: 'thành viên',
accent: 'text-sky-600',
primary: 'name',
secondary: [
['role', ''],
['org', ''],
],
badge: 'status',
accessKey: 'access',
fields: [
txt('name', 'Họ và tên, học vị'),
sel('role', 'Vai trò trong đề tài', ['Chủ nhiệm đề tài', 'Thư ký khoa học', 'Thành viên chính', 'Kỹ thuật viên', 'Cộng tác viên'], 'Thành viên chính'),
sel('access', 'Quyền truy cập (RBAC)', ACCESS_LABELS, 'Quan sát'),
txt('org', 'Tổ chức công tác'),
txt('email', 'E-mail'),
num('months', 'Thời gian tham gia (tháng)', { min: 0 }),
area('tasks', 'Nhiệm vụ phụ trách'),
sel('status', 'Trạng thái', ['Đang hoạt động', 'Chờ xác nhận', 'Tạm dừng'], 'Đang hoạt động'),
],
},
datasets: {
icon: Database,
title: 'Dữ liệu',
singular: 'bộ dữ liệu',
accent: 'text-teal-600',
primary: 'name',
secondary: [
['type', ''],
['records', ' bản ghi'],
],
badge: 'status',
fields: [
txt('name', 'Tên bộ dữ liệu'),
sel('type', 'Loại dữ liệu', ['Ảnh CLVT', 'Nhãn phân đoạn', 'Dữ liệu lâm sàng', 'Bảng số liệu'], 'Ảnh CLVT'),
num('records', 'Số lượng bản ghi', { min: 0 }),
txt('source', 'Nguồn / nơi thu thập'),
sel('sensitivity', 'Mức nhạy cảm', ['PII', 'Đã ẩn danh', 'Công khai'], 'Đã ẩn danh'),
sel('ethics', 'Phê duyệt y đức', ['Đã duyệt', 'Chờ duyệt', 'Không yêu cầu'], 'Chờ duyệt'),
txt('owner', 'Người phụ trách'),
sel('status', 'Trạng thái', ['Đang thu thập', 'Đang làm sạch', 'Sẵn sàng', 'Khóa'], 'Đang thu thập'),
],
},
models: {
icon: Cpu,
title: 'Mô hình',
singular: 'mô hình',
accent: 'text-violet-600',
primary: 'name',
secondary: [
['task', ''],
['version', ''],
],
badge: 'status',
metrics: [
['auc', 'AUC'],
['sensitivity', 'Độ nhạy'],
['specificity', 'Độ đặc hiệu'],
['accuracy', 'Độ chính xác'],
],
fields: [
txt('name', 'Tên mô hình'),
txt('task', 'Bài toán'),
sel('framework', 'Framework', ['PyTorch', 'TensorFlow', 'MONAI', 'nnU-Net', 'scikit-learn'], 'PyTorch'),
txt('version', 'Phiên bản'),
txt('dataset', 'Bộ dữ liệu sử dụng'),
num('auc', 'AUC', { min: 0, max: 1, step: 0.01 }),
num('sensitivity', 'Độ nhạy', { min: 0, max: 1, step: 0.01 }),
num('specificity', 'Độ đặc hiệu', { min: 0, max: 1, step: 0.01 }),
num('accuracy', 'Độ chính xác', { min: 0, max: 1, step: 0.01 }),
txt('owner', 'Người phụ trách'),
area('notes', 'Ghi chú'),
sel('status', 'Trạng thái', ['Kế hoạch', 'Đang huấn luyện', 'Đang đánh giá', 'Đã triển khai'], 'Kế hoạch'),
],
},
assets: {
icon: Boxes,
title: 'Tài sản',
singular: 'tài sản',
accent: 'text-amber-600',
primary: 'name',
secondary: [
['category', ''],
['acquisition', ''],
],
badge: 'status',
fields: [
txt('name', 'Tên tài sản'),
sel('category', 'Phân loại', ['Thiết bị', 'Phần mềm', 'Giấy phép', 'Sản phẩm bàn giao'], 'Thiết bị'),
sel('acquisition', 'Hình thức', ['Hiện có', 'Điều chuyển', 'Thuê', 'Mua mới'], 'Hiện có'),
num('value', 'Giá trị (triệu đồng)', { min: 0 }),
txt('owner', 'Người quản lý'),
area('notes', 'Thông số / ghi chú'),
sel('status', 'Trạng thái', ['Đang dùng', 'Đặt mua', 'Bảo trì', 'Ngừng'], 'Đang dùng'),
],
},
milestones: {
icon: CalendarClock,
title: 'Tiến độ',
singular: 'mốc tiến độ',
accent: 'text-rose-600',
primary: 'title',
secondary: [
['start', ''],
['end', ''],
],
badge: 'status',
progress: 'progress',
fields: [
txt('title', 'Nội dung / công việc'),
area('deliverable', 'Kết quả phải đạt'),
txt('start', 'Bắt đầu (tháng/năm)'),
txt('end', 'Kết thúc (tháng/năm)'),
txt('owner', 'Chủ trì'),
num('budget', 'Kinh phí (triệu đồng)', { min: 0 }),
num('progress', 'Tiến độ (%)', { min: 0, max: 100, step: 5 }),
sel('status', 'Trạng thái', ['Chưa bắt đầu', 'Đang thực hiện', 'Hoàn thành', 'Trễ hạn'], 'Chưa bắt đầu'),
],
},
};
export const ENTITY_ORDER = ['members', 'datasets', 'models', 'assets', 'milestones'] as const;
export type CockpitEntityKey = (typeof ENTITY_ORDER)[number];
// Status → tone (semantic colors for the dashboard badges).
export type Tone = 'emerald' | 'blue' | 'violet' | 'amber' | 'rose' | 'slate';
const STATUS_TONE: Record<string, Tone> = {
'Sẵn sàng': 'emerald', 'Đã triển khai': 'emerald', 'Hoàn thành': 'emerald', 'Đã duyệt': 'emerald',
'Đang dùng': 'emerald', 'Đang hoạt động': 'emerald',
'Đang đánh giá': 'blue', 'Đang thực hiện': 'blue',
'Đang huấn luyện': 'violet',
'Đang thu thập': 'amber', 'Đang làm sạch': 'amber', 'Chờ duyệt': 'amber', 'Đặt mua': 'amber',
'Bảo trì': 'amber', 'Chưa bắt đầu': 'amber', 'Kế hoạch': 'amber', 'Chờ xác nhận': 'amber', 'Thuê': 'amber',
'Trễ hạn': 'rose', 'Khóa': 'rose', 'PII': 'rose', 'Ngừng': 'rose', 'Tạm dừng': 'rose',
};
const TONE_CLASS: Record<Tone, string> = {
emerald: 'bg-emerald-100 text-emerald-700 border-emerald-200',
blue: 'bg-blue-100 text-blue-700 border-blue-200',
violet: 'bg-violet-100 text-violet-700 border-violet-200',
amber: 'bg-amber-100 text-amber-700 border-amber-200',
rose: 'bg-rose-100 text-rose-700 border-rose-200',
slate: 'bg-slate-100 text-slate-600 border-slate-200',
};
export const toneClass = (v: unknown): string => TONE_CLASS[STATUS_TONE[String(v)] ?? 'slate'];
export const fmt = (n: unknown): string => (Number(n) || 0).toLocaleString('vi-VN');
export const pct = (n: unknown): number => (n == null ? 0 : Math.max(0, Math.min(100, Number(n))));
export const asNum = (n: unknown): number => Number(n) || 0;
export const asStr = (v: unknown): string => (v == null ? '' : String(v));
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
DETAIL_SECTIONS,
SECTION_BY_TAB,
displayValue,
fmtMoney,
readContent,
type DetailField,
} from './detailConfig';
describe('detailConfig schema', () => {
it('has the five administrative sections in order with unique tab ids', () => {
expect(DETAIL_SECTIONS.map((s) => s.tab)).toEqual([
'general',
'contract',
'funding',
'timeline',
'acceptance',
]);
expect(DETAIL_SECTIONS.map((s) => s.index)).toEqual(['I', 'II', 'III', 'IV', 'V']);
});
it('SECTION_BY_TAB resolves every section', () => {
for (const s of DETAIL_SECTIONS) expect(SECTION_BY_TAB[s.tab]).toBe(s);
});
it('field keys are globally unique (no content-key collisions across sections)', () => {
const keys = DETAIL_SECTIONS.flatMap((s) => s.fields.map((f) => f.key));
expect(new Set(keys).size).toBe(keys.length);
});
it('every select field declares options', () => {
const selects = DETAIL_SECTIONS.flatMap((s) => s.fields).filter((f) => f.type === 'select');
expect(selects.length).toBeGreaterThan(0);
for (const f of selects) expect(f.options && f.options.length).toBeTruthy();
});
it('reuses the proposal keys that the BE promotes to columns', () => {
const keys = new Set(DETAIL_SECTIONS.flatMap((s) => s.fields.map((f) => f.key)));
for (const k of ['tenDeTai', 'chuNhiem.hoTen', 'tongKinhPhi', 'thoiGianThucHienThang', 'capDeTai']) {
expect(keys.has(k)).toBe(true);
}
});
});
describe('display helpers', () => {
const money: DetailField = { key: 'x', label: '', type: 'money' };
const months: DetailField = { key: 'x', label: '', type: 'months' };
const text: DetailField = { key: 'x', label: '', type: 'text' };
it('fmtMoney groups vi-VN and appends VNĐ', () => {
expect(fmtMoney(29900000)).toBe('29.900.000 VNĐ');
expect(fmtMoney('25600000')).toBe('25.600.000 VNĐ');
expect(fmtMoney('')).toBe('');
expect(fmtMoney(0)).toBe('');
expect(fmtMoney('abc')).toBe('');
});
it('displayValue formats by type and returns empty string for blanks', () => {
expect(displayValue(money, 29900000)).toBe('29.900.000 VNĐ');
expect(displayValue(months, 24)).toBe('24 tháng');
expect(displayValue(text, 'Dược')).toBe('Dược');
expect(displayValue(text, '')).toBe('');
expect(displayValue(text, null)).toBe('');
expect(displayValue(text, undefined)).toBe('');
});
});
describe('readContent', () => {
it('reads flat dotted keys directly', () => {
const content = { tenDeTai: 'A', 'chuNhiem.hoTen': 'Lâm', tongKinhPhi: 300 };
expect(readContent(content, 'chuNhiem.hoTen')).toBe('Lâm');
expect(readContent(content, 'tongKinhPhi')).toBe(300);
expect(readContent(content, 'missing')).toBeUndefined();
expect(readContent(undefined, 'x')).toBeUndefined();
});
});
@@ -0,0 +1,149 @@
// Administrative-detail schema for the project cockpit — mirrors the institutional
// "đề tài" detail sheet (Thông tin chung / Hợp đồng / Kinh phí / Thời gian / Nghiệm thu).
// One config drives BOTH the read-only section panels AND the edit dialog.
//
// Storage: every `key` is a flat (optionally dotted) key inside ResearchProject.content
// JSONB. Some keys are REUSED from the proposal form (tenDeTai, chuNhiem.hoTen, tongKinhPhi,
// kinhPhiKhoan, kinhPhiKhongKhoan, thoiGianThucHienThang, capDeTai, loaiHinhDeTai, maSo);
// the rest are net-new administrative fields filled in at cockpit time. Edits are persisted
// via updateProjectDetail() which shallow-merges, so the proposal keys are never clobbered.
export type DetailFieldType = 'text' | 'textarea' | 'number' | 'money' | 'months' | 'date' | 'select';
export interface DetailField {
key: string;
label: string;
type: DetailFieldType;
options?: string[];
full?: boolean;
}
export interface DetailSection {
index: string; // roman numeral shown in the panel header
tab: string; // tab id this section renders under
title: string; // panel + tab title
fields: DetailField[];
}
const STATUS_OPTIONS = ['Chưa có', 'Đang thực hiện', 'Tạm dừng', 'Hoàn thành', 'Trễ hạn'];
const GRADE_OPTIONS = ['Xuất sắc', 'Tốt', 'Khá', 'Đạt', 'Không đạt'];
/** Section → tab id. Keep in sync with the cockpit tab list. */
export const DETAIL_TABS = {
general: 'general',
contract: 'contract',
funding: 'funding',
timeline: 'timeline',
acceptance: 'acceptance',
} as const;
export const DETAIL_SECTIONS: DetailSection[] = [
{
index: 'I',
tab: 'general',
title: 'Thông tin chung',
fields: [
{ key: 'tenDeTai', label: 'Tên đề tài', type: 'text', full: true },
{ key: 'chuNhiem.hoTen', label: 'Chủ nhiệm', type: 'text' },
{ key: 'chuNhiem.gioiTinh', label: 'Giới tính', type: 'text' },
{ key: 'chuNhiem.namSinh', label: 'Năm sinh', type: 'text' },
{ key: 'khoaDonVi', label: 'Khoa / Đơn vị', type: 'text' },
{ key: 'boMon', label: 'Bộ môn', type: 'text' },
{ key: 'linhVucNC', label: 'Lĩnh vực NC', type: 'text' },
{ key: 'loaiHinhDeTai', label: 'Loại hình NC', type: 'text' },
{ key: 'capDeTai', label: 'Loại đề tài', type: 'text' },
{ key: 'thanhVienNC', label: 'Thành viên NC', type: 'textarea', full: true },
],
},
{
index: 'II',
tab: 'contract',
title: 'Hợp đồng & Quyết định',
fields: [
{ key: 'soHopDong', label: 'Số hợp đồng', type: 'text' },
{ key: 'phuLucHopDong', label: 'Phụ lục hợp đồng', type: 'text' },
{ key: 'ngayKyHopDong', label: 'Ngày ký HĐ', type: 'date' },
{ key: 'qdXetDuyet', label: 'QĐ xét duyệt', type: 'text' },
{ key: 'qdPheDuyet', label: 'QĐ phê duyệt', type: 'text' },
{ key: 'soGCNKetQua', label: 'Số GCN kết quả', type: 'text' },
{ key: 'ngayCapGCN', label: 'Ngày cấp GCN', type: 'date' },
{ key: 'coQuanCapGCN', label: 'Cơ quan cấp GCN', type: 'text' },
],
},
{
index: 'III',
tab: 'funding',
title: 'Kinh phí & Phân bổ',
fields: [
{ key: 'tongKinhPhi', label: 'Tổng kinh phí', type: 'money' },
{ key: 'kinhPhiKhoan', label: 'Kinh phí khoán', type: 'money' },
{ key: 'kinhPhiKhongKhoan', label: 'Kinh phí không khoán', type: 'money' },
{ key: 'nguonKhac', label: 'Nguồn khác', type: 'money' },
{ key: 'capDot1', label: 'Cấp đợt 1', type: 'money' },
{ key: 'capDot2', label: 'Cấp đợt 2', type: 'money' },
{ key: 'capDot3', label: 'Cấp đợt 3', type: 'money' },
],
},
{
index: 'IV',
tab: 'timeline',
title: 'Thời gian & Tiến độ',
fields: [
{ key: 'thoiGianThucHienThang', label: 'Thời gian TH', type: 'months' },
{ key: 'thoiGianBatDau', label: 'Bắt đầu', type: 'date' },
{ key: 'thoiGianKetThuc', label: 'Kết thúc', type: 'date' },
{ key: 'giaHan', label: 'Gia hạn', type: 'text' },
{ key: 'trangThaiTienDo', label: 'Tiến độ thực hiện', type: 'select', options: STATUS_OPTIONS },
{ key: 'baoCaoGiamDinh', label: 'Báo cáo giám định', type: 'date' },
{ key: 'bcTienDo1', label: 'BC tiến độ 1', type: 'date' },
{ key: 'bcTienDo2', label: 'BC tiến độ 2', type: 'date' },
{ key: 'bcTienDo3', label: 'BC tiến độ 3', type: 'date' },
{ key: 'bcTienDo4', label: 'BC tiến độ 4', type: 'date' },
{ key: 'ngayNhac', label: 'Ngày nhắc', type: 'date' },
{ key: 'ghiChuBaoCao', label: 'Ghi chú báo cáo', type: 'textarea', full: true },
],
},
{
index: 'V',
tab: 'acceptance',
title: 'Nghiệm thu',
fields: [
{ key: 'ngayNghiemThu', label: 'Ngày nghiệm thu', type: 'date' },
{ key: 'soQDNghiemThu', label: 'Số QĐ nghiệm thu', type: 'text' },
{ key: 'xepLoaiNghiemThu', label: 'Xếp loại', type: 'select', options: GRADE_OPTIONS },
{ key: 'hoiDongNghiemThu', label: 'Hội đồng nghiệm thu', type: 'textarea', full: true },
{ key: 'ketQuaNghiemThu', label: 'Kết quả nghiệm thu', type: 'textarea', full: true },
{ key: 'ghiChuNghiemThu', label: 'Ghi chú', type: 'textarea', full: true },
],
},
];
export const SECTION_BY_TAB: Record<string, DetailSection> = Object.fromEntries(
DETAIL_SECTIONS.map((s) => [s.tab, s]),
);
/** Read a (possibly dotted) content key. Keys in content are flat strings, so a direct lookup. */
export function readContent(content: Record<string, unknown> | undefined, key: string): unknown {
if (!content) return undefined;
return content[key];
}
/** Money in triệu/đồng → grouped string. Accepts number or numeric string. */
export function fmtMoney(v: unknown): string {
const n = Number(v);
if (!v || Number.isNaN(n)) return '';
return n.toLocaleString('vi-VN') + ' VNĐ';
}
/** Format a field value for the READ panel per its type. Returns '' when empty (panel shows ---). */
export function displayValue(field: DetailField, raw: unknown): string {
if (raw === undefined || raw === null || raw === '') return '';
switch (field.type) {
case 'money':
return fmtMoney(raw);
case 'months':
return `${raw} tháng`;
default:
return String(raw);
}
}