Add fullWidth field option to PanelField interface. Short fields render in a grid grid-cols-2 layout; fullWidth fields (address, notes) render below at full width. Add icons to People and Locations fields for consistency with other detail panels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import { useState, useMemo, useRef, useEffect } from 'react';
|
|
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } 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 { EmptyState } from '@/components/ui/empty-state';
|
|
import {
|
|
EntityTable,
|
|
EntityDetailPanel,
|
|
CategoryFilterBar,
|
|
type ColumnDef,
|
|
type PanelField,
|
|
} from '@/components/shared';
|
|
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
|
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
|
import LocationForm from './LocationForm';
|
|
|
|
export default function LocationsPage() {
|
|
const queryClient = useQueryClient();
|
|
|
|
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
|
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
|
const [showPinned, setShowPinned] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [sortKey, setSortKey] = useState<string>('name');
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
|
|
const tableRef = useRef<HTMLDivElement>(null);
|
|
const panelOpen = selectedLocationId !== null;
|
|
const visibilityMode = useTableVisibility(tableRef as React.RefObject<HTMLElement>, panelOpen);
|
|
|
|
const { data: locations = [], isLoading } = useQuery({
|
|
queryKey: ['locations'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Location[]>('/locations');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
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'));
|
|
},
|
|
});
|
|
|
|
// 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]
|
|
);
|
|
|
|
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('locations', allCategories);
|
|
|
|
const sortedLocations = useMemo(() => {
|
|
return [...locations].sort((a, b) => {
|
|
const aVal = String(a[sortKey as keyof Location] ?? '');
|
|
const bVal = String(b[sortKey as keyof Location] ?? '');
|
|
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]
|
|
);
|
|
|
|
// Build row groups for the table — ordered by custom category order
|
|
const groups = useMemo(() => {
|
|
if (activeFilters.length <= 1) {
|
|
const label = activeFilters.length === 1 ? activeFilters[0] : 'All';
|
|
return [{ label, rows: filteredLocations }];
|
|
}
|
|
return orderedCategories
|
|
.filter((cat) => activeFilters.includes(cat))
|
|
.map((cat) => ({
|
|
label: cat,
|
|
rows: filteredLocations.filter((l) => l.category === cat),
|
|
}))
|
|
.filter((g) => g.rows.length > 0);
|
|
}, [activeFilters, filteredLocations, orderedCategories]);
|
|
|
|
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);
|
|
};
|
|
|
|
// Escape key closes detail panel
|
|
useEffect(() => {
|
|
if (!panelOpen) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setSelectedLocationId(null);
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [panelOpen]);
|
|
|
|
const handleCloseForm = () => {
|
|
setShowForm(false);
|
|
setEditingLocation(null);
|
|
};
|
|
|
|
const handleToggleCategory = (cat: string) => {
|
|
setActiveFilters((prev) =>
|
|
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
|
);
|
|
};
|
|
const selectAllCategories = () => {
|
|
const allSelected = orderedCategories.every((c) => activeFilters.includes(c));
|
|
setActiveFilters(allSelected ? [] : [...orderedCategories]);
|
|
};
|
|
|
|
const columns: ColumnDef<Location>[] = [
|
|
{
|
|
key: 'name',
|
|
label: 'Location',
|
|
sortable: true,
|
|
visibilityLevel: 'essential',
|
|
render: (l) => (
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1 rounded-md bg-accent/10 shrink-0">
|
|
<MapPin
|
|
className="h-3.5 w-3.5"
|
|
style={{ color: 'hsl(var(--accent-color))' }}
|
|
/>
|
|
</div>
|
|
<span className="font-medium truncate">{l.name}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'address',
|
|
label: 'Address',
|
|
sortable: true,
|
|
visibilityLevel: 'essential',
|
|
render: (l) => <span className="text-muted-foreground truncate">{l.address}</span>,
|
|
},
|
|
{
|
|
key: 'contact_number',
|
|
label: 'Contact',
|
|
sortable: false,
|
|
visibilityLevel: 'filtered',
|
|
render: (l) => (
|
|
<span className="text-muted-foreground">{l.contact_number || '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'email',
|
|
label: 'Email',
|
|
sortable: true,
|
|
visibilityLevel: 'filtered',
|
|
render: (l) => (
|
|
<span className="text-muted-foreground truncate">{l.email || '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'category',
|
|
label: 'Category',
|
|
sortable: true,
|
|
visibilityLevel: 'all',
|
|
render: (l) =>
|
|
l.category && l.category.toLowerCase() !== 'other' ? (
|
|
<span
|
|
className="px-2 py-0.5 text-xs rounded"
|
|
style={{
|
|
backgroundColor: 'hsl(var(--accent-color) / 0.1)',
|
|
color: 'hsl(var(--accent-color))',
|
|
}}
|
|
>
|
|
{l.category}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
const panelFields: PanelField[] = [
|
|
{ label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone },
|
|
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
|
{ label: 'Category', key: 'category', icon: Tag },
|
|
{ label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true },
|
|
{ label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true },
|
|
];
|
|
|
|
const renderPanel = () => (
|
|
<EntityDetailPanel<Location>
|
|
item={selectedLocation}
|
|
fields={panelFields}
|
|
onEdit={handleEdit}
|
|
onDelete={() => deleteMutation.mutate()}
|
|
deleteLoading={deleteMutation.isPending}
|
|
onClose={() => setSelectedLocationId(null)}
|
|
renderHeader={(l) => (
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-lg bg-accent/10">
|
|
<MapPin
|
|
className="h-5 w-5"
|
|
style={{ color: 'hsl(var(--accent-color))' }}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="font-heading text-lg font-semibold truncate">{l.name}</h3>
|
|
<span className="text-xs text-muted-foreground">{l.category}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
getUpdatedAt={(l) => l.updated_at}
|
|
getValue={(l, key) => {
|
|
const val = l[key as keyof Location];
|
|
if (val == null || val === '') return undefined;
|
|
if (typeof val === 'boolean') return undefined;
|
|
return String(val);
|
|
}}
|
|
isFavourite={selectedLocation?.is_frequent}
|
|
onToggleFavourite={() => selectedLocation && toggleFrequentMutation.mutate(selectedLocation)}
|
|
favouriteLabel="frequent"
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full animate-fade-in">
|
|
{/* Header */}
|
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
|
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<CategoryFilterBar
|
|
categories={orderedCategories}
|
|
activeFilters={activeFilters}
|
|
pinnedLabel="Frequent"
|
|
showPinned={showPinned}
|
|
onToggleAll={() => setActiveFilters([])}
|
|
onTogglePinned={() => setShowPinned((v) => !v)}
|
|
onToggleCategory={handleToggleCategory}
|
|
onSelectAllCategories={selectAllCategories}
|
|
onReorderCategories={reorderCategories}
|
|
searchValue={search}
|
|
onSearchChange={setSearch}
|
|
/>
|
|
</div>
|
|
|
|
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Location
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-hidden flex">
|
|
{/* Table */}
|
|
<div
|
|
ref={tableRef}
|
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
|
}`}
|
|
>
|
|
<div className="px-6 pb-6 pt-5">
|
|
{isLoading ? (
|
|
<EntityTable<Location>
|
|
columns={columns}
|
|
groups={[]}
|
|
pinnedRows={[]}
|
|
pinnedLabel="Frequent"
|
|
showPinned={false}
|
|
selectedId={null}
|
|
onRowClick={() => {}}
|
|
sortKey={sortKey}
|
|
sortDir={sortDir}
|
|
onSort={handleSort}
|
|
visibilityMode={visibilityMode}
|
|
loading={true}
|
|
/>
|
|
) : locations.length === 0 ? (
|
|
<EmptyState
|
|
icon={MapPin}
|
|
title="No locations yet"
|
|
description="Add locations to organise your favourite places, workspaces, and more."
|
|
actionLabel="Add Location"
|
|
onAction={() => setShowForm(true)}
|
|
/>
|
|
) : (
|
|
<EntityTable<Location>
|
|
columns={columns}
|
|
groups={groups}
|
|
pinnedRows={frequentLocations}
|
|
pinnedLabel="Frequent"
|
|
showPinned={showPinned}
|
|
selectedId={selectedLocationId}
|
|
onRowClick={handleRowClick}
|
|
sortKey={sortKey}
|
|
sortDir={sortDir}
|
|
onSort={handleSort}
|
|
visibilityMode={visibilityMode}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail panel (desktop) */}
|
|
<div
|
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
}`}
|
|
>
|
|
{renderPanel()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile detail panel overlay */}
|
|
{panelOpen && selectedLocation && (
|
|
<div
|
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
|
onClick={() => setSelectedLocationId(null)}
|
|
>
|
|
<div
|
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{renderPanel()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showForm && (
|
|
<LocationForm
|
|
location={editingLocation}
|
|
categories={allCategories}
|
|
onClose={handleCloseForm}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|