sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 1–2 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 lý thành viên, vai trò và 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ọ và 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user