Polish entity pages: groups, favourite toggle, copy icon, initials, notes

- EntityTable: replace flat rows with grouped sections (label + rows),
  each group gets its own section header for visual clarity
- EntityDetailPanel: add favourite/frequent toggle star button in header,
  add multiline flag for notes with whitespace-pre-wrap
- CopyableField: use inline-flex to keep copy icon close to text
- PeoplePage: compute initials from first_name/last_name (not nickname),
  build row groups from active filters, add toggle favourite mutation
- LocationsPage: build row groups from active filters, add toggle
  frequent mutation, pass favourite props to detail panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-25 00:21:23 +08:00
parent 1b78dadf75
commit 1231c4b36d
6 changed files with 189 additions and 88 deletions

View File

@ -56,6 +56,19 @@ export default function LocationsPage() {
}, },
}); });
// Toggle frequent mutation
const toggleFrequentMutation = useMutation({
mutationFn: async (loc: Location) => {
await api.put(`/locations/${loc.id}`, { is_frequent: !loc.is_frequent });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update frequent'));
},
});
const allCategories = useMemo( const allCategories = useMemo(
() => Array.from(new Set(locations.map((l) => l.category).filter(Boolean))).sort(), () => Array.from(new Set(locations.map((l) => l.category).filter(Boolean))).sort(),
[locations] [locations]
@ -94,6 +107,20 @@ export default function LocationsPage() {
[sortedLocations, activeFilters, search, showPinned] [sortedLocations, activeFilters, search, showPinned]
); );
// Build row groups for the table
const groups = useMemo(() => {
if (activeFilters.length <= 1) {
const label = activeFilters.length === 1 ? activeFilters[0] : 'All';
return [{ label, rows: filteredLocations }];
}
return activeFilters
.map((cat) => ({
label: cat,
rows: filteredLocations.filter((l) => l.category === cat),
}))
.filter((g) => g.rows.length > 0);
}, [activeFilters, filteredLocations]);
const selectedLocation = useMemo( const selectedLocation = useMemo(
() => locations.find((l) => l.id === selectedLocationId) ?? null, () => locations.find((l) => l.id === selectedLocationId) ?? null,
[locations, selectedLocationId] [locations, selectedLocationId]
@ -209,7 +236,7 @@ export default function LocationsPage() {
{ label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone }, { label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail }, { label: 'Email', key: 'email', copyable: true, icon: Mail },
{ label: 'Category', key: 'category' }, { label: 'Category', key: 'category' },
{ label: 'Notes', key: 'notes' }, { label: 'Notes', key: 'notes', multiline: true },
]; ];
const renderPanel = () => ( const renderPanel = () => (
@ -238,6 +265,9 @@ export default function LocationsPage() {
getValue={(l, key) => getValue={(l, key) =>
(l[key as keyof Location] as string | undefined) ?? undefined (l[key as keyof Location] as string | undefined) ?? undefined
} }
isFavourite={selectedLocation?.is_frequent}
onToggleFavourite={() => selectedLocation && toggleFrequentMutation.mutate(selectedLocation)}
favouriteLabel="frequent"
/> />
); );
@ -281,7 +311,7 @@ export default function LocationsPage() {
{isLoading ? ( {isLoading ? (
<EntityTable<Location> <EntityTable<Location>
columns={columns} columns={columns}
rows={[]} groups={[]}
pinnedRows={[]} pinnedRows={[]}
pinnedLabel="Frequent" pinnedLabel="Frequent"
showPinned={false} showPinned={false}
@ -304,7 +334,7 @@ export default function LocationsPage() {
) : ( ) : (
<EntityTable<Location> <EntityTable<Location>
columns={columns} columns={columns}
rows={filteredLocations} groups={groups}
pinnedRows={frequentLocations} pinnedRows={frequentLocations}
pinnedLabel="Frequent" pinnedLabel="Frequent"
showPinned={showPinned} showPinned={showPinned}

View File

@ -53,8 +53,13 @@ function StatCounter({
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sort helper // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getPersonInitialsName(p: Person): string {
const parts = [p.first_name, p.last_name].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : p.name;
}
function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[] { function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[] {
return [...people].sort((a, b) => { return [...people].sort((a, b) => {
let cmp = 0; let cmp = 0;
@ -82,16 +87,19 @@ const columns: ColumnDef<Person>[] = [
label: 'Name', label: 'Name',
sortable: true, sortable: true,
visibilityLevel: 'essential', visibilityLevel: 'essential',
render: (p) => ( render: (p) => {
<div className="flex items-center gap-2.5"> const initialsName = getPersonInitialsName(p);
<div return (
className={`h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${getAvatarColor(p.name)}`} <div className="flex items-center gap-2.5">
> <div
{getInitials(p.name)} className={`h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${getAvatarColor(initialsName)}`}
>
{getInitials(initialsName)}
</div>
<span className="font-medium truncate">{p.nickname || p.name}</span>
</div> </div>
<span className="font-medium truncate">{p.nickname || p.name}</span> );
</div> },
),
}, },
{ {
key: 'phone', key: 'phone',
@ -163,7 +171,7 @@ const panelFields: PanelField[] = [
{ label: 'Category', key: 'category' }, { label: 'Category', key: 'category' },
{ label: 'Company', key: 'company' }, { label: 'Company', key: 'company' },
{ label: 'Job Title', key: 'job_title' }, { label: 'Job Title', key: 'job_title' },
{ label: 'Notes', key: 'notes' }, { label: 'Notes', key: 'notes', multiline: true },
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -193,7 +201,6 @@ export default function PeoplePage() {
const panelOpen = selectedPersonId !== null; const panelOpen = selectedPersonId !== null;
const visibilityMode = useTableVisibility(tableContainerRef, panelOpen); const visibilityMode = useTableVisibility(tableContainerRef, panelOpen);
// Unique categories (only those with at least one member)
const allCategories = useMemo(() => { const allCategories = useMemo(() => {
const cats = new Set<string>(); const cats = new Set<string>();
people.forEach((p) => { if (p.category) cats.add(p.category); }); people.forEach((p) => { if (p.category) cats.add(p.category); });
@ -232,6 +239,20 @@ export default function PeoplePage() {
return sortPeople(list, sortKey, sortDir); return sortPeople(list, sortKey, sortDir);
}, [people, showPinned, activeFilters, search, sortKey, sortDir]); }, [people, showPinned, activeFilters, search, sortKey, sortDir]);
// Build row groups for the table
const groups = useMemo(() => {
if (activeFilters.length <= 1) {
const label = activeFilters.length === 1 ? activeFilters[0] : 'All';
return [{ label, rows: filteredPeople }];
}
return activeFilters
.map((cat) => ({
label: cat,
rows: filteredPeople.filter((p) => p.category === cat),
}))
.filter((g) => g.rows.length > 0);
}, [activeFilters, filteredPeople]);
// Stats // Stats
const totalCount = people.length; const totalCount = people.length;
const favouriteCount = people.filter((p) => p.is_favourite).length; const favouriteCount = people.filter((p) => p.is_favourite).length;
@ -285,6 +306,19 @@ export default function PeoplePage() {
}, },
}); });
// Toggle favourite mutation
const toggleFavouriteMutation = useMutation({
mutationFn: async (person: Person) => {
await api.put(`/people/${person.id}`, { is_favourite: !person.is_favourite });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update favourite'));
},
});
// Escape key closes detail panel // Escape key closes detail panel
useEffect(() => { useEffect(() => {
if (!panelOpen) return; if (!panelOpen) return;
@ -301,21 +335,24 @@ export default function PeoplePage() {
}; };
// Panel header renderer (shared between desktop and mobile) // Panel header renderer (shared between desktop and mobile)
const renderPersonHeader = (p: Person) => ( const renderPersonHeader = (p: Person) => {
<div className="flex items-center gap-3"> const initialsName = getPersonInitialsName(p);
<div return (
className={`h-10 w-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${getAvatarColor(p.name)}`} <div className="flex items-center gap-3">
> <div
{getInitials(p.name)} className={`h-10 w-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${getAvatarColor(initialsName)}`}
>
{getInitials(initialsName)}
</div>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
</div>
</div> </div>
<div className="min-w-0"> );
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3> };
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
</div>
</div>
);
// Panel getValue // Panel getValue
const getPanelValue = (p: Person, key: string): string | undefined => { const getPanelValue = (p: Person, key: string): string | undefined => {
@ -327,6 +364,26 @@ export default function PeoplePage() {
return val != null ? String(val) : undefined; return val != null ? String(val) : undefined;
}; };
const renderPanel = () => (
<EntityDetailPanel<Person>
item={selectedPerson}
fields={panelFields}
onEdit={() => {
setEditingPerson(selectedPerson);
setShowForm(true);
}}
onDelete={() => deleteMutation.mutate()}
deleteLoading={deleteMutation.isPending}
onClose={() => setSelectedPersonId(null)}
renderHeader={renderPersonHeader}
getUpdatedAt={(p) => p.updated_at}
getValue={getPanelValue}
isFavourite={selectedPerson?.is_favourite}
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
favouriteLabel="favourite"
/>
);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
@ -407,7 +464,7 @@ export default function PeoplePage() {
{isLoading ? ( {isLoading ? (
<EntityTable<Person> <EntityTable<Person>
columns={columns} columns={columns}
rows={[]} groups={[]}
pinnedRows={[]} pinnedRows={[]}
pinnedLabel="Favourites" pinnedLabel="Favourites"
showPinned={false} showPinned={false}
@ -430,7 +487,7 @@ export default function PeoplePage() {
) : ( ) : (
<EntityTable<Person> <EntityTable<Person>
columns={columns} columns={columns}
rows={filteredPeople} groups={groups}
pinnedRows={showPinned ? favourites : []} pinnedRows={showPinned ? favourites : []}
pinnedLabel="Favourites" pinnedLabel="Favourites"
showPinned={showPinned} showPinned={showPinned}
@ -453,20 +510,7 @@ export default function PeoplePage() {
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0' panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`} }`}
> >
<EntityDetailPanel<Person> {renderPanel()}
item={selectedPerson}
fields={panelFields}
onEdit={() => {
setEditingPerson(selectedPerson);
setShowForm(true);
}}
onDelete={() => deleteMutation.mutate()}
deleteLoading={deleteMutation.isPending}
onClose={() => setSelectedPersonId(null)}
renderHeader={renderPersonHeader}
getUpdatedAt={(p) => p.updated_at}
getValue={getPanelValue}
/>
</div> </div>
</div> </div>
</div> </div>
@ -475,20 +519,7 @@ export default function PeoplePage() {
{panelOpen && selectedPerson && ( {panelOpen && selectedPerson && (
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"> <div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"> <div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
<EntityDetailPanel<Person> {renderPanel()}
item={selectedPerson}
fields={panelFields}
onEdit={() => {
setEditingPerson(selectedPerson);
setShowForm(true);
}}
onDelete={() => deleteMutation.mutate()}
deleteLoading={deleteMutation.isPending}
onClose={() => setSelectedPersonId(null)}
renderHeader={renderPersonHeader}
getUpdatedAt={(p) => p.updated_at}
getValue={getPanelValue}
/>
</div> </div>
</div> </div>
)} )}

View File

@ -20,9 +20,9 @@ export default function CopyableField({ value, icon: Icon, label }: CopyableFiel
}; };
return ( return (
<div className="group flex items-center gap-2"> <div className="group inline-flex items-center gap-2 max-w-full">
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />} {Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="text-sm truncate flex-1">{value}</span> <span className="text-sm truncate">{value}</span>
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}

View File

@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { X, Pencil, Trash2 } from 'lucide-react'; import { X, Pencil, Trash2, Star, StarOff } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useConfirmAction } from '@/hooks/useConfirmAction';
@ -11,6 +11,7 @@ export interface PanelField {
key: string; key: string;
copyable?: boolean; copyable?: boolean;
icon?: LucideIcon; icon?: LucideIcon;
multiline?: boolean;
} }
interface EntityDetailPanelProps<T> { interface EntityDetailPanelProps<T> {
@ -23,6 +24,9 @@ interface EntityDetailPanelProps<T> {
renderHeader: (item: T) => React.ReactNode; renderHeader: (item: T) => React.ReactNode;
getUpdatedAt: (item: T) => string; getUpdatedAt: (item: T) => string;
getValue: (item: T, key: string) => string | undefined; getValue: (item: T, key: string) => string | undefined;
isFavourite?: boolean;
onToggleFavourite?: () => void;
favouriteLabel?: string;
} }
export function EntityDetailPanel<T>({ export function EntityDetailPanel<T>({
@ -35,6 +39,9 @@ export function EntityDetailPanel<T>({
renderHeader, renderHeader,
getUpdatedAt, getUpdatedAt,
getValue, getValue,
isFavourite,
onToggleFavourite,
favouriteLabel = 'favourite',
}: EntityDetailPanelProps<T>) { }: EntityDetailPanelProps<T>) {
const executeDelete = useCallback(() => onDelete(), [onDelete]); const executeDelete = useCallback(() => onDelete(), [onDelete]);
const { confirming, handleClick: handleDelete } = useConfirmAction(executeDelete); const { confirming, handleClick: handleDelete } = useConfirmAction(executeDelete);
@ -46,15 +53,32 @@ export function EntityDetailPanel<T>({
{/* Header */} {/* Header */}
<div className="px-5 py-4 border-b border-border flex items-start justify-between"> <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-1 min-w-0">{renderHeader(item)}</div>
<Button <div className="flex items-center gap-1 shrink-0 ml-2">
variant="ghost" {onToggleFavourite && (
size="icon" <Button
onClick={onClose} variant="ghost"
aria-label="Close panel" size="icon"
className="h-7 w-7 shrink-0 ml-2" onClick={onToggleFavourite}
> aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
<X className="h-4 w-4" /> className={`h-7 w-7 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
</Button> >
{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> </div>
{/* Body */} {/* Body */}
@ -76,7 +100,7 @@ export function EntityDetailPanel<T>({
<p className="text-[11px] uppercase tracking-wider text-muted-foreground"> <p className="text-[11px] uppercase tracking-wider text-muted-foreground">
{field.label} {field.label}
</p> </p>
<p className="text-sm">{value}</p> <p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap' : ''}`}>{value}</p>
</div> </div>
)} )}
</div> </div>

View File

@ -1,3 +1,4 @@
import React from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility'; import type { VisibilityMode } from '@/hooks/useTableVisibility';
@ -9,9 +10,14 @@ export interface ColumnDef<T> {
visibilityLevel: VisibilityMode; visibilityLevel: VisibilityMode;
} }
export interface RowGroup<T> {
label: string;
rows: T[];
}
interface EntityTableProps<T extends { id: number }> { interface EntityTableProps<T extends { id: number }> {
columns: ColumnDef<T>[]; columns: ColumnDef<T>[];
rows: T[]; groups: RowGroup<T>[];
pinnedRows: T[]; pinnedRows: T[];
pinnedLabel: string; pinnedLabel: string;
showPinned: boolean; showPinned: boolean;
@ -44,7 +50,7 @@ function SkeletonRow({ colCount }: { colCount: number }) {
export function EntityTable<T extends { id: number }>({ export function EntityTable<T extends { id: number }>({
columns, columns,
rows, groups,
pinnedRows, pinnedRows,
pinnedLabel, pinnedLabel,
showPinned, showPinned,
@ -93,6 +99,17 @@ export function EntityTable<T extends { id: number }>({
</tr> </tr>
); );
const SectionHeader = ({ label }: { label: string }) => (
<tr>
<td
colSpan={colCount}
className="px-3 pt-4 pb-1.5 text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
>
{label}
</td>
</tr>
);
return ( return (
<div className="w-full"> <div className="w-full">
<table className="w-full border-collapse"> <table className="w-full border-collapse">
@ -127,24 +144,23 @@ export function EntityTable<T extends { id: number }>({
<> <>
{showPinnedSection && ( {showPinnedSection && (
<> <>
<tr> <SectionHeader label={pinnedLabel} />
<td
colSpan={colCount}
className="px-3 py-1.5 text-[11px] uppercase tracking-wider text-muted-foreground"
>
{pinnedLabel}
</td>
</tr>
{pinnedRows.map((item) => ( {pinnedRows.map((item) => (
<DataRow key={item.id} item={item} /> <DataRow key={item.id} item={item} />
))} ))}
<tr>
<td colSpan={colCount} className="border-b border-border/50" />
</tr>
</> </>
)} )}
{rows.map((item) => ( {groups.map((group) => (
<DataRow key={item.id} item={item} /> <React.Fragment key={group.label}>
{group.rows.length > 0 && (
<>
<SectionHeader label={group.label} />
{group.rows.map((item) => (
<DataRow key={item.id} item={item} />
))}
</>
)}
</React.Fragment>
))} ))}
</> </>
)} )}

View File

@ -1,5 +1,5 @@
export { EntityTable } from './EntityTable'; export { EntityTable } from './EntityTable';
export type { ColumnDef } from './EntityTable'; export type { ColumnDef, RowGroup } from './EntityTable';
export { EntityDetailPanel } from './EntityDetailPanel'; export { EntityDetailPanel } from './EntityDetailPanel';
export type { PanelField } from './EntityDetailPanel'; export type { PanelField } from './EntityDetailPanel';
export { default as CategoryFilterBar } from './CategoryFilterBar'; export { default as CategoryFilterBar } from './CategoryFilterBar';