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:
parent
1b78dadf75
commit
1231c4b36d
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user