From 1231c4b36d6f385652641cbd0c69ca38a45c828d Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 25 Feb 2026 00:21:23 +0800 Subject: [PATCH] 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 --- .../components/locations/LocationsPage.tsx | 36 ++++- frontend/src/components/people/PeoplePage.tsx | 143 +++++++++++------- .../src/components/shared/CopyableField.tsx | 4 +- .../components/shared/EntityDetailPanel.tsx | 46 ++++-- .../src/components/shared/EntityTable.tsx | 46 ++++-- frontend/src/components/shared/index.ts | 2 +- 6 files changed, 189 insertions(+), 88 deletions(-) diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx index 4d97836..9970ff1 100644 --- a/frontend/src/components/locations/LocationsPage.tsx +++ b/frontend/src/components/locations/LocationsPage.tsx @@ -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( () => Array.from(new Set(locations.map((l) => l.category).filter(Boolean))).sort(), [locations] @@ -94,6 +107,20 @@ export default function LocationsPage() { [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( () => locations.find((l) => l.id === selectedLocationId) ?? null, [locations, selectedLocationId] @@ -209,7 +236,7 @@ export default function LocationsPage() { { label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone }, { label: 'Email', key: 'email', copyable: true, icon: Mail }, { label: 'Category', key: 'category' }, - { label: 'Notes', key: 'notes' }, + { label: 'Notes', key: 'notes', multiline: true }, ]; const renderPanel = () => ( @@ -238,6 +265,9 @@ export default function LocationsPage() { getValue={(l, key) => (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 ? ( columns={columns} - rows={[]} + groups={[]} pinnedRows={[]} pinnedLabel="Frequent" showPinned={false} @@ -304,7 +334,7 @@ export default function LocationsPage() { ) : ( columns={columns} - rows={filteredLocations} + groups={groups} pinnedRows={frequentLocations} pinnedLabel="Frequent" showPinned={showPinned} diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index a48fb1c..7e21498 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -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[] { return [...people].sort((a, b) => { let cmp = 0; @@ -82,16 +87,19 @@ const columns: ColumnDef[] = [ label: 'Name', sortable: true, visibilityLevel: 'essential', - render: (p) => ( -
-
- {getInitials(p.name)} + render: (p) => { + const initialsName = getPersonInitialsName(p); + return ( +
+
+ {getInitials(initialsName)} +
+ {p.nickname || p.name}
- {p.nickname || p.name} -
- ), + ); + }, }, { key: 'phone', @@ -163,7 +171,7 @@ const panelFields: PanelField[] = [ { label: 'Category', key: 'category' }, { label: 'Company', key: 'company' }, { 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 visibilityMode = useTableVisibility(tableContainerRef, panelOpen); - // Unique categories (only those with at least one member) const allCategories = useMemo(() => { const cats = new Set(); people.forEach((p) => { if (p.category) cats.add(p.category); }); @@ -232,6 +239,20 @@ export default function PeoplePage() { return sortPeople(list, 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 const totalCount = people.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 useEffect(() => { if (!panelOpen) return; @@ -301,21 +335,24 @@ export default function PeoplePage() { }; // Panel header renderer (shared between desktop and mobile) - const renderPersonHeader = (p: Person) => ( -
-
- {getInitials(p.name)} + const renderPersonHeader = (p: Person) => { + const initialsName = getPersonInitialsName(p); + return ( +
+
+ {getInitials(initialsName)} +
+
+

{p.name}

+ {p.category && ( + {p.category} + )} +
-
-

{p.name}

- {p.category && ( - {p.category} - )} -
-
- ); + ); + }; // Panel getValue const getPanelValue = (p: Person, key: string): string | undefined => { @@ -327,6 +364,26 @@ export default function PeoplePage() { return val != null ? String(val) : undefined; }; + const 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} + isFavourite={selectedPerson?.is_favourite} + onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)} + favouriteLabel="favourite" + /> + ); + return (
{/* Header */} @@ -407,7 +464,7 @@ export default function PeoplePage() { {isLoading ? ( columns={columns} - rows={[]} + groups={[]} pinnedRows={[]} pinnedLabel="Favourites" showPinned={false} @@ -430,7 +487,7 @@ export default function PeoplePage() { ) : ( columns={columns} - rows={filteredPeople} + groups={groups} pinnedRows={showPinned ? favourites : []} pinnedLabel="Favourites" showPinned={showPinned} @@ -453,20 +510,7 @@ export default function PeoplePage() { panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0' }`} > - - 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} - /> + {renderPanel()}
@@ -475,20 +519,7 @@ export default function PeoplePage() { {panelOpen && selectedPerson && (
- - 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} - /> + {renderPanel()}
)} diff --git a/frontend/src/components/shared/CopyableField.tsx b/frontend/src/components/shared/CopyableField.tsx index 27f26d2..ec44827 100644 --- a/frontend/src/components/shared/CopyableField.tsx +++ b/frontend/src/components/shared/CopyableField.tsx @@ -20,9 +20,9 @@ export default function CopyableField({ value, icon: Icon, label }: CopyableFiel }; return ( -
+
{Icon && } - {value} + {value} +
+ {onToggleFavourite && ( + + )} + +
{/* Body */} @@ -76,7 +100,7 @@ export function EntityDetailPanel({

{field.label}

-

{value}

+

{value}

)} diff --git a/frontend/src/components/shared/EntityTable.tsx b/frontend/src/components/shared/EntityTable.tsx index f4e46e5..0146d04 100644 --- a/frontend/src/components/shared/EntityTable.tsx +++ b/frontend/src/components/shared/EntityTable.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import type { VisibilityMode } from '@/hooks/useTableVisibility'; @@ -9,9 +10,14 @@ export interface ColumnDef { visibilityLevel: VisibilityMode; } +export interface RowGroup { + label: string; + rows: T[]; +} + interface EntityTableProps { columns: ColumnDef[]; - rows: T[]; + groups: RowGroup[]; pinnedRows: T[]; pinnedLabel: string; showPinned: boolean; @@ -44,7 +50,7 @@ function SkeletonRow({ colCount }: { colCount: number }) { export function EntityTable({ columns, - rows, + groups, pinnedRows, pinnedLabel, showPinned, @@ -93,6 +99,17 @@ export function EntityTable({ ); + const SectionHeader = ({ label }: { label: string }) => ( + + + {label} + + + ); + return (
@@ -127,24 +144,23 @@ export function EntityTable({ <> {showPinnedSection && ( <> - - - + {pinnedRows.map((item) => ( ))} - - )} - {rows.map((item) => ( - + {groups.map((group) => ( + + {group.rows.length > 0 && ( + <> + + {group.rows.map((item) => ( + + ))} + + )} + ))} )} diff --git a/frontend/src/components/shared/index.ts b/frontend/src/components/shared/index.ts index 833bfd2..6197b53 100644 --- a/frontend/src/components/shared/index.ts +++ b/frontend/src/components/shared/index.ts @@ -1,5 +1,5 @@ export { EntityTable } from './EntityTable'; -export type { ColumnDef } from './EntityTable'; +export type { ColumnDef, RowGroup } from './EntityTable'; export { EntityDetailPanel } from './EntityDetailPanel'; export type { PanelField } from './EntityDetailPanel'; export { default as CategoryFilterBar } from './CategoryFilterBar';
- {pinnedLabel} -
-