Merge Stage 5: Entity Pages Enhancement (People & Locations rebuild, shared components, CategoryFilterBar QOL)

This commit is contained in:
Kyle 2026-02-25 01:25:01 +08:00
commit 5feb67bf13
28 changed files with 2540 additions and 416 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

@ -0,0 +1,21 @@
"""Drop deprecated relationship column from people table
Revision ID: 021
Revises: 020
Create Date: 2026-02-25
"""
from alembic import op
import sqlalchemy as sa
revision = '021'
down_revision = '020'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column('people', 'relationship')
def downgrade() -> None:
op.add_column('people', sa.Column('relationship', sa.String(100), nullable=True))

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, func from sqlalchemy import String, Text, Boolean, func, text
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, server_default=text('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,5 +1,5 @@
from sqlalchemy import String, Text, Date, func from sqlalchemy import String, Text, Date, Boolean, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
from app.database import Base from app.database import Base
@ -14,10 +14,18 @@ class Person(Base):
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True) address: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
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)
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, server_default=text('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())
# Relationships # Relationships
assigned_tasks: Mapped[List["ProjectTask"]] = sa_relationship(back_populates="person") assigned_tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="person")

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, or_ from sqlalchemy import select, or_
from datetime import datetime, timezone
from typing import Optional, List from typing import Optional, List
import asyncio import asyncio
import json import json
@ -151,6 +152,9 @@ async def update_location(
for key, value in update_data.items(): for key, value in update_data.items():
setattr(location, key, value) setattr(location, key, value)
# Guarantee timestamp refresh regardless of DB driver behaviour
location.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
await db.commit() await db.commit()
await db.refresh(location) await db.refresh(location)

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, or_
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,46 @@ 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; else empty."""
if nickname:
return nickname
full = ((first_name or '') + ' ' + (last_name or '')).strip()
if full:
return full
# Don't fall back to stale `name` if all fields were explicitly cleared
return (name or '').strip() if name else ''
@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}%")) term = f"%{search}%"
query = query.where(
or_(
Person.name.ilike(term),
Person.first_name.ilike(term),
Person.last_name.ilike(term),
Person.nickname.ilike(term),
Person.email.ilike(term),
Person.company.ilike(term),
)
)
if category:
query = query.where(Person.category == category)
query = query.order_by(Person.name.asc()) query = query.order_by(Person.name.asc())
@ -38,8 +68,20 @@ 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()) data = person.model_dump()
# Auto-split legacy name into first/last if only name is provided
if data.get('name') and not data.get('first_name') and not data.get('last_name') and not data.get('nickname'):
parts = data['name'].split(' ', 1)
data['first_name'] = parts[0]
data['last_name'] = parts[1] if len(parts) > 1 else None
new_person = Person(**data)
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 +112,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 +124,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

@ -1,7 +1,10 @@
from pydantic import BaseModel, ConfigDict import re
from pydantic import BaseModel, ConfigDict, field_validator
from datetime import datetime from datetime import datetime
from typing import Optional, Literal from typing import Optional, Literal
_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
class LocationSearchResult(BaseModel): class LocationSearchResult(BaseModel):
source: Literal["local", "nominatim"] source: Literal["local", "nominatim"]
@ -15,6 +18,16 @@ 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
@field_validator('email')
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v and not _EMAIL_RE.match(v):
raise ValueError('Invalid email address')
return v
class LocationUpdate(BaseModel): class LocationUpdate(BaseModel):
@ -22,6 +35,16 @@ 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
@field_validator('email')
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v and not _EMAIL_RE.match(v):
raise ValueError('Invalid email address')
return v
class LocationResponse(BaseModel): class LocationResponse(BaseModel):
@ -30,6 +53,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,85 @@
from pydantic import BaseModel, ConfigDict import re
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
from datetime import datetime, date from datetime import datetime, date
from typing import Optional from typing import Optional
_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
class PersonCreate(BaseModel): class PersonCreate(BaseModel):
name: str name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone
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 and self.name.strip(),
self.first_name and self.first_name.strip(),
self.last_name and self.last_name.strip(),
self.nickname and self.nickname.strip(),
]):
raise ValueError('At least one name field is required')
return self
@field_validator('email')
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v and not _EMAIL_RE.match(v):
raise ValueError('Invalid email address')
return v
class PersonUpdate(BaseModel): class PersonUpdate(BaseModel):
name: Optional[str] = None # name is intentionally omitted — always computed from first/last/nickname
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
@field_validator('email')
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v and not _EMAIL_RE.match(v):
raise ValueError('Invalid email address')
return v
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]
relationship: Optional[str] category: 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

@ -8,6 +8,10 @@
"name": "umbra", "name": "umbra",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15", "@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15", "@fullcalendar/react": "^6.1.15",
@ -330,6 +334,73 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -3055,6 +3126,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@ -9,24 +9,25 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"@fullcalendar/react": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"lucide-react": "^0.468.0",
"date-fns": "^4.1.0",
"sonner": "^1.7.1", "sonner": "^1.7.1",
"clsx": "^2.1.1", "tailwind-merge": "^2.6.0"
"tailwind-merge": "^2.6.0",
"class-variance-authority": "^0.7.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",

View File

@ -1,75 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { MapPin, Trash2, Edit } from 'lucide-react';
import api from '@/lib/api';
import type { Location } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface LocationCardProps {
location: Location;
onEdit: (location: Location) => void;
}
const categoryColors: Record<string, string> = {
home: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
work: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
restaurant: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
shop: 'bg-green-500/10 text-green-500 border-green-500/20',
other: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
};
export default function LocationCard({ location, onEdit }: LocationCardProps) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/locations/${location.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
toast.success('Location deleted');
},
onError: () => {
toast.error('Failed to delete location');
},
});
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-xl flex items-center gap-2">
<MapPin className="h-5 w-5" />
{location.name}
</CardTitle>
<Badge className={categoryColors[location.category]} style={{ marginTop: '0.5rem' }}>
{location.category}
</Badge>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => onEdit(location)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">{location.address}</p>
{location.notes && (
<p className="text-sm 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, X } 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 {
@ -9,26 +10,30 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
SheetFooter, SheetFooter,
SheetClose,
} 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 { 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 || '',
}); });
@ -44,11 +49,18 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] }); queryClient.invalidateQueries({ queryKey: ['locations'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(location ? 'Location updated' : 'Location created'); toast.success(location ? 'Location updated' : 'Location created');
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'
)
);
}, },
}); });
@ -60,26 +72,59 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
return ( return (
<Sheet open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<SheetContent> <SheetContent>
<SheetClose onClick={onClose} />
<SheetHeader> <SheetHeader>
<div className="flex items-center justify-between">
<SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle> <SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
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>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close"
className="h-7 w-7 shrink-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</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) => {
@ -93,27 +138,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) =>
<option value="home">Home</option> setFormData({ ...formData, contact_number: e.target.value })
<option value="work">Work</option> }
<option value="restaurant">Restaurant</option> placeholder="+61..."
<option value="shop">Shop</option> />
<option value="other">Other</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,20 +1,37 @@
import { useState } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, MapPin } 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 { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import LocationCard from './LocationCard'; 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'; import LocationForm from './LocationForm';
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 [categoryFilter, setCategoryFilter] = useState(''); 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({ const { data: locations = [], isLoading } = useQuery({
queryKey: ['locations'], queryKey: ['locations'],
@ -24,52 +41,302 @@ export default function LocationsPage() {
}, },
}); });
const filteredLocations = categoryFilter const deleteMutation = useMutation({
? locations.filter((loc) => loc.category === categoryFilter) mutationFn: async () => {
: locations; 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 handleEdit = (location: Location) => { // Toggle frequent mutation
setEditingLocation(location); 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); 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 = () => { const handleCloseForm = () => {
setShowForm(false); setShowForm(false);
setEditingLocation(null); 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: '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', multiline: 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="text-3xl font-bold">Locations</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
<Button onClick={() => setShowForm(true)}>
<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" /> <Plus className="mr-2 h-4 w-4" />
Add Location Add Location
</Button> </Button>
</div> </div>
<div className="flex items-center gap-4"> {/* Body */}
<Label htmlFor="category-filter">Filter by category:</Label> <div className="flex-1 overflow-hidden flex flex-col">
<Select <div className="flex-1 overflow-hidden flex">
id="category-filter" {/* Table */}
value={categoryFilter} <div
onChange={(e) => setCategoryFilter(e.target.value)} 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'
}`}
> >
<option value="">All</option> <div className="px-6 pb-6 pt-5">
<option value="home">Home</option>
<option value="work">Work</option>
<option value="restaurant">Restaurant</option>
<option value="shop">Shop</option>
<option value="other">Other</option>
</Select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? ( {isLoading ? (
<GridSkeleton cards={6} /> <EntityTable<Location>
) : filteredLocations.length === 0 ? ( 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 <EmptyState
icon={MapPin} icon={MapPin}
title="No locations yet" title="No locations yet"
@ -78,15 +345,56 @@ 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} /> groups={groups}
))} 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"
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> </div>
); );
} }

View File

@ -1,19 +1,201 @@
import { useState } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, Users } from 'lucide-react'; import { Plus, Users, Star, Cake, Phone, Mail, MapPin } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import type { LucideIcon } from 'lucide-react';
import api from '@/lib/api'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types'; import type { Person } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import PersonCard from './PersonCard'; import {
EntityTable,
EntityDetailPanel,
CategoryFilterBar,
} from '@/components/shared';
import type { ColumnDef, PanelField } from '@/components/shared';
import {
getInitials,
getAvatarColor,
getNextBirthday,
getDaysUntilBirthday,
} from '@/components/shared/utils';
import { useTableVisibility } from '@/hooks/useTableVisibility';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import PersonForm from './PersonForm'; import PersonForm from './PersonForm';
// ---------------------------------------------------------------------------
// StatCounter — inline helper
// ---------------------------------------------------------------------------
function StatCounter({
icon: Icon,
iconBg,
iconColor,
label,
value,
}: {
icon: LucideIcon;
iconBg: string;
iconColor: string;
label: string;
value: number;
}) {
return (
<div className="flex items-center gap-2.5">
<div className={`p-1.5 rounded-md ${iconBg}`}>
<Icon className={`h-4 w-4 ${iconColor}`} />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getPersonInitialsName(p: Person): string {
const parts = [p.first_name, p.last_name].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : p.name;
}
function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[] {
return [...people].sort((a, b) => {
let cmp = 0;
if (key === 'birthday') {
const aD = a.birthday ? getDaysUntilBirthday(a.birthday) : Infinity;
const bD = b.birthday ? getDaysUntilBirthday(b.birthday) : Infinity;
cmp = aD - bD;
} else {
const aVal = a[key as keyof Person];
const bVal = b[key as keyof Person];
const aStr = aVal != null ? String(aVal) : '';
const bStr = bVal != null ? String(bVal) : '';
cmp = aStr.localeCompare(bStr);
}
return dir === 'asc' ? cmp : -cmp;
});
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
const columns: ColumnDef<Person>[] = [
{
key: 'name',
label: 'Name',
sortable: true,
visibilityLevel: 'essential',
render: (p) => {
const initialsName = getPersonInitialsName(p);
return (
<div className="flex items-center gap-2.5">
<div
className={`h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${getAvatarColor(initialsName)}`}
>
{getInitials(initialsName)}
</div>
<span className="font-medium truncate">{p.nickname || p.name}</span>
</div>
);
},
},
{
key: 'phone',
label: 'Number',
sortable: false,
visibilityLevel: 'essential',
render: (p) => (
<span className="text-muted-foreground truncate">{p.mobile || p.phone || '—'}</span>
),
},
{
key: 'email',
label: 'Email',
sortable: true,
visibilityLevel: 'essential',
render: (p) => (
<span className="text-muted-foreground truncate">{p.email || '—'}</span>
),
},
{
key: 'job_title',
label: 'Role',
sortable: true,
visibilityLevel: 'filtered',
render: (p) => {
const parts = [p.job_title, p.company].filter(Boolean);
return (
<span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>
);
},
},
{
key: 'birthday',
label: 'Birthday',
sortable: true,
visibilityLevel: 'filtered',
render: (p) =>
p.birthday ? (
<span className="text-muted-foreground">{format(parseISO(p.birthday), 'MMM d')}</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
key: 'category',
label: 'Category',
sortable: true,
visibilityLevel: 'all',
render: (p) =>
p.category ? (
<span
className="px-2 py-0.5 text-xs rounded"
style={{
backgroundColor: 'hsl(var(--accent-color) / 0.1)',
color: 'hsl(var(--accent-color))',
}}
>
{p.category}
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
];
// ---------------------------------------------------------------------------
// Panel field config
// ---------------------------------------------------------------------------
const panelFields: PanelField[] = [
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
{ label: 'Address', key: 'address', copyable: true, icon: MapPin },
{ label: 'Birthday', key: 'birthday_display' },
{ label: 'Category', key: 'category' },
{ label: 'Company', key: 'company' },
{ label: 'Job Title', key: 'job_title' },
{ label: 'Notes', key: 'notes', multiline: true },
];
// ---------------------------------------------------------------------------
// PeoplePage
// ---------------------------------------------------------------------------
export default function PeoplePage() { export default function PeoplePage() {
const queryClient = useQueryClient();
const tableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingPerson, setEditingPerson] = useState<Person | null>(null); const [editingPerson, setEditingPerson] = useState<Person | null>(null);
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 { data: people = [], isLoading } = useQuery({ const { data: people = [], isLoading } = useQuery({
queryKey: ['people'], queryKey: ['people'],
@ -23,43 +205,295 @@ export default function PeoplePage() {
}, },
}); });
const filteredPeople = people.filter((person) => const panelOpen = selectedPersonId !== null;
person.name.toLowerCase().includes(search.toLowerCase()) const visibilityMode = useTableVisibility(tableContainerRef, panelOpen);
const allCategories = useMemo(() => {
const cats = new Set<string>();
people.forEach((p) => { if (p.category) cats.add(p.category); });
return Array.from(cats).sort();
}, [people]);
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('people', allCategories);
// Favourites (pinned section) — sorted
const favourites = useMemo(
() => sortPeople(people.filter((p) => p.is_favourite), sortKey, sortDir),
[people, sortKey, sortDir]
); );
const handleEdit = (person: Person) => { // Filtered non-favourites
setEditingPerson(person); const filteredPeople = useMemo(() => {
setShowForm(true); let list = showPinned
? people.filter((p) => !p.is_favourite)
: people;
if (activeFilters.length > 0) {
list = list.filter((p) => p.category && activeFilters.includes(p.category));
}
if (search) {
const q = search.toLowerCase();
list = list.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.mobile?.toLowerCase().includes(q) ||
p.phone?.toLowerCase().includes(q) ||
p.company?.toLowerCase().includes(q) ||
p.category?.toLowerCase().includes(q)
);
}
return sortPeople(list, sortKey, sortDir);
}, [people, showPinned, activeFilters, search, sortKey, sortDir]);
// 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: filteredPeople }];
}
// Use orderedCategories to control section order, filtered to active only
return orderedCategories
.filter((cat) => activeFilters.includes(cat))
.map((cat) => ({
label: cat,
rows: filteredPeople.filter((p) => p.category === cat),
}))
.filter((g) => g.rows.length > 0);
}, [activeFilters, filteredPeople, orderedCategories]);
// Stats
const totalCount = people.length;
const favouriteCount = people.filter((p) => p.is_favourite).length;
const upcomingBirthdays = useMemo(
() =>
people
.filter((p) => p.birthday && getDaysUntilBirthday(p.birthday) <= 30)
.sort((a, b) => getDaysUntilBirthday(a.birthday!) - getDaysUntilBirthday(b.birthday!)),
[people]
);
const upcomingBdayCount = upcomingBirthdays.length;
const selectedPerson = useMemo(
() => people.find((p) => p.id === selectedPersonId) ?? null,
[selectedPersonId, people]
);
// Sort handler
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
}; };
// Filter handlers
const toggleAll = () => setActiveFilters([]);
const togglePinned = () => setShowPinned((p) => !p);
const toggleCategory = (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]);
};
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/people/${selectedPersonId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Person deleted');
setSelectedPersonId(null);
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete person'));
},
});
// Toggle favourite mutation
const toggleFavouriteMutation = useMutation({
mutationFn: async (person: Person) => {
await api.put(`/people/${person.id}`, { is_favourite: !person.is_favourite });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update favourite'));
},
});
// Escape key closes detail panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSelectedPersonId(null);
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
const handleCloseForm = () => { const handleCloseForm = () => {
setShowForm(false); setShowForm(false);
setEditingPerson(null); setEditingPerson(null);
}; };
// Panel header renderer (shared between desktop and mobile)
const renderPersonHeader = (p: Person) => {
const initialsName = getPersonInitialsName(p);
return (
<div className="flex items-center gap-3">
<div
className={`h-10 w-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${getAvatarColor(initialsName)}`}
>
{getInitials(initialsName)}
</div>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
</div>
</div>
);
};
// Panel getValue
const getPanelValue = (p: Person, key: string): string | undefined => {
if (key === 'birthday_display' && p.birthday) {
const age = differenceInYears(new Date(), parseISO(p.birthday));
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
}
const val = p[key as keyof Person];
return val != null ? String(val) : undefined;
};
const renderPanel = () => (
<EntityDetailPanel<Person>
item={selectedPerson}
fields={panelFields}
onEdit={() => {
setEditingPerson(selectedPerson);
setShowForm(true);
}}
onDelete={() => deleteMutation.mutate()}
deleteLoading={deleteMutation.isPending}
onClose={() => setSelectedPersonId(null)}
renderHeader={renderPersonHeader}
getUpdatedAt={(p) => p.updated_at}
getValue={getPanelValue}
isFavourite={selectedPerson?.is_favourite}
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
favouriteLabel="favourite"
/>
);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="text-3xl font-bold">People</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
<Button onClick={() => setShowForm(true)}> <CategoryFilterBar
activeFilters={activeFilters}
pinnedLabel="Favourites"
showPinned={showPinned}
categories={orderedCategories}
onToggleAll={toggleAll}
onTogglePinned={togglePinned}
onToggleCategory={toggleCategory}
onSelectAllCategories={selectAllCategories}
onReorderCategories={reorderCategories}
searchValue={search}
onSearchChange={setSearch}
/>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Person Add Person
</Button> </Button>
</div> </div>
<Input <div className="flex-1 overflow-hidden flex flex-col">
placeholder="Search people..." {/* Stat bar */}
value={search} {!isLoading && people.length > 0 && (
onChange={(e) => setSearch(e.target.value)} <div className="px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
className="max-w-md" <div className="flex gap-6 shrink-0">
<StatCounter
icon={Users}
iconBg="bg-blue-500/10"
iconColor="text-blue-400"
label="Total"
value={totalCount}
/>
<StatCounter
icon={Star}
iconBg="bg-yellow-500/10"
iconColor="text-yellow-400"
label="Favourites"
value={favouriteCount}
/>
<StatCounter
icon={Cake}
iconBg="bg-pink-500/10"
iconColor="text-pink-400"
label="Upcoming Bdays"
value={upcomingBdayCount}
/> />
</div> </div>
{/* Birthday list */}
<div className="flex-1 flex flex-wrap gap-x-4 gap-y-1 overflow-hidden">
{upcomingBirthdays.slice(0, 5).map((p) => (
<span key={p.id} className="text-[11px] text-muted-foreground whitespace-nowrap">
{p.name} {format(getNextBirthday(p.birthday!), 'MMM d')} (
{getDaysUntilBirthday(p.birthday!)}d)
</span>
))}
{upcomingBirthdays.length > 5 && (
<span className="text-[11px] text-muted-foreground">
+{upcomingBirthdays.length - 5} more
</span>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-6"> {/* Main content: table + panel */}
<div className="flex-1 overflow-hidden flex">
{/* Table */}
<div
ref={tableContainerRef}
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">
{isLoading ? ( {isLoading ? (
<GridSkeleton cards={6} /> <EntityTable<Person>
) : filteredPeople.length === 0 ? ( columns={columns}
groups={[]}
pinnedRows={[]}
pinnedLabel="Favourites"
showPinned={false}
selectedId={null}
onRowClick={() => {}}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
visibilityMode={visibilityMode}
loading={true}
/>
) : filteredPeople.length === 0 && favourites.length === 0 ? (
<EmptyState <EmptyState
icon={Users} icon={Users}
title="No contacts yet" title="No contacts yet"
@ -68,15 +502,58 @@ export default function PeoplePage() {
onAction={() => setShowForm(true)} onAction={() => setShowForm(true)}
/> />
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <EntityTable<Person>
{filteredPeople.map((person) => ( columns={columns}
<PersonCard key={person.id} person={person} onEdit={handleEdit} /> groups={groups}
))} pinnedRows={showPinned ? favourites : []}
</div> pinnedLabel="Favourites"
showPinned={showPinned}
selectedId={selectedPersonId}
onRowClick={(id) =>
setSelectedPersonId((prev) => (prev === id ? null : id))
}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
visibilityMode={visibilityMode}
/>
)} )}
</div> </div>
</div>
{showForm && <PersonForm person={editingPerson} 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 && selectedPerson && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedPersonId(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 && (
<PersonForm
person={editingPerson}
categories={allCategories}
onClose={handleCloseForm}
/>
)}
</div> </div>
); );
} }

View File

@ -1,91 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Mail, Phone, MapPin, Calendar, Trash2, Edit } from 'lucide-react';
import { format } from 'date-fns';
import api from '@/lib/api';
import type { Person } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface PersonCardProps {
person: Person;
onEdit: (person: Person) => void;
}
export default function PersonCard({ person, onEdit }: PersonCardProps) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/people/${person.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
toast.success('Person deleted');
},
onError: () => {
toast.error('Failed to delete person');
},
});
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-xl">{person.name}</CardTitle>
{person.relationship && (
<Badge variant="outline" style={{ marginTop: '0.5rem' }}>
{person.relationship}
</Badge>
)}
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => onEdit(person)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{person.email && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-4 w-4" />
<span className="truncate">{person.email}</span>
</div>
)}
{person.phone && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="h-4 w-4" />
{person.phone}
</div>
)}
{person.address && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
<span className="truncate">{person.address}</span>
</div>
)}
{person.birthday && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
Birthday: {format(new Date(person.birthday), 'MMM d, yyyy')}
</div>
)}
{person.notes && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">{person.notes}</p>
)}
</CardContent>
</Card>
);
}

View File

@ -1,112 +1,167 @@
import { useState, FormEvent } from 'react'; import { useState, useMemo, 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, X } from 'lucide-react';
import { parseISO, differenceInYears } from 'date-fns';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types'; import type { Person } from '@/types';
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, SheetFooter,
DialogClose, } from '@/components/ui/sheet';
} from '@/components/ui/dialog';
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 { 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 CategoryAutocomplete from '@/components/shared/CategoryAutocomplete';
import { splitName } from '@/components/shared/utils';
interface PersonFormProps { interface PersonFormProps {
person: Person | null; person: Person | null;
categories: string[];
onClose: () => void; onClose: () => void;
} }
export default function PersonForm({ person, onClose }: PersonFormProps) { export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: person?.name || '', first_name:
person?.first_name ||
(person?.name ? splitName(person.name).firstName : ''),
last_name:
person?.last_name ||
(person?.name ? splitName(person.name).lastName : ''),
nickname: person?.nickname || '',
email: person?.email || '', email: person?.email || '',
phone: person?.phone || '', phone: person?.phone || '',
mobile: person?.mobile || '',
address: person?.address || '', address: person?.address || '',
birthday: person?.birthday || '', birthday: person?.birthday
relationship: person?.relationship || '', ? person.birthday.slice(0, 10)
: '',
category: person?.category || '',
is_favourite: person?.is_favourite ?? false,
company: person?.company || '',
job_title: person?.job_title || '',
notes: person?.notes || '', notes: person?.notes || '',
}); });
const age = useMemo(() => {
if (!formData.birthday) return null;
try {
return differenceInYears(new Date(), parseISO(formData.birthday));
} catch {
return null;
}
}, [formData.birthday]);
const set = <K extends keyof typeof formData>(key: K, value: (typeof formData)[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: typeof formData) => { mutationFn: async (data: typeof formData) => {
if (person) { if (person) {
const response = await api.put(`/people/${person.id}`, data); const { data: res } = await api.put(`/people/${person.id}`, data);
return response.data; return res;
} else {
const response = await api.post('/people', data);
return response.data;
} }
const { data: res } = await api.post('/people', data);
return res;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(person ? 'Person updated' : 'Person created'); toast.success(person ? 'Person updated' : 'Person created');
onClose(); onClose();
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person')); toast.error(
getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person')
);
}, },
}); });
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
mutation.mutate(formData); mutation.mutate({ ...formData, birthday: formData.birthday || null } as typeof formData);
}; };
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<DialogContent> <SheetContent>
<DialogClose onClick={onClose} /> <SheetHeader>
<DialogHeader> <div className="flex items-center justify-between">
<DialogTitle>{person ? 'Edit Person' : 'New Person'}</DialogTitle> <SheetTitle>{person ? 'Edit Person' : 'New Person'}</SheetTitle>
</DialogHeader> <div className="flex items-center gap-1">
<form onSubmit={handleSubmit} className="space-y-4"> <Button
type="button"
variant="ghost"
size="icon"
className={`h-7 w-7 ${formData.is_favourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
onClick={() => set('is_favourite', !formData.is_favourite)}
aria-label={formData.is_favourite ? 'Remove from favourites' : 'Add to favourites'}
>
{formData.is_favourite ? (
<Star className="h-4 w-4 fill-yellow-400" />
) : (
<StarOff className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close"
className="h-7 w-7 shrink-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
{/* Row 2: First + Last name */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="first_name">First Name</Label>
<Input <Input
id="name" id="first_name"
value={formData.name} value={formData.first_name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => set('first_name', e.target.value)}
required required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="last_name">Last Name</Label>
<Input <Input
id="email" id="last_name"
type="email" value={formData.last_name}
value={formData.email} onChange={(e) => set('last_name', e.target.value)}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/> />
</div> </div>
</div> </div>
{/* Row 3: Nickname */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="address">Address</Label> <Label htmlFor="nickname">Nickname</Label>
<Input <Input
id="address" id="nickname"
value={formData.address} value={formData.nickname}
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(e) => set('nickname', e.target.value)}
placeholder="Optional display name"
/> />
</div> </div>
{/* Row 4: Birthday + Age */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label> <Label htmlFor="birthday">Birthday</Label>
@ -114,41 +169,122 @@ export default function PersonForm({ person, onClose }: PersonFormProps) {
id="birthday" id="birthday"
type="date" type="date"
value={formData.birthday} value={formData.birthday}
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })} onChange={(e) => set('birthday', e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="relationship">Relationship</Label> <Label htmlFor="age">Age</Label>
<Input <Input
id="relationship" id="age"
value={formData.relationship} value={age !== null ? String(age) : ''}
onChange={(e) => setFormData({ ...formData, relationship: e.target.value })} disabled
placeholder="e.g., Friend, Family, Colleague" placeholder="—"
aria-label="Calculated age"
/> />
</div> </div>
</div> </div>
{/* Row 5: Category */}
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<CategoryAutocomplete
id="category"
value={formData.category}
onChange={(val) => set('category', val)}
categories={categories}
placeholder="e.g. Friend, Family, Colleague"
/>
</div>
{/* Row 6: Mobile + Email */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mobile">Mobile</Label>
<Input
id="mobile"
type="tel"
value={formData.mobile}
onChange={(e) => set('mobile', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => set('email', e.target.value)}
/>
</div>
</div>
{/* Row 7: Phone */}
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => set('phone', e.target.value)}
placeholder="Landline / work number"
/>
</div>
{/* Row 8: Address */}
<div className="space-y-2">
<Label htmlFor="address">Address</Label>
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
</div>
{/* Row 9: Company + Job Title */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">Company</Label>
<Input
id="company"
value={formData.company}
onChange={(e) => set('company', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="job_title">Job Title</Label>
<Input
id="job_title"
value={formData.job_title}
onChange={(e) => set('job_title', e.target.value)}
/>
</div>
</div>
{/* Row 10: Notes */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="notes">Notes</Label> <Label htmlFor="notes">Notes</Label>
<Textarea <Textarea
id="notes" id="notes"
value={formData.notes} value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })} onChange={(e) => set('notes', e.target.value)}
rows={3} rows={3}
placeholder="Any additional context..."
/> />
</div> </div>
</div>
<DialogFooter> <SheetFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'} {mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'}
</Button> </Button>
</DialogFooter> </SheetFooter>
</form> </form>
</DialogContent> </SheetContent>
</Dialog> </Sheet>
); );
} }

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,142 @@
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 [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const filtered = categories.filter(
(c) => c.toLowerCase().includes(value.toLowerCase()) && c.toLowerCase() !== value.toLowerCase()
);
// Reset active index when filtered list changes
useEffect(() => {
setActiveIndex(-1);
}, [filtered.length, value]);
// 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);
}, []);
// Scroll active item into view
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const item = listRef.current.children[activeIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}
}, [activeIndex]);
const handleBlur = () => {
const match = categories.find((c) => c.toLowerCase() === value.toLowerCase());
if (match && match !== value) onChange(match);
setOpen(false);
};
const handleSelect = (cat: string) => {
onChange(cat);
setOpen(false);
setActiveIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open || filtered.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
break;
case 'Enter':
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filtered.length) {
handleSelect(filtered[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setOpen(false);
setActiveIndex(-1);
break;
}
};
const activeDescendantId = activeIndex >= 0 ? `${id || 'cat'}-option-${activeIndex}` : undefined;
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}
onKeyDown={handleKeyDown}
autoComplete="off"
role="combobox"
aria-autocomplete="list"
aria-expanded={open && filtered.length > 0}
aria-controls={open && filtered.length > 0 ? `${id || 'cat'}-listbox` : undefined}
aria-activedescendant={activeDescendantId}
/>
{open && filtered.length > 0 && (
<ul
ref={listRef}
id={`${id || 'cat'}-listbox`}
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, idx) => (
<li
key={cat}
id={`${id || 'cat'}-option-${idx}`}
role="option"
aria-selected={idx === activeIndex}
onPointerDown={(e) => {
e.preventDefault();
handleSelect(cat);
}}
className={`px-3 py-1.5 text-sm cursor-pointer transition-colors duration-150 ${
idx === activeIndex
? 'bg-card-elevated text-foreground'
: 'hover:bg-card-elevated'
}`}
>
{cat}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,285 @@
import { useState, useRef, useEffect } from 'react';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface CategoryFilterBarProps {
activeFilters: string[];
pinnedLabel: string;
showPinned: boolean;
categories: string[];
onToggleAll: () => void;
onTogglePinned: () => void;
onToggleCategory: (cat: string) => void;
onSelectAllCategories?: () => void;
onReorderCategories?: (order: 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))',
};
// ---------------------------------------------------------------------------
// SortableCategoryChip
// ---------------------------------------------------------------------------
function SortableCategoryChip({
id,
isActive,
onToggle,
}: {
id: string;
isActive: boolean;
onToggle: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const wasDragging = useRef(false);
useEffect(() => {
if (isDragging) wasDragging.current = true;
}, [isDragging]);
const handleClick = () => {
if (wasDragging.current) {
wasDragging.current = false;
return;
}
onToggle();
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
...(isActive ? activePillStyle : {}),
};
return (
<button
ref={setNodeRef}
type="button"
onClick={handleClick}
aria-label={`Filter by ${id}`}
className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0 touch-none cursor-grab active:cursor-grabbing"
style={style}
{...attributes}
aria-pressed={isActive}
{...listeners}
>
<span className={isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
{id}
</span>
</button>
);
}
// ---------------------------------------------------------------------------
// CategoryFilterBar
// ---------------------------------------------------------------------------
export default function CategoryFilterBar({
activeFilters,
pinnedLabel,
showPinned,
categories,
onToggleAll,
onTogglePinned,
onToggleCategory,
onSelectAllCategories,
onReorderCategories,
searchValue,
onSearchChange,
}: CategoryFilterBarProps) {
const [otherOpen, setOtherOpen] = useState(false);
const [searchCollapsed, setSearchCollapsed] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const isAllActive = activeFilters.length === 0;
const allCategoriesSelected =
categories.length > 0 && categories.every((c) => activeFilters.includes(c));
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
// Collapse search if there are many categories
useEffect(() => {
setSearchCollapsed(categories.length >= 4);
}, [categories.length]);
const handleExpandSearch = () => {
setSearchCollapsed(false);
setTimeout(() => searchInputRef.current?.focus(), 50);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !onReorderCategories) return;
const oldIndex = categories.indexOf(String(active.id));
const newIndex = categories.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
onReorderCategories(arrayMove(categories, oldIndex, newIndex));
};
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>
{/* Categories 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'}>
Categories
</span>
</button>
<div
className="flex items-center gap-1.5 overflow-x-auto transition-all duration-200 ease-out"
style={{
maxWidth: otherOpen ? '100vw' : '0px',
opacity: otherOpen ? 1 : 0,
overflow: 'hidden',
}}
>
{/* "All" chip inside categories — non-draggable */}
{onSelectAllCategories && (
<button
type="button"
onClick={onSelectAllCategories}
aria-label="Select all categories"
aria-pressed={allCategoriesSelected}
className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0"
style={allCategoriesSelected ? activePillStyle : undefined}
>
<span
className={
allCategoriesSelected
? ''
: 'text-muted-foreground hover:text-foreground'
}
>
All
</span>
</button>
)}
{/* Draggable category chips */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories}
strategy={horizontalListSortingStrategy}
>
{categories.map((cat) => (
<SortableCategoryChip
key={cat}
id={cat}
isActive={activeFilters.includes(cat)}
onToggle={() => onToggleCategory(cat)}
/>
))}
</SortableContext>
</DndContext>
</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,36 @@
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);
}).catch(() => {
// Clipboard API can fail in non-secure contexts or when permission is denied
});
};
return (
<div className="group inline-flex items-center gap-2 max-w-full">
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="text-sm truncate">{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,148 @@
import { X, Pencil, Trash2, Star, StarOff } 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;
multiline?: boolean;
}
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;
isFavourite?: boolean;
onToggleFavourite?: () => void;
favouriteLabel?: string;
}
export function EntityDetailPanel<T>({
item,
fields,
onEdit,
onDelete,
deleteLoading = false,
onClose,
renderHeader,
getUpdatedAt,
getValue,
isFavourite,
onToggleFavourite,
favouriteLabel = 'favourite',
}: EntityDetailPanelProps<T>) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
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>
<div className="flex items-center gap-1 shrink-0 ml-2">
{onToggleFavourite && (
<Button
variant="ghost"
size="icon"
onClick={onToggleFavourite}
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
className={`h-7 w-7 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
>
{isFavourite ? (
<Star className="h-4 w-4 fill-yellow-400" />
) : (
<StarOff className="h-4 w-4" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* 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 ${field.multiline ? 'whitespace-pre-wrap' : ''}`}>{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,205 @@
import React from 'react';
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;
}
export interface RowGroup<T> {
label: string;
rows: T[];
}
interface EntityTableProps<T extends { id: number }> {
columns: ColumnDef<T>[];
groups: RowGroup<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>
);
}
function SortIcon({
sortKey,
sortDir,
colKey,
}: {
sortKey: string;
sortDir: 'asc' | 'desc';
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" />
);
}
function DataRow<T extends { id: number }>({
item,
visibleColumns,
selectedId,
onRowClick,
}: {
item: T;
visibleColumns: ColumnDef<T>[];
selectedId: number | null;
onRowClick: (id: number) => void;
}) {
return (
<tr
className={`border-b border-border/50 cursor-pointer hover:bg-card-elevated transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-ring ${
selectedId === item.id ? 'bg-accent/10' : ''
}`}
onClick={() => onRowClick(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(item.id);
}
}}
tabIndex={0}
role="row"
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>
);
}
function SectionHeader({ label, colCount }: { label: string; colCount: number }) {
return (
<tr>
<td
colSpan={colCount}
className="px-3 pt-4 pb-1.5 text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
>
{label}
</td>
</tr>
);
}
export function EntityTable<T extends { id: number }>({
columns,
groups,
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;
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 sortKey={sortKey} sortDir={sortDir} colKey={col.key} />
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} colCount={colCount} />)
) : (
<>
{showPinnedSection && (
<>
<SectionHeader label={pinnedLabel} colCount={colCount} />
{pinnedRows.map((item) => (
<DataRow
key={item.id}
item={item}
visibleColumns={visibleColumns}
selectedId={selectedId}
onRowClick={onRowClick}
/>
))}
</>
)}
{groups.map((group) => (
<React.Fragment key={group.label}>
{group.rows.length > 0 && (
<>
<SectionHeader label={group.label} colCount={colCount} />
{group.rows.map((item) => (
<DataRow
key={item.id}
item={item}
visibleColumns={visibleColumns}
selectedId={selectedId}
onRowClick={onRowClick}
/>
))}
</>
)}
</React.Fragment>
))}
</>
)}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,8 @@
export { EntityTable } from './EntityTable';
export type { ColumnDef, RowGroup } 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,57 @@
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 {
if (!name.trim()) return '??';
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,41 @@
import { useState, useCallback, useMemo } from 'react';
const STORAGE_PREFIX = 'umbra-';
export function useCategoryOrder(key: string, categories: string[]) {
const storageKey = `${STORAGE_PREFIX}${key}-category-order`;
const [savedOrder, setSavedOrder] = useState<string[]>(() => {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
});
const orderedCategories = useMemo(() => {
const catSet = new Set(categories);
// Keep saved items that still exist, in saved order
const ordered = savedOrder.filter((c) => catSet.has(c));
// Append any new categories not in saved order
const remaining = categories.filter((c) => !savedOrder.includes(c));
return [...ordered, ...remaining];
}, [categories, savedOrder]);
const reorder = useCallback(
(newOrder: string[]) => {
setSavedOrder(newOrder);
try {
localStorage.setItem(storageKey, JSON.stringify(newOrder));
} catch {
// localStorage full — silently ignore
}
},
[storageKey],
);
return { orderedCategories, reorder };
}

View File

@ -0,0 +1,56 @@
import { useState, useEffect, useRef } from 'react';
export type VisibilityMode = 'all' | 'filtered' | 'essential';
function calculate(width: number, panelOpen: boolean): VisibilityMode {
if (panelOpen) {
return width >= 600 ? 'filtered' : 'essential';
}
if (width >= 900) return 'all';
if (width >= 600) return 'filtered';
return '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 observer = new ResizeObserver((entries) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const entry = entries[0];
if (entry) {
setMode(calculate(entry.contentRect.width, panelOpen));
}
}, 50);
});
observer.observe(el);
setMode(calculate(el.getBoundingClientRect().width, panelOpen));
return () => {
observer.disconnect();
clearTimeout(timerRef.current);
};
}, [containerRef, panelOpen]);
// Recalculate when panelOpen changes
useEffect(() => {
const el = containerRef.current;
if (!el) return;
setMode(calculate(el.getBoundingClientRect().width, panelOpen));
}, [panelOpen, containerRef]);
return mode;
}

View File

@ -142,11 +142,18 @@ 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;
relationship?: string; category?: 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 +164,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;