Entity pages enhancement: backend model extensions, shared components, Locations rebuild, panel animations

- Add migrations 019/020: extend Person (first/last name, nickname, is_favourite, company, job_title, mobile, category) and Location (is_frequent, contact_number, email)
- Update Person/Location models, schemas, and routers with new fields + name denormalisation
- Create shared component library: EntityTable, EntityDetailPanel, CategoryFilterBar, CopyableField, CategoryAutocomplete, useTableVisibility hook
- Rebuild LocationsPage: table layout with sortable columns, detail side panel, category filter bar, frequent pinned section
- Extend LocationForm with contact number, email, frequent toggle, category autocomplete
- Add animated panel transitions to ProjectDetail (55/45 split with cubic-bezier easing)
- Update TypeScript interfaces for Person and Location

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-24 21:10:26 +08:00
parent f4b1239904
commit cb9f74a387
21 changed files with 1250 additions and 262 deletions

View File

@ -0,0 +1,40 @@
"""Extend person model with new fields
Revision ID: 019
Revises: 018
Create Date: 2026-02-24
"""
from alembic import op
import sqlalchemy as sa
revision = '019'
down_revision = '018'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('people', sa.Column('first_name', sa.String(100), nullable=True))
op.add_column('people', sa.Column('last_name', sa.String(100), nullable=True))
op.add_column('people', sa.Column('nickname', sa.String(100), nullable=True))
op.add_column('people', sa.Column('is_favourite', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('people', sa.Column('company', sa.String(255), nullable=True))
op.add_column('people', sa.Column('job_title', sa.String(255), nullable=True))
op.add_column('people', sa.Column('mobile', sa.String(50), nullable=True))
op.add_column('people', sa.Column('category', sa.String(100), nullable=True))
# Data migration: seed category from existing relationship field
op.execute("UPDATE people SET category = relationship WHERE category IS NULL AND relationship IS NOT NULL")
# Belt-and-suspenders: ensure no NULL on is_favourite despite server_default
op.execute("UPDATE people SET is_favourite = FALSE WHERE is_favourite IS NULL")
def downgrade() -> None:
op.drop_column('people', 'category')
op.drop_column('people', 'mobile')
op.drop_column('people', 'job_title')
op.drop_column('people', 'company')
op.drop_column('people', 'is_favourite')
op.drop_column('people', 'nickname')
op.drop_column('people', 'last_name')
op.drop_column('people', 'first_name')

View File

@ -0,0 +1,28 @@
"""Extend location model with new fields
Revision ID: 020
Revises: 019
Create Date: 2026-02-24
"""
from alembic import op
import sqlalchemy as sa
revision = '020'
down_revision = '019'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('locations', sa.Column('is_frequent', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('locations', sa.Column('contact_number', sa.String(50), nullable=True))
op.add_column('locations', sa.Column('email', sa.String(255), nullable=True))
# Belt-and-suspenders: ensure no NULL on is_frequent despite server_default
op.execute("UPDATE locations SET is_frequent = FALSE WHERE is_frequent IS NULL")
def downgrade() -> None:
op.drop_column('locations', 'email')
op.drop_column('locations', 'contact_number')
op.drop_column('locations', 'is_frequent')

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, func from sqlalchemy import String, Text, Boolean, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
@ -12,6 +12,9 @@ class Location(Base):
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
address: Mapped[str] = mapped_column(Text, nullable=False) address: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(100), default="other") category: Mapped[str] = mapped_column(String(100), default="other")
is_frequent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
contact_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Date, func from sqlalchemy import String, Text, Date, Boolean, func
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
@ -16,6 +16,15 @@ class Person(Base):
birthday: Mapped[Optional[date]] = mapped_column(Date, nullable=True) birthday: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
relationship: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) relationship: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Extended fields
first_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
nickname: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
is_favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime, timezone
from typing import Optional, List from typing import Optional, List
from app.database import get_db from app.database import get_db
@ -12,17 +13,35 @@ from app.models.settings import Settings
router = APIRouter() router = APIRouter()
def _compute_display_name(
first_name: Optional[str],
last_name: Optional[str],
nickname: Optional[str],
name: Optional[str],
) -> str:
"""Denormalise a display name. Nickname wins; else 'First Last'; else legacy name."""
if nickname:
return nickname
full = ((first_name or '') + ' ' + (last_name or '')).strip()
if full:
return full
return name or ''
@router.get("/", response_model=List[PersonResponse]) @router.get("/", response_model=List[PersonResponse])
async def get_people( async def get_people(
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
category: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Get all people with optional search.""" """Get all people with optional search and category filter."""
query = select(Person) query = select(Person)
if search: if search:
query = query.where(Person.name.ilike(f"%{search}%")) query = query.where(Person.name.ilike(f"%{search}%"))
if category:
query = query.where(Person.category == category)
query = query.order_by(Person.name.asc()) query = query.order_by(Person.name.asc())
@ -38,8 +57,14 @@ async def create_person(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Create a new person.""" """Create a new person with denormalised display name."""
new_person = Person(**person.model_dump()) new_person = Person(**person.model_dump())
new_person.name = _compute_display_name(
new_person.first_name,
new_person.last_name,
new_person.nickname,
new_person.name,
)
db.add(new_person) db.add(new_person)
await db.commit() await db.commit()
await db.refresh(new_person) await db.refresh(new_person)
@ -70,7 +95,7 @@ async def update_person(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Update a person.""" """Update a person and refresh the denormalised display name."""
result = await db.execute(select(Person).where(Person.id == person_id)) result = await db.execute(select(Person).where(Person.id == person_id))
person = result.scalar_one_or_none() person = result.scalar_one_or_none()
@ -82,6 +107,16 @@ async def update_person(
for key, value in update_data.items(): for key, value in update_data.items():
setattr(person, key, value) setattr(person, key, value)
# Recompute display name after applying updates
person.name = _compute_display_name(
person.first_name,
person.last_name,
person.nickname,
person.name,
)
# Guarantee timestamp refresh regardless of DB driver behaviour
person.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
await db.commit() await db.commit()
await db.refresh(person) await db.refresh(person)

View File

@ -15,6 +15,9 @@ class LocationCreate(BaseModel):
address: str address: str
category: str = "other" category: str = "other"
notes: Optional[str] = None notes: Optional[str] = None
is_frequent: bool = False
contact_number: Optional[str] = None
email: Optional[str] = None
class LocationUpdate(BaseModel): class LocationUpdate(BaseModel):
@ -22,6 +25,9 @@ class LocationUpdate(BaseModel):
address: Optional[str] = None address: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_frequent: Optional[bool] = None
contact_number: Optional[str] = None
email: Optional[str] = None
class LocationResponse(BaseModel): class LocationResponse(BaseModel):
@ -30,6 +36,9 @@ class LocationResponse(BaseModel):
address: str address: str
category: str category: str
notes: Optional[str] notes: Optional[str]
is_frequent: bool
contact_number: Optional[str]
email: Optional[str]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@ -1,36 +1,64 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, model_validator
from datetime import datetime, date from datetime import datetime, date
from typing import Optional from typing import Optional
class PersonCreate(BaseModel): class PersonCreate(BaseModel):
name: str name: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
nickname: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None address: Optional[str] = None
birthday: Optional[date] = None birthday: Optional[date] = None
relationship: Optional[str] = None category: Optional[str] = None
is_favourite: bool = False
company: Optional[str] = None
job_title: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
@model_validator(mode='after')
def require_some_name(self) -> 'PersonCreate':
if not any([self.name, self.first_name, self.last_name, self.nickname]):
raise ValueError('At least one name field is required')
return self
class PersonUpdate(BaseModel): class PersonUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
nickname: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None address: Optional[str] = None
birthday: Optional[date] = None birthday: Optional[date] = None
relationship: Optional[str] = None category: Optional[str] = None
is_favourite: Optional[bool] = None
company: Optional[str] = None
job_title: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
class PersonResponse(BaseModel): class PersonResponse(BaseModel):
id: int id: int
name: str name: str
first_name: Optional[str]
last_name: Optional[str]
nickname: Optional[str]
email: Optional[str] email: Optional[str]
phone: Optional[str] phone: Optional[str]
mobile: Optional[str]
address: Optional[str] address: Optional[str]
birthday: Optional[date] birthday: Optional[date]
category: Optional[str]
relationship: Optional[str] relationship: Optional[str]
is_favourite: bool
company: Optional[str]
job_title: Optional[str]
notes: Optional[str] notes: Optional[str]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@ -1,91 +0,0 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { MapPin, Trash2, Pencil } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Location } from '@/types';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { getCategoryColor } from './constants';
interface LocationCardProps {
location: Location;
onEdit: (location: Location) => void;
}
const QUERY_KEYS = [['locations'], ['dashboard'], ['upcoming']] as const;
export default function LocationCard({ location, onEdit }: LocationCardProps) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/locations/${location.id}`);
},
onSuccess: () => {
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
toast.success('Location deleted');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete location'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-heading text-lg font-semibold leading-none tracking-tight flex items-center gap-2">
<MapPin className="h-4 w-4 shrink-0" />
<span className="truncate">{location.name}</span>
</h3>
<Badge className={`mt-1.5 ${getCategoryColor(location.category)}`}>
{location.category}
</Badge>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={() => onEdit(location)} className="h-7 w-7" aria-label="Edit location">
<Pencil className="h-3 w-3" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
aria-label="Confirm delete"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="h-7 shrink-0 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
aria-label="Delete location"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-1.5">
{location.address && (
<p className="text-xs text-muted-foreground truncate">{location.address}</p>
)}
{location.notes && (
<p className="text-xs text-muted-foreground line-clamp-2">{location.notes}</p>
)}
</CardContent>
</Card>
);
}

View File

@ -1,6 +1,7 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Star, StarOff } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Location } from '@/types'; import type { Location } from '@/types';
import { import {
@ -13,23 +14,27 @@ import {
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import LocationPicker from '@/components/ui/location-picker'; import LocationPicker from '@/components/ui/location-picker';
import { CATEGORIES } from './constants'; import { CategoryAutocomplete } from '@/components/shared';
interface LocationFormProps { interface LocationFormProps {
location: Location | null; location: Location | null;
categories: string[];
onClose: () => void; onClose: () => void;
} }
export default function LocationForm({ location, onClose }: LocationFormProps) { export default function LocationForm({ location, categories, onClose }: LocationFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: location?.name || '', name: location?.name || '',
address: location?.address || '', address: location?.address || '',
category: location?.category || 'other', category: location?.category || 'other',
contact_number: location?.contact_number || '',
email: location?.email || '',
is_frequent: location?.is_frequent ?? false,
notes: location?.notes || '', notes: location?.notes || '',
}); });
@ -51,7 +56,12 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
onClose(); onClose();
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error, location ? 'Failed to update location' : 'Failed to create location')); toast.error(
getErrorMessage(
error,
location ? 'Failed to update location' : 'Failed to create location'
)
);
}, },
}); });
@ -65,24 +75,46 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
<SheetContent> <SheetContent>
<SheetClose onClick={onClose} /> <SheetClose onClick={onClose} />
<SheetHeader> <SheetHeader>
<div className="flex items-center justify-between pr-8">
<SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle> <SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={formData.is_frequent ? 'Remove from frequent' : 'Mark as frequent'}
onClick={() =>
setFormData((prev) => ({ ...prev, is_frequent: !prev.is_frequent }))
}
>
{formData.is_frequent ? (
<Star className="h-4 w-4 text-yellow-400 fill-yellow-400" />
) : (
<StarOff className="h-4 w-4" />
)}
</Button>
</div>
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1"> <div className="px-6 py-5 space-y-4 flex-1">
{/* Location Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="loc-name">Location Name</Label>
<Input <Input
id="name" id="loc-name"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. Home, Office, Coffee Shop"
required required
/> />
</div> </div>
{/* Address */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="address">Address</Label> <Label htmlFor="loc-address">Address</Label>
<LocationPicker <LocationPicker
id="address" id="loc-address"
value={formData.address} value={formData.address}
onChange={(val) => setFormData({ ...formData, address: val })} onChange={(val) => setFormData({ ...formData, address: val })}
onSelect={(result) => { onSelect={(result) => {
@ -96,27 +128,52 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
/> />
</div> </div>
{/* Contact Number + Email */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="category">Category</Label> <Label htmlFor="loc-contact">Contact Number</Label>
<Select <Input
id="category" id="loc-contact"
value={formData.category} type="tel"
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })} value={formData.contact_number}
> onChange={(e) =>
{CATEGORIES.map((c) => ( setFormData({ ...formData, contact_number: e.target.value })
<option key={c} value={c}> }
{c.charAt(0).toUpperCase() + c.slice(1)} placeholder="+44..."
</option> />
))} </div>
</Select> <div className="space-y-2">
<Label htmlFor="loc-email">Email</Label>
<Input
id="loc-email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="info@..."
/>
</div>
</div> </div>
{/* Category */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="notes">Notes</Label> <Label htmlFor="loc-category">Category</Label>
<CategoryAutocomplete
id="loc-category"
value={formData.category}
onChange={(val) => setFormData({ ...formData, category: val })}
categories={categories}
placeholder="e.g. work, restaurant, gym..."
/>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="loc-notes">Notes</Label>
<Textarea <Textarea
id="notes" id="loc-notes"
value={formData.notes} value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })} onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Any additional details..."
rows={4} rows={4}
/> />
</div> </div>

View File

@ -1,27 +1,36 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useRef } from 'react';
import { Plus, MapPin, Tag, Search } from 'lucide-react'; import { Plus, MapPin, Phone, Mail } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Location } from '@/types'; import type { Location } from '@/types';
import { Button } from '@/components/ui/button'; 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 { EmptyState } from '@/components/ui/empty-state';
import { CATEGORIES } from './constants'; import {
import LocationCard from './LocationCard'; EntityTable,
EntityDetailPanel,
CategoryFilterBar,
type ColumnDef,
type PanelField,
} from '@/components/shared';
import { useTableVisibility } from '@/hooks/useTableVisibility';
import LocationForm from './LocationForm'; 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() { export default function LocationsPage() {
const queryClient = useQueryClient();
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null); const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [filter, setFilter] = useState(''); const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showPinned, setShowPinned] = useState(true);
const [search, setSearch] = useState(''); 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({ const { data: locations = [], isLoading } = useQuery({
queryKey: ['locations'], queryKey: ['locations'],
@ -31,29 +40,81 @@ export default function LocationsPage() {
}, },
}); });
const filteredLocations = useMemo( const deleteMutation = useMutation({
() => mutationFn: async () => {
locations.filter((loc) => { await api.delete(`/locations/${selectedLocationId}`);
if (filter && loc.category !== filter) return false; },
if (search) { onSuccess: () => {
const q = search.toLowerCase(); queryClient.invalidateQueries({ queryKey: ['locations'] });
const matchName = loc.name.toLowerCase().includes(q); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
const matchAddress = loc.address?.toLowerCase().includes(q); queryClient.invalidateQueries({ queryKey: ['upcoming'] });
if (!matchName && !matchAddress) return false; toast.success('Location deleted');
} setSelectedLocationId(null);
return true; },
}), onError: (error) => {
[locations, filter, search] toast.error(getErrorMessage(error, 'Failed to delete location'));
); },
});
const totalCount = locations.length; const allCategories = useMemo(
const categoryCount = useMemo( () => Array.from(new Set(locations.map((l) => l.category).filter(Boolean))).sort(),
() => new Set(locations.map((l) => l.category)).size,
[locations] [locations]
); );
const handleEdit = (location: Location) => { const sortedLocations = useMemo(() => {
setEditingLocation(location); return [...locations].sort((a, b) => {
const aVal = String((a as unknown as Record<string, unknown>)[sortKey] ?? '');
const bVal = String((b as unknown as Record<string, unknown>)[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); setShowForm(true);
}; };
@ -62,87 +123,164 @@ export default function LocationsPage() {
setEditingLocation(null); setEditingLocation(null);
}; };
const handleToggleCategory = (cat: string) => {
setActiveFilters((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
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) => (
<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>
),
},
];
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 = () => (
<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) =>
(l as unknown as Record<string, string | undefined>)[key] ?? undefined
}
/>
);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <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> <h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4"> <div className="flex-1 min-w-0">
{categoryFilters.map((cf) => ( <CategoryFilterBar
<button categories={allCategories}
key={cf.value} activeFilters={activeFilters}
onClick={() => setFilter(cf.value)} pinnedLabel="Frequent"
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${ showPinned={showPinned}
filter === cf.value onToggleAll={() => setActiveFilters([])}
? 'bg-accent/15 text-accent' onTogglePinned={() => setShowPinned((v) => !v)}
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated' onToggleCategory={handleToggleCategory}
}`} searchValue={search}
style={{ onSearchChange={setSearch}
backgroundColor:
filter === cf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filter === cf.value ? 'hsl(var(--accent-color))' : undefined,
}}
>
{cf.label}
</button>
))}
</div>
<div className="relative ml-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm"
/> />
</div> </div>
<div className="flex-1" /> <Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location">
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Location Add Location
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-5"> {/* Body */}
{/* Summary stats */} <div className="flex-1 overflow-hidden flex flex-col">
{!isLoading && locations.length > 0 && ( <div className="flex-1 overflow-hidden flex">
<div className="grid gap-2.5 grid-cols-2 mb-5"> {/* Table */}
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <div
<CardContent className="p-4 flex items-center gap-3"> ref={tableRef}
<div className="p-1.5 rounded-md bg-blue-500/10"> className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
<MapPin className="h-4 w-4 text-blue-400" /> panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
</div> }`}
<div> >
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <div className="px-6 pb-6 pt-5">
Total
</p>
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-purple-500/10">
<Tag className="h-4 w-4 text-purple-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Categories
</p>
<p className="font-heading text-xl font-bold tabular-nums">{categoryCount}</p>
</div>
</CardContent>
</Card>
</div>
)}
{isLoading ? ( {isLoading ? (
<GridSkeleton cards={6} /> <EntityTable<Location>
) : filteredLocations.length === 0 ? ( columns={columns}
rows={[]}
pinnedRows={[]}
pinnedLabel="Frequent"
showPinned={false}
selectedId={null}
onRowClick={() => {}}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
visibilityMode={visibilityMode}
loading={true}
/>
) : locations.length === 0 ? (
<EmptyState <EmptyState
icon={MapPin} icon={MapPin}
title="No locations yet" title="No locations yet"
@ -151,15 +289,50 @@ export default function LocationsPage() {
onAction={() => setShowForm(true)} onAction={() => setShowForm(true)}
/> />
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <EntityTable<Location>
{filteredLocations.map((location) => ( columns={columns}
<LocationCard key={location.id} location={location} onEdit={handleEdit} /> rows={filteredLocations}
))} pinnedRows={frequentLocations}
</div> pinnedLabel="Frequent"
showPinned={showPinned}
selectedId={selectedLocationId}
onRowClick={handleRowClick}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
visibilityMode={visibilityMode}
/>
)} )}
</div> </div>
</div>
{showForm && <LocationForm location={editingLocation} onClose={handleCloseForm} />} {/* 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">
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
{renderPanel()}
</div>
</div>
)}
{showForm && (
<LocationForm
location={editingLocation}
categories={allCategories}
onClose={handleCloseForm}
/>
)}
</div> </div>
); );
} }

View File

@ -1,16 +1,13 @@
export const CATEGORIES = ['home', 'work', 'restaurant', 'shop', 'other'] as const;
export const categoryColors: Record<string, string> = {
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'; const FALLBACK = 'bg-gray-500/10 text-gray-400 border-gray-500/20';
export function getCategoryColor(category: string | undefined): string { export function getCategoryColor(category: string | undefined): string {
if (!category) return FALLBACK; if (!category) return FALLBACK;
return categoryColors[category] ?? FALLBACK; const colors: Record<string, string> = {
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;
} }

View File

@ -543,7 +543,7 @@ export default function ProjectDetail() {
{/* Main content: task list/kanban + detail panel */} {/* Main content: task list/kanban + detail panel */}
<div className="flex-1 overflow-hidden flex"> <div className="flex-1 overflow-hidden flex">
{/* Left panel: task list or kanban */} {/* Left panel: task list or kanban */}
<div className={`overflow-y-auto ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}> <div className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}>
<div className="px-6 pb-6"> <div className="px-6 pb-6">
{topLevelTasks.length === 0 ? ( {topLevelTasks.length === 0 ? (
<EmptyState <EmptyState
@ -631,9 +631,12 @@ export default function ProjectDetail() {
</div> </div>
{/* Right panel: task detail (hidden on small screens) */} {/* Right panel: task detail (hidden on small screens) */}
{selectedTaskId && ( <div
<div className="hidden lg:flex lg:w-[45%] border-l border-border bg-card"> className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card ${
<div className="flex-1 overflow-hidden"> selectedTaskId ? 'hidden lg:flex lg:w-[45%]' : 'w-0 opacity-0 border-l-0'
}`}
>
<div className="flex-1 overflow-hidden min-w-[360px]">
<TaskDetailPanel <TaskDetailPanel
task={selectedTask} task={selectedTask}
projectId={parseInt(id!)} projectId={parseInt(id!)}
@ -644,7 +647,6 @@ export default function ProjectDetail() {
/> />
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>

View File

@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="relative">
<Input
id={id}
value={value}
placeholder={placeholder}
onChange={(e) => {
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 && (
<ul
role="listbox"
className="absolute z-10 mt-1 w-full bg-card border border-border rounded-md shadow-lg max-h-40 overflow-y-auto"
>
{filtered.map((cat) => (
<li
key={cat}
role="option"
aria-selected={cat === value}
onMouseDown={() => handleSelect(cat)}
className="px-3 py-1.5 text-sm hover:bg-card-elevated cursor-pointer transition-colors duration-150"
>
{cat}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -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<HTMLInputElement>(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 (
<div className="flex items-center gap-2 overflow-x-auto">
{/* All pill */}
<button
type="button"
onClick={onToggleAll}
aria-label="Show all"
className={pillBase}
style={isAllActive ? activePillStyle : undefined}
>
<span
className={
isAllActive ? '' : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}
>
All
</span>
</button>
{/* Pinned pill */}
<button
type="button"
onClick={onTogglePinned}
aria-label={`Toggle ${pinnedLabel}`}
className={pillBase}
style={showPinned ? activePillStyle : undefined}
>
<span className={showPinned ? '' : 'text-muted-foreground hover:text-foreground'}>
{pinnedLabel}
</span>
</button>
{/* Other pill + expandable chips */}
{categories.length > 0 && (
<>
<button
type="button"
onClick={() => setOtherOpen((p) => !p)}
aria-label="Toggle category filters"
className={pillBase}
style={otherOpen ? activePillStyle : undefined}
>
<span className={otherOpen ? '' : 'text-muted-foreground hover:text-foreground'}>
Other
</span>
</button>
<div
className="flex items-center gap-1.5 overflow-x-auto transition-all duration-200 ease-out"
style={{
maxWidth: otherOpen ? '600px' : '0px',
opacity: otherOpen ? 1 : 0,
overflow: 'hidden',
}}
>
{categories.map((cat) => {
const isActive = activeFilters.includes(cat);
return (
<button
key={cat}
type="button"
onClick={() => 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
}
>
<span className={isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
{cat}
</span>
</button>
);
})}
</div>
</>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Search */}
{searchCollapsed ? (
<button
type="button"
onClick={handleExpandSearch}
aria-label="Expand search"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-card-elevated transition-colors duration-150"
>
<Search className="h-4 w-4" />
</button>
) : (
<div className="relative transition-all duration-200">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="search"
placeholder="Search..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={() => {
if (!searchValue && categories.length >= 4) setSearchCollapsed(true);
}}
className="w-44 h-8 pl-8 text-sm"
aria-label="Search"
/>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="group flex items-center gap-2">
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="text-sm truncate flex-1">{value}</span>
<button
type="button"
onClick={handleCopy}
aria-label={`Copy ${label || value}`}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
);
}

View File

@ -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<T> {
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<T>({
item,
fields,
onEdit,
onDelete,
deleteLoading = false,
onClose,
renderHeader,
getUpdatedAt,
getValue,
}: EntityDetailPanelProps<T>) {
const executeDelete = useCallback(() => onDelete(), [onDelete]);
const { confirming, handleClick: handleDelete } = useConfirmAction(executeDelete);
if (!item) return null;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<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>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7 shrink-0 ml-2"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{fields.map((field) => {
const value = getValue(item, field.key);
if (!value) return null;
return (
<div key={field.key}>
{field.copyable ? (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
{field.label}
</p>
<CopyableField value={value} icon={field.icon} label={field.label} />
</div>
) : (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
{field.label}
</p>
<p className="text-sm">{value}</p>
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onEdit}
aria-label="Edit"
>
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
{confirming ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Confirm delete"
className="h-8 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Delete"
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,147 @@
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility';
export interface ColumnDef<T> {
key: string;
label: string;
render: (item: T) => React.ReactNode;
sortable?: boolean;
visibilityLevel: VisibilityMode;
}
interface EntityTableProps<T extends { id: number }> {
columns: ColumnDef<T>[];
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 (
<tr className="border-b border-border/50">
{Array.from({ length: colCount }).map((_, i) => (
<td key={i} className="px-3 py-2.5">
<div className="animate-pulse rounded-md bg-muted h-4 w-full" />
</td>
))}
</tr>
);
}
export function EntityTable<T extends { id: number }>({
columns,
rows,
pinnedRows,
pinnedLabel,
showPinned,
selectedId,
onRowClick,
sortKey,
sortDir,
onSort,
visibilityMode,
loading = false,
}: EntityTableProps<T>) {
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 <ArrowUpDown className="h-3.5 w-3.5 ml-1 opacity-40" />;
return sortDir === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5 ml-1" />
) : (
<ArrowDown className="h-3.5 w-3.5 ml-1" />
);
};
const DataRow = ({ item }: { item: T }) => (
<tr
className={`border-b border-border/50 cursor-pointer hover:bg-card-elevated transition-colors duration-150 ${
selectedId === item.id ? 'bg-accent/10' : ''
}`}
onClick={() => onRowClick(item.id)}
aria-selected={selectedId === item.id}
>
{visibleColumns.map((col) => (
<td key={col.key} className="px-3 py-2.5 text-sm">
{col.render(item)}
</td>
))}
</tr>
);
return (
<div className="w-full">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border">
{visibleColumns.map((col) => (
<th
key={col.key}
className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
>
{col.sortable ? (
<button
type="button"
onClick={() => onSort(col.key)}
aria-label={`Sort by ${col.label}`}
className="flex items-center hover:text-foreground transition-colors duration-150"
>
{col.label}
<SortIcon colKey={col.key} />
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} colCount={colCount} />)
) : (
<>
{showPinnedSection && (
<>
<tr>
<td
colSpan={colCount}
className="px-3 py-1.5 text-[11px] uppercase tracking-wider text-muted-foreground"
>
{pinnedLabel}
</td>
</tr>
{pinnedRows.map((item) => (
<DataRow key={item.id} item={item} />
))}
<tr>
<td colSpan={colCount} className="border-b border-border/50" />
</tr>
</>
)}
{rows.map((item) => (
<DataRow key={item.id} item={item} />
))}
</>
)}
</tbody>
</table>
</div>
);
}

View File

@ -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';

View File

@ -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) };
}

View File

@ -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<HTMLElement>,
panelOpen: boolean
): VisibilityMode {
const [mode, setMode] = useState<VisibilityMode>('all');
const timerRef = useRef<ReturnType<typeof setTimeout>>();
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;
}

View File

@ -142,11 +142,19 @@ export interface ProjectTask {
export interface Person { export interface Person {
id: number; id: number;
name: string; name: string;
first_name?: string;
last_name?: string;
nickname?: string;
email?: string; email?: string;
phone?: string; phone?: string;
mobile?: string;
address?: string; address?: string;
birthday?: string; birthday?: string;
category?: string;
relationship?: string; relationship?: string;
is_favourite: boolean;
company?: string;
job_title?: string;
notes?: string; notes?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -157,6 +165,9 @@ export interface Location {
name: string; name: string;
address: string; address: string;
category: string; category: string;
contact_number?: string;
email?: string;
is_frequent: boolean;
notes?: string; notes?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;