UMBRA/frontend/src/components/shared/EntityDetailPanel.tsx
Kyle Pope 33aac72639 Add delete-with-sever and unlink actions for umbral contacts
Delete person now severs the bidirectional connection when the person
is an umbral contact — removes both UserConnection rows and detaches
the counterpart's Person record. Fixes "Already connected" error
when trying to reconnect after deleting an umbral contact.

New PUT /people/{id}/unlink endpoint converts an umbral contact to a
standard contact (detaches linked fields) while also severing the
bidirectional connection, keeping the Person in the contact list.

Frontend: EntityDetailPanel gains extraActions prop. PeoplePage renders
an "Unlink" button in the panel footer for umbral contacts. Delete
mutation now also invalidates connections query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:50:31 +08:00

180 lines
5.9 KiB
TypeScript

import { X, Pencil, Trash2, Star, StarOff } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from './utils';
import CopyableField from './CopyableField';
export interface PanelField {
label: string;
key: string;
copyable?: boolean;
icon?: LucideIcon;
multiline?: boolean;
fullWidth?: boolean;
}
interface EntityDetailPanelProps<T> {
item: T | null;
fields: PanelField[];
onEdit: () => void;
onDelete: () => void;
deleteLoading?: boolean;
onClose: () => void;
renderHeader: (item: T) => React.ReactNode;
getUpdatedAt: (item: T) => string;
getValue: (item: T, key: string) => string | undefined;
isFavourite?: boolean;
onToggleFavourite?: () => void;
favouriteLabel?: string;
extraActions?: (item: T) => React.ReactNode;
}
export function EntityDetailPanel<T>({
item,
fields,
onEdit,
onDelete,
deleteLoading = false,
onClose,
renderHeader,
getUpdatedAt,
getValue,
isFavourite,
onToggleFavourite,
favouriteLabel = 'favourite',
extraActions,
}: EntityDetailPanelProps<T>) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
if (!item) return null;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border flex items-start justify-between">
<div className="flex-1 min-w-0">{renderHeader(item)}</div>
<div className="flex items-center gap-1 shrink-0 ml-2">
{onToggleFavourite && (
<Button
variant="ghost"
size="icon"
onClick={onToggleFavourite}
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
className={`h-7 w-7 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
>
{isFavourite ? (
<Star className="h-4 w-4 fill-yellow-400" />
) : (
<StarOff className="h-4 w-4" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{(() => {
const gridFields = fields.filter((f) => !f.fullWidth && getValue(item, f.key));
const fullWidthFields = fields.filter((f) => f.fullWidth && getValue(item, f.key));
return (
<>
{gridFields.length > 0 && (
<div className="grid grid-cols-2 gap-3">
{gridFields.map((field) => {
const value = getValue(item, field.key)!;
return (
<div key={field.key} className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{field.icon && <field.icon className="h-3 w-3" />}
{field.label}
</div>
{field.copyable ? (
<CopyableField value={value} icon={field.icon} label={field.label} />
) : (
<p className="text-sm">{value}</p>
)}
</div>
);
})}
</div>
)}
{fullWidthFields.map((field) => {
const value = getValue(item, field.key)!;
return (
<div key={field.key} className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{field.icon && <field.icon className="h-3 w-3" />}
{field.label}
</div>
{field.copyable ? (
<CopyableField value={value} icon={field.icon} label={field.label} />
) : (
<p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap text-muted-foreground leading-relaxed' : ''}`}>
{value}
</p>
)}
</div>
);
})}
</>
);
})()}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
{extraActions?.(item)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onEdit}
aria-label="Edit"
>
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
{confirming ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Confirm delete"
className="h-8 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Delete"
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
);
}