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>
);
}