181 lines
5.3 KiB
TypeScript
181 lines
5.3 KiB
TypeScript
// 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>
|
|
);
|
|
}
|