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>
180 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|