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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user