-
-
+ {/* Contact Number + Email */}
+
+ {/* Category */}
-
+
+ setFormData({ ...formData, category: val })}
+ categories={categories}
+ placeholder="e.g. work, restaurant, gym..."
+ />
+
+
+ {/* Notes */}
+
+
diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx
index 59b4d81..471b5ed 100644
--- a/frontend/src/components/locations/LocationsPage.tsx
+++ b/frontend/src/components/locations/LocationsPage.tsx
@@ -1,27 +1,36 @@
-import { useState, useMemo } from 'react';
-import { Plus, MapPin, Tag, Search } from 'lucide-react';
-import { useQuery } from '@tanstack/react-query';
-import api from '@/lib/api';
+import { useState, useMemo, useRef } from 'react';
+import { Plus, MapPin, Phone, Mail } from 'lucide-react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import api, { getErrorMessage } from '@/lib/api';
import type { Location } from '@/types';
import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Card, CardContent } from '@/components/ui/card';
-import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
-import { CATEGORIES } from './constants';
-import LocationCard from './LocationCard';
+import {
+ EntityTable,
+ EntityDetailPanel,
+ CategoryFilterBar,
+ type ColumnDef,
+ type PanelField,
+} from '@/components/shared';
+import { useTableVisibility } from '@/hooks/useTableVisibility';
import LocationForm from './LocationForm';
-const categoryFilters = [
- { value: '', label: 'All' },
- ...CATEGORIES.map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) })),
-] as const;
-
export default function LocationsPage() {
+ const queryClient = useQueryClient();
+
+ const [selectedLocationId, setSelectedLocationId] = useState
(null);
const [showForm, setShowForm] = useState(false);
const [editingLocation, setEditingLocation] = useState(null);
- const [filter, setFilter] = useState('');
+ const [activeFilters, setActiveFilters] = useState([]);
+ const [showPinned, setShowPinned] = useState(true);
const [search, setSearch] = useState('');
+ const [sortKey, setSortKey] = useState('name');
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
+
+ const tableRef = useRef(null);
+ const panelOpen = selectedLocationId !== null;
+ const visibilityMode = useTableVisibility(tableRef as React.RefObject, panelOpen);
const { data: locations = [], isLoading } = useQuery({
queryKey: ['locations'],
@@ -31,29 +40,81 @@ export default function LocationsPage() {
},
});
- const filteredLocations = useMemo(
- () =>
- locations.filter((loc) => {
- if (filter && loc.category !== filter) return false;
- if (search) {
- const q = search.toLowerCase();
- const matchName = loc.name.toLowerCase().includes(q);
- const matchAddress = loc.address?.toLowerCase().includes(q);
- if (!matchName && !matchAddress) return false;
- }
- return true;
- }),
- [locations, filter, search]
- );
+ const deleteMutation = useMutation({
+ mutationFn: async () => {
+ await api.delete(`/locations/${selectedLocationId}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['locations'] });
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] });
+ queryClient.invalidateQueries({ queryKey: ['upcoming'] });
+ toast.success('Location deleted');
+ setSelectedLocationId(null);
+ },
+ onError: (error) => {
+ toast.error(getErrorMessage(error, 'Failed to delete location'));
+ },
+ });
- const totalCount = locations.length;
- const categoryCount = useMemo(
- () => new Set(locations.map((l) => l.category)).size,
+ const allCategories = useMemo(
+ () => Array.from(new Set(locations.map((l) => l.category).filter(Boolean))).sort(),
[locations]
);
- const handleEdit = (location: Location) => {
- setEditingLocation(location);
+ const sortedLocations = useMemo(() => {
+ return [...locations].sort((a, b) => {
+ const aVal = String((a as unknown as Record)[sortKey] ?? '');
+ const bVal = String((b as unknown as Record)[sortKey] ?? '');
+ const cmp = aVal.localeCompare(bVal);
+ return sortDir === 'asc' ? cmp : -cmp;
+ });
+ }, [locations, sortKey, sortDir]);
+
+ const frequentLocations = useMemo(
+ () => sortedLocations.filter((l) => l.is_frequent),
+ [sortedLocations]
+ );
+
+ const filteredLocations = useMemo(
+ () =>
+ sortedLocations.filter((l) => {
+ if (showPinned && l.is_frequent) return false;
+ if (activeFilters.length > 0 && !activeFilters.includes(l.category)) return false;
+ if (search) {
+ const q = search.toLowerCase();
+ if (
+ !l.name.toLowerCase().includes(q) &&
+ !(l.address?.toLowerCase().includes(q)) &&
+ !(l.category?.toLowerCase().includes(q))
+ )
+ return false;
+ }
+ return true;
+ }),
+ [sortedLocations, activeFilters, search, showPinned]
+ );
+
+ const selectedLocation = useMemo(
+ () => locations.find((l) => l.id === selectedLocationId) ?? null,
+ [locations, selectedLocationId]
+ );
+
+ const handleSort = (key: string) => {
+ if (sortKey === key) {
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortKey(key);
+ setSortDir('asc');
+ }
+ };
+
+ const handleRowClick = (id: number) => {
+ setSelectedLocationId((prev) => (prev === id ? null : id));
+ };
+
+ const handleEdit = () => {
+ if (!selectedLocation) return;
+ setEditingLocation(selectedLocation);
setShowForm(true);
};
@@ -62,104 +123,216 @@ export default function LocationsPage() {
setEditingLocation(null);
};
+ const handleToggleCategory = (cat: string) => {
+ setActiveFilters((prev) =>
+ prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
+ );
+ };
+
+ const columns: ColumnDef[] = [
+ {
+ key: 'name',
+ label: 'Location',
+ sortable: true,
+ visibilityLevel: 'essential',
+ render: (l) => (
+
+ ),
+ },
+ {
+ key: 'address',
+ label: 'Address',
+ sortable: true,
+ visibilityLevel: 'essential',
+ render: (l) => {l.address},
+ },
+ {
+ key: 'contact_number',
+ label: 'Contact',
+ sortable: false,
+ visibilityLevel: 'filtered',
+ render: (l) => (
+ {l.contact_number || '—'}
+ ),
+ },
+ {
+ key: 'email',
+ label: 'Email',
+ sortable: true,
+ visibilityLevel: 'filtered',
+ render: (l) => (
+ {l.email || '—'}
+ ),
+ },
+ {
+ key: 'category',
+ label: 'Category',
+ sortable: true,
+ visibilityLevel: 'all',
+ render: (l) => (
+
+ {l.category}
+
+ ),
+ },
+ ];
+
+ const panelFields: PanelField[] = [
+ { label: 'Address', key: 'address', copyable: true, icon: MapPin },
+ { 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' },
+ ];
+
+ const renderPanel = () => (
+
+ item={selectedLocation}
+ fields={panelFields}
+ onEdit={handleEdit}
+ onDelete={() => deleteMutation.mutate()}
+ deleteLoading={deleteMutation.isPending}
+ onClose={() => setSelectedLocationId(null)}
+ renderHeader={(l) => (
+
+
+
+
+
+
{l.name}
+ {l.category}
+
+
+ )}
+ getUpdatedAt={(l) => l.updated_at}
+ getValue={(l, key) =>
+ (l as unknown as Record)[key] ?? undefined
+ }
+ />
+ );
+
return (
{/* Header */}
Locations
-
- {categoryFilters.map((cf) => (
-
- ))}
-
-
-
-
-
setSearch(e.target.value)}
- className="w-52 h-8 pl-8 text-sm"
+
+ setActiveFilters([])}
+ onTogglePinned={() => setShowPinned((v) => !v)}
+ onToggleCategory={handleToggleCategory}
+ searchValue={search}
+ onSearchChange={setSearch}
/>
-
-
-
-
- {/* Summary stats */}
- {!isLoading && locations.length > 0 && (
-
-
-
-
-
-
-
-
- Total
-
-
{totalCount}
-
-
-
-
-
-
-
-
-
-
- Categories
-
-
{categoryCount}
-
-
-
+ {/* Body */}
+
+
+ {/* Table */}
+
+
+ {isLoading ? (
+
+ columns={columns}
+ rows={[]}
+ pinnedRows={[]}
+ pinnedLabel="Frequent"
+ showPinned={false}
+ selectedId={null}
+ onRowClick={() => {}}
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={handleSort}
+ visibilityMode={visibilityMode}
+ loading={true}
+ />
+ ) : locations.length === 0 ? (
+ setShowForm(true)}
+ />
+ ) : (
+
+ columns={columns}
+ rows={filteredLocations}
+ pinnedRows={frequentLocations}
+ pinnedLabel="Frequent"
+ showPinned={showPinned}
+ selectedId={selectedLocationId}
+ onRowClick={handleRowClick}
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={handleSort}
+ visibilityMode={visibilityMode}
+ />
+ )}
+
- )}
- {isLoading ? (
-
- ) : filteredLocations.length === 0 ? (
-
setShowForm(true)}
- />
- ) : (
-
- {filteredLocations.map((location) => (
-
- ))}
+ {/* Detail panel (desktop) */}
+
+ {renderPanel()}
- )}
+
- {showForm &&
}
+ {/* Mobile detail panel overlay */}
+ {panelOpen && selectedLocation && (
+
+ )}
+
+ {showForm && (
+
+ )}
);
}
diff --git a/frontend/src/components/locations/constants.ts b/frontend/src/components/locations/constants.ts
index 4c25945..7bf603b 100644
--- a/frontend/src/components/locations/constants.ts
+++ b/frontend/src/components/locations/constants.ts
@@ -1,16 +1,13 @@
-export const CATEGORIES = ['home', 'work', 'restaurant', 'shop', 'other'] as const;
-
-export const categoryColors: Record
= {
- home: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
- work: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
- restaurant: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
- shop: 'bg-green-500/10 text-green-400 border-green-500/20',
- other: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
-};
-
const FALLBACK = 'bg-gray-500/10 text-gray-400 border-gray-500/20';
export function getCategoryColor(category: string | undefined): string {
if (!category) return FALLBACK;
- return categoryColors[category] ?? FALLBACK;
+ const colors: Record = {
+ home: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
+ work: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
+ restaurant: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
+ shop: 'bg-green-500/10 text-green-400 border-green-500/20',
+ other: FALLBACK,
+ };
+ return colors[category] ?? FALLBACK;
}
diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx
index b5d312a..7a1e519 100644
--- a/frontend/src/components/projects/ProjectDetail.tsx
+++ b/frontend/src/components/projects/ProjectDetail.tsx
@@ -543,7 +543,7 @@ export default function ProjectDetail() {
{/* Main content: task list/kanban + detail panel */}
{/* Left panel: task list or kanban */}
-
+
{topLevelTasks.length === 0 ? (
{/* Right panel: task detail (hidden on small screens) */}
- {selectedTaskId && (
-
-
- openTaskForm(null, parentId)}
- onClose={() => setSelectedTaskId(null)}
- onSelectTask={setSelectedTaskId}
- />
-
+
+
+ openTaskForm(null, parentId)}
+ onClose={() => setSelectedTaskId(null)}
+ onSelectTask={setSelectedTaskId}
+ />
- )}
+
diff --git a/frontend/src/components/shared/CategoryAutocomplete.tsx b/frontend/src/components/shared/CategoryAutocomplete.tsx
new file mode 100644
index 0000000..8d87a9c
--- /dev/null
+++ b/frontend/src/components/shared/CategoryAutocomplete.tsx
@@ -0,0 +1,87 @@
+import { useState, useRef, useEffect } from 'react';
+import { Input } from '@/components/ui/input';
+
+interface CategoryAutocompleteProps {
+ value: string;
+ onChange: (val: string) => void;
+ categories: string[];
+ placeholder?: string;
+ id?: string;
+}
+
+export default function CategoryAutocomplete({
+ value,
+ onChange,
+ categories,
+ placeholder,
+ id,
+}: CategoryAutocompleteProps) {
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef
(null);
+
+ const filtered = categories.filter(
+ (c) => c.toLowerCase().includes(value.toLowerCase()) && c.toLowerCase() !== value.toLowerCase()
+ );
+
+ // Close on outside click
+ useEffect(() => {
+ const handleMouseDown = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleMouseDown);
+ return () => document.removeEventListener('mousedown', handleMouseDown);
+ }, []);
+
+ const handleBlur = () => {
+ // Normalise casing if input matches an existing category
+ setTimeout(() => {
+ const match = categories.find((c) => c.toLowerCase() === value.toLowerCase());
+ if (match && match !== value) onChange(match);
+ setOpen(false);
+ }, 150);
+ };
+
+ const handleSelect = (cat: string) => {
+ onChange(cat);
+ setOpen(false);
+ };
+
+ return (
+
+
{
+ onChange(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => setOpen(true)}
+ onBlur={handleBlur}
+ autoComplete="off"
+ aria-autocomplete="list"
+ aria-expanded={open && filtered.length > 0}
+ />
+ {open && filtered.length > 0 && (
+
+ {filtered.map((cat) => (
+ - handleSelect(cat)}
+ className="px-3 py-1.5 text-sm hover:bg-card-elevated cursor-pointer transition-colors duration-150"
+ >
+ {cat}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/shared/CategoryFilterBar.tsx b/frontend/src/components/shared/CategoryFilterBar.tsx
new file mode 100644
index 0000000..72c6722
--- /dev/null
+++ b/frontend/src/components/shared/CategoryFilterBar.tsx
@@ -0,0 +1,165 @@
+import { useState, useRef, useEffect } from 'react';
+import { Search } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+
+interface CategoryFilterBarProps {
+ activeFilters: string[];
+ pinnedLabel: string;
+ showPinned: boolean;
+ categories: string[];
+ onToggleAll: () => void;
+ onTogglePinned: () => void;
+ onToggleCategory: (cat: string) => void;
+ searchValue: string;
+ onSearchChange: (val: string) => void;
+}
+
+const pillBase =
+ 'px-3 py-1.5 text-sm font-medium rounded-md transition-colors duration-150 whitespace-nowrap shrink-0';
+
+const activePillStyle = {
+ backgroundColor: 'hsl(var(--accent-color) / 0.15)',
+ color: 'hsl(var(--accent-color))',
+};
+
+export default function CategoryFilterBar({
+ activeFilters,
+ pinnedLabel,
+ showPinned,
+ categories,
+ onToggleAll,
+ onTogglePinned,
+ onToggleCategory,
+ searchValue,
+ onSearchChange,
+}: CategoryFilterBarProps) {
+ const [otherOpen, setOtherOpen] = useState(false);
+ const [searchCollapsed, setSearchCollapsed] = useState(false);
+ const searchInputRef = useRef(null);
+
+ const isAllActive = activeFilters.length === 0;
+
+ // Collapse search if there are many categories
+ useEffect(() => {
+ setSearchCollapsed(categories.length >= 4);
+ }, [categories.length]);
+
+ const handleExpandSearch = () => {
+ setSearchCollapsed(false);
+ setTimeout(() => searchInputRef.current?.focus(), 50);
+ };
+
+ return (
+
+ {/* All pill */}
+
+
+ All
+
+
+
+ {/* Pinned pill */}
+
+
+ {pinnedLabel}
+
+
+
+ {/* Other pill + expandable chips */}
+ {categories.length > 0 && (
+ <>
+
setOtherOpen((p) => !p)}
+ aria-label="Toggle category filters"
+ className={pillBase}
+ style={otherOpen ? activePillStyle : undefined}
+ >
+
+ Other
+
+
+
+
+ {categories.map((cat) => {
+ const isActive = activeFilters.includes(cat);
+ return (
+ onToggleCategory(cat)}
+ aria-label={`Filter by ${cat}`}
+ aria-pressed={isActive}
+ className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0"
+ style={
+ isActive
+ ? activePillStyle
+ : undefined
+ }
+ >
+
+ {cat}
+
+
+ );
+ })}
+
+ >
+ )}
+
+ {/* Spacer */}
+
+
+ {/* Search */}
+ {searchCollapsed ? (
+
+
+
+ ) : (
+
+
+ onSearchChange(e.target.value)}
+ onBlur={() => {
+ if (!searchValue && categories.length >= 4) setSearchCollapsed(true);
+ }}
+ className="w-44 h-8 pl-8 text-sm"
+ aria-label="Search"
+ />
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/shared/CopyableField.tsx b/frontend/src/components/shared/CopyableField.tsx
new file mode 100644
index 0000000..7a7c8eb
--- /dev/null
+++ b/frontend/src/components/shared/CopyableField.tsx
@@ -0,0 +1,34 @@
+import { useState } from 'react';
+import { Copy, Check, type LucideIcon } from 'lucide-react';
+
+interface CopyableFieldProps {
+ value: string;
+ icon?: LucideIcon;
+ label?: string;
+}
+
+export default function CopyableField({ value, icon: Icon, label }: CopyableFieldProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ return (
+
+ {Icon && }
+ {value}
+
+ {copied ? : }
+
+
+ );
+}
diff --git a/frontend/src/components/shared/EntityDetailPanel.tsx b/frontend/src/components/shared/EntityDetailPanel.tsx
new file mode 100644
index 0000000..3c97816
--- /dev/null
+++ b/frontend/src/components/shared/EntityDetailPanel.tsx
@@ -0,0 +1,126 @@
+import { useCallback } from 'react';
+import { X, Pencil, Trash2 } 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;
+}
+
+interface EntityDetailPanelProps {
+ 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;
+}
+
+export function EntityDetailPanel({
+ item,
+ fields,
+ onEdit,
+ onDelete,
+ deleteLoading = false,
+ onClose,
+ renderHeader,
+ getUpdatedAt,
+ getValue,
+}: EntityDetailPanelProps) {
+ const executeDelete = useCallback(() => onDelete(), [onDelete]);
+ const { confirming, handleClick: handleDelete } = useConfirmAction(executeDelete);
+
+ if (!item) return null;
+
+ return (
+
+ {/* Header */}
+
+
{renderHeader(item)}
+
+
+
+
+
+ {/* Body */}
+
+ {fields.map((field) => {
+ const value = getValue(item, field.key);
+ if (!value) return null;
+ return (
+
+ {field.copyable ? (
+
+ ) : (
+
+
+ {field.label}
+
+
{value}
+
+ )}
+
+ );
+ })}
+
+
+ {/* Footer */}
+
+
{formatUpdatedAt(getUpdatedAt(item))}
+
+
+
+ Edit
+
+ {confirming ? (
+
+ Sure?
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/EntityTable.tsx b/frontend/src/components/shared/EntityTable.tsx
new file mode 100644
index 0000000..a7032ba
--- /dev/null
+++ b/frontend/src/components/shared/EntityTable.tsx
@@ -0,0 +1,147 @@
+import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
+import type { VisibilityMode } from '@/hooks/useTableVisibility';
+
+export interface ColumnDef {
+ key: string;
+ label: string;
+ render: (item: T) => React.ReactNode;
+ sortable?: boolean;
+ visibilityLevel: VisibilityMode;
+}
+
+interface EntityTableProps {
+ columns: ColumnDef[];
+ rows: T[];
+ pinnedRows: T[];
+ pinnedLabel: string;
+ showPinned: boolean;
+ selectedId: number | null;
+ onRowClick: (id: number) => void;
+ sortKey: string;
+ sortDir: 'asc' | 'desc';
+ onSort: (key: string) => void;
+ visibilityMode: VisibilityMode;
+ loading?: boolean;
+}
+
+const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
+
+function isVisible(colLevel: VisibilityMode, mode: VisibilityMode): boolean {
+ return LEVEL_ORDER.indexOf(colLevel) <= LEVEL_ORDER.indexOf(mode);
+}
+
+function SkeletonRow({ colCount }: { colCount: number }) {
+ return (
+
+ {Array.from({ length: colCount }).map((_, i) => (
+ |
+
+ |
+ ))}
+
+ );
+}
+
+export function EntityTable({
+ columns,
+ rows,
+ pinnedRows,
+ pinnedLabel,
+ showPinned,
+ selectedId,
+ onRowClick,
+ sortKey,
+ sortDir,
+ onSort,
+ visibilityMode,
+ loading = false,
+}: EntityTableProps) {
+ const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
+ const colCount = visibleColumns.length;
+ const showPinnedSection = showPinned && pinnedRows.length > 0;
+
+ const SortIcon = ({ colKey }: { colKey: string }) => {
+ if (sortKey !== colKey) return ;
+ return sortDir === 'asc' ? (
+
+ ) : (
+
+ );
+ };
+
+ const DataRow = ({ item }: { item: T }) => (
+ onRowClick(item.id)}
+ aria-selected={selectedId === item.id}
+ >
+ {visibleColumns.map((col) => (
+ |
+ {col.render(item)}
+ |
+ ))}
+
+ );
+
+ return (
+
+
+
+
+ {visibleColumns.map((col) => (
+ |
+ {col.sortable ? (
+ onSort(col.key)}
+ aria-label={`Sort by ${col.label}`}
+ className="flex items-center hover:text-foreground transition-colors duration-150"
+ >
+ {col.label}
+
+
+ ) : (
+ col.label
+ )}
+ |
+ ))}
+
+
+
+ {loading ? (
+ Array.from({ length: 6 }).map((_, i) => )
+ ) : (
+ <>
+ {showPinnedSection && (
+ <>
+
+ |
+ {pinnedLabel}
+ |
+
+ {pinnedRows.map((item) => (
+
+ ))}
+
+ |
+
+ >
+ )}
+ {rows.map((item) => (
+
+ ))}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/index.ts b/frontend/src/components/shared/index.ts
new file mode 100644
index 0000000..833bfd2
--- /dev/null
+++ b/frontend/src/components/shared/index.ts
@@ -0,0 +1,8 @@
+export { EntityTable } from './EntityTable';
+export type { ColumnDef } from './EntityTable';
+export { EntityDetailPanel } from './EntityDetailPanel';
+export type { PanelField } from './EntityDetailPanel';
+export { default as CategoryFilterBar } from './CategoryFilterBar';
+export { default as CopyableField } from './CopyableField';
+export { default as CategoryAutocomplete } from './CategoryAutocomplete';
+export * from './utils';
diff --git a/frontend/src/components/shared/utils.ts b/frontend/src/components/shared/utils.ts
new file mode 100644
index 0000000..4722b7b
--- /dev/null
+++ b/frontend/src/components/shared/utils.ts
@@ -0,0 +1,56 @@
+import { formatDistanceToNow, parseISO, addYears, differenceInDays } from 'date-fns';
+
+// Deterministic avatar color from name hash
+export const avatarColors = [
+ 'bg-rose-500/20 text-rose-400',
+ 'bg-blue-500/20 text-blue-400',
+ 'bg-purple-500/20 text-purple-400',
+ 'bg-pink-500/20 text-pink-400',
+ 'bg-teal-500/20 text-teal-400',
+ 'bg-orange-500/20 text-orange-400',
+ 'bg-green-500/20 text-green-400',
+ 'bg-amber-500/20 text-amber-400',
+];
+
+export function getInitials(name: string): string {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+ return name.slice(0, 2).toUpperCase();
+}
+
+export function getAvatarColor(name: string): string {
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
+ }
+ return avatarColors[Math.abs(hash) % avatarColors.length];
+}
+
+export function formatUpdatedAt(updatedAt: string): string {
+ try {
+ return `Updated ${formatDistanceToNow(parseISO(updatedAt), { addSuffix: true })}`;
+ } catch {
+ return '';
+ }
+}
+
+export function getNextBirthday(birthday: string): Date {
+ const today = new Date();
+ const parsed = parseISO(birthday);
+ const thisYear = new Date(today.getFullYear(), parsed.getMonth(), parsed.getDate());
+ if (thisYear < today) {
+ return addYears(thisYear, 1);
+ }
+ return thisYear;
+}
+
+export function getDaysUntilBirthday(birthday: string): number {
+ const next = getNextBirthday(birthday);
+ return differenceInDays(next, new Date());
+}
+
+export function splitName(name: string): { firstName: string; lastName: string } {
+ const idx = name.indexOf(' ');
+ if (idx === -1) return { firstName: name, lastName: '' };
+ return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) };
+}
diff --git a/frontend/src/hooks/useTableVisibility.ts b/frontend/src/hooks/useTableVisibility.ts
new file mode 100644
index 0000000..1c3b93a
--- /dev/null
+++ b/frontend/src/hooks/useTableVisibility.ts
@@ -0,0 +1,64 @@
+import { useState, useEffect, useRef } from 'react';
+
+export type VisibilityMode = 'all' | 'filtered' | 'essential';
+
+/**
+ * Observes container width via ResizeObserver and returns a visibility mode
+ * for table columns. Adjusts thresholds when a side panel is open.
+ */
+export function useTableVisibility(
+ containerRef: React.RefObject,
+ panelOpen: boolean
+): VisibilityMode {
+ const [mode, setMode] = useState('all');
+ const timerRef = useRef>();
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const calculate = (width: number): VisibilityMode => {
+ if (panelOpen) {
+ return width >= 600 ? 'filtered' : 'essential';
+ }
+ if (width >= 900) return 'all';
+ if (width >= 600) return 'filtered';
+ return 'essential';
+ };
+
+ const observer = new ResizeObserver((entries) => {
+ clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ const entry = entries[0];
+ if (entry) {
+ setMode(calculate(entry.contentRect.width));
+ }
+ }, 50);
+ });
+
+ observer.observe(el);
+ // Set initial value
+ setMode(calculate(el.getBoundingClientRect().width));
+
+ return () => {
+ observer.disconnect();
+ clearTimeout(timerRef.current);
+ };
+ }, [containerRef, panelOpen]);
+
+ // Recalculate when panelOpen changes
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const width = el.getBoundingClientRect().width;
+ if (panelOpen) {
+ setMode(width >= 600 ? 'filtered' : 'essential');
+ } else {
+ if (width >= 900) setMode('all');
+ else if (width >= 600) setMode('filtered');
+ else setMode('essential');
+ }
+ }, [panelOpen, containerRef]);
+
+ return mode;
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index f39565f..d490331 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -142,11 +142,19 @@ export interface ProjectTask {
export interface Person {
id: number;
name: string;
+ first_name?: string;
+ last_name?: string;
+ nickname?: string;
email?: string;
phone?: string;
+ mobile?: string;
address?: string;
birthday?: string;
+ category?: string;
relationship?: string;
+ is_favourite: boolean;
+ company?: string;
+ job_title?: string;
notes?: string;
created_at: string;
updated_at: string;
@@ -157,6 +165,9 @@ export interface Location {
name: string;
address: string;
category: string;
+ contact_number?: string;
+ email?: string;
+ is_frequent: boolean;
notes?: string;
created_at: string;
updated_at: string;