Entity pages enhancement: backend model extensions, shared components, Locations rebuild, panel animations
- Add migrations 019/020: extend Person (first/last name, nickname, is_favourite, company, job_title, mobile, category) and Location (is_frequent, contact_number, email) - Update Person/Location models, schemas, and routers with new fields + name denormalisation - Create shared component library: EntityTable, EntityDetailPanel, CategoryFilterBar, CopyableField, CategoryAutocomplete, useTableVisibility hook - Rebuild LocationsPage: table layout with sortable columns, detail side panel, category filter bar, frequent pinned section - Extend LocationForm with contact number, email, frequent toggle, category autocomplete - Add animated panel transitions to ProjectDetail (55/45 split with cubic-bezier easing) - Update TypeScript interfaces for Person and Location Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f4b1239904
commit
cb9f74a387
40
backend/alembic/versions/019_extend_person_model.py
Normal file
40
backend/alembic/versions/019_extend_person_model.py
Normal 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')
|
||||
28
backend/alembic/versions/020_extend_location_model.py
Normal file
28
backend/alembic/versions/020_extend_location_model.py
Normal 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')
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import String, Text, func
|
||||
from sqlalchemy import String, Text, Boolean, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
@ -12,6 +12,9 @@ class Location(Base):
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
address: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(100), default="other")
|
||||
is_frequent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
contact_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import String, Text, Date, func
|
||||
from sqlalchemy import String, Text, Date, Boolean, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
@ -16,6 +16,15 @@ class Person(Base):
|
||||
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)
|
||||
# Extended fields
|
||||
first_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
nickname: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
is_favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
from app.database import get_db
|
||||
@ -12,17 +13,35 @@ from app.models.settings import Settings
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _compute_display_name(
|
||||
first_name: Optional[str],
|
||||
last_name: Optional[str],
|
||||
nickname: Optional[str],
|
||||
name: Optional[str],
|
||||
) -> str:
|
||||
"""Denormalise a display name. Nickname wins; else 'First Last'; else legacy name."""
|
||||
if nickname:
|
||||
return nickname
|
||||
full = ((first_name or '') + ' ' + (last_name or '')).strip()
|
||||
if full:
|
||||
return full
|
||||
return name or ''
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PersonResponse])
|
||||
async def get_people(
|
||||
search: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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)
|
||||
|
||||
if search:
|
||||
query = query.where(Person.name.ilike(f"%{search}%"))
|
||||
if category:
|
||||
query = query.where(Person.category == category)
|
||||
|
||||
query = query.order_by(Person.name.asc())
|
||||
|
||||
@ -38,8 +57,14 @@ async def create_person(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
):
|
||||
"""Create a new person."""
|
||||
"""Create a new person with denormalised display name."""
|
||||
new_person = Person(**person.model_dump())
|
||||
new_person.name = _compute_display_name(
|
||||
new_person.first_name,
|
||||
new_person.last_name,
|
||||
new_person.nickname,
|
||||
new_person.name,
|
||||
)
|
||||
db.add(new_person)
|
||||
await db.commit()
|
||||
await db.refresh(new_person)
|
||||
@ -70,7 +95,7 @@ async def update_person(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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))
|
||||
person = result.scalar_one_or_none()
|
||||
|
||||
@ -82,6 +107,16 @@ async def update_person(
|
||||
for key, value in update_data.items():
|
||||
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.refresh(person)
|
||||
|
||||
|
||||
@ -15,6 +15,9 @@ class LocationCreate(BaseModel):
|
||||
address: str
|
||||
category: str = "other"
|
||||
notes: Optional[str] = None
|
||||
is_frequent: bool = False
|
||||
contact_number: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class LocationUpdate(BaseModel):
|
||||
@ -22,6 +25,9 @@ class LocationUpdate(BaseModel):
|
||||
address: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_frequent: Optional[bool] = None
|
||||
contact_number: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class LocationResponse(BaseModel):
|
||||
@ -30,6 +36,9 @@ class LocationResponse(BaseModel):
|
||||
address: str
|
||||
category: str
|
||||
notes: Optional[str]
|
||||
is_frequent: bool
|
||||
contact_number: Optional[str]
|
||||
email: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@ -1,36 +1,64 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from datetime import datetime, date
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PersonCreate(BaseModel):
|
||||
name: str
|
||||
name: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
address: Optional[str] = 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
|
||||
|
||||
@model_validator(mode='after')
|
||||
def require_some_name(self) -> 'PersonCreate':
|
||||
if not any([self.name, self.first_name, self.last_name, self.nickname]):
|
||||
raise ValueError('At least one name field is required')
|
||||
return self
|
||||
|
||||
|
||||
class PersonUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
address: Optional[str] = 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
|
||||
|
||||
|
||||
class PersonResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
nickname: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
mobile: Optional[str]
|
||||
address: Optional[str]
|
||||
birthday: Optional[date]
|
||||
category: Optional[str]
|
||||
relationship: Optional[str]
|
||||
is_favourite: bool
|
||||
company: Optional[str]
|
||||
job_title: Optional[str]
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { MapPin, Trash2, Pencil } from 'lucide-react';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Location } from '@/types';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { getCategoryColor } from './constants';
|
||||
|
||||
interface LocationCardProps {
|
||||
location: Location;
|
||||
onEdit: (location: Location) => void;
|
||||
}
|
||||
|
||||
const QUERY_KEYS = [['locations'], ['dashboard'], ['upcoming']] as const;
|
||||
|
||||
export default function LocationCard({ location, onEdit }: LocationCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.delete(`/locations/${location.id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
|
||||
toast.success('Location deleted');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to delete location'));
|
||||
},
|
||||
});
|
||||
|
||||
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
||||
const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-heading text-lg font-semibold leading-none tracking-tight flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{location.name}</span>
|
||||
</h3>
|
||||
<Badge className={`mt-1.5 ${getCategoryColor(location.category)}`}>
|
||||
{location.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => onEdit(location)} className="h-7 w-7" aria-label="Edit location">
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
{confirmingDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Confirm delete"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="h-7 shrink-0 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||
>
|
||||
Sure?
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete location"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{location.address && (
|
||||
<p className="text-xs text-muted-foreground truncate">{location.address}</p>
|
||||
)}
|
||||
{location.notes && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{location.notes}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Star, StarOff } from 'lucide-react';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Location } from '@/types';
|
||||
import {
|
||||
@ -13,23 +14,27 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import LocationPicker from '@/components/ui/location-picker';
|
||||
import { CATEGORIES } from './constants';
|
||||
import { CategoryAutocomplete } from '@/components/shared';
|
||||
|
||||
interface LocationFormProps {
|
||||
location: Location | null;
|
||||
categories: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function LocationForm({ location, onClose }: LocationFormProps) {
|
||||
export default function LocationForm({ location, categories, onClose }: LocationFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: location?.name || '',
|
||||
address: location?.address || '',
|
||||
category: location?.category || 'other',
|
||||
contact_number: location?.contact_number || '',
|
||||
email: location?.email || '',
|
||||
is_frequent: location?.is_frequent ?? false,
|
||||
notes: location?.notes || '',
|
||||
});
|
||||
|
||||
@ -51,7 +56,12 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, location ? 'Failed to update location' : 'Failed to create location'));
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
location ? 'Failed to update location' : 'Failed to create location'
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@ -65,24 +75,46 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
|
||||
<SheetContent>
|
||||
<SheetClose onClick={onClose} />
|
||||
<SheetHeader>
|
||||
<SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle>
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={formData.is_frequent ? 'Remove from frequent' : 'Mark as frequent'}
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({ ...prev, is_frequent: !prev.is_frequent }))
|
||||
}
|
||||
>
|
||||
{formData.is_frequent ? (
|
||||
<Star className="h-4 w-4 text-yellow-400 fill-yellow-400" />
|
||||
) : (
|
||||
<StarOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-5 space-y-4 flex-1">
|
||||
{/* Location Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Label htmlFor="loc-name">Location Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
id="loc-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g. Home, Office, Coffee Shop"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Label htmlFor="loc-address">Address</Label>
|
||||
<LocationPicker
|
||||
id="address"
|
||||
id="loc-address"
|
||||
value={formData.address}
|
||||
onChange={(val) => setFormData({ ...formData, address: val })}
|
||||
onSelect={(result) => {
|
||||
@ -96,27 +128,52 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{/* Contact Number + Email */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loc-contact">Contact Number</Label>
|
||||
<Input
|
||||
id="loc-contact"
|
||||
type="tel"
|
||||
value={formData.contact_number}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, contact_number: e.target.value })
|
||||
}
|
||||
placeholder="+44..."
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Category */}
|
||||
<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
|
||||
id="notes"
|
||||
id="loc-notes"
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Any additional details..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,27 +1,36 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus, MapPin, Tag, Search } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import { Plus, MapPin, Phone, Mail } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Location } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { GridSkeleton } from '@/components/ui/skeleton';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { CATEGORIES } from './constants';
|
||||
import LocationCard from './LocationCard';
|
||||
import {
|
||||
EntityTable,
|
||||
EntityDetailPanel,
|
||||
CategoryFilterBar,
|
||||
type ColumnDef,
|
||||
type PanelField,
|
||||
} from '@/components/shared';
|
||||
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
||||
import LocationForm from './LocationForm';
|
||||
|
||||
const categoryFilters = [
|
||||
{ value: '', label: 'All' },
|
||||
...CATEGORIES.map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) })),
|
||||
] as const;
|
||||
|
||||
export default function LocationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
const [showPinned, setShowPinned] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<string>('name');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const panelOpen = selectedLocationId !== null;
|
||||
const visibilityMode = useTableVisibility(tableRef as React.RefObject<HTMLElement>, panelOpen);
|
||||
|
||||
const { data: locations = [], isLoading } = useQuery({
|
||||
queryKey: ['locations'],
|
||||
@ -31,29 +40,81 @@ export default function LocationsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const filteredLocations = useMemo(
|
||||
() =>
|
||||
locations.filter((loc) => {
|
||||
if (filter && loc.category !== filter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const matchName = loc.name.toLowerCase().includes(q);
|
||||
const matchAddress = loc.address?.toLowerCase().includes(q);
|
||||
if (!matchName && !matchAddress) return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[locations, filter, search]
|
||||
);
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.delete(`/locations/${selectedLocationId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||
toast.success('Location deleted');
|
||||
setSelectedLocationId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to delete location'));
|
||||
},
|
||||
});
|
||||
|
||||
const totalCount = locations.length;
|
||||
const categoryCount = useMemo(
|
||||
() => new Set(locations.map((l) => l.category)).size,
|
||||
const allCategories = useMemo(
|
||||
() => Array.from(new Set(locations.map((l) => l.category).filter(Boolean))).sort(),
|
||||
[locations]
|
||||
);
|
||||
|
||||
const handleEdit = (location: Location) => {
|
||||
setEditingLocation(location);
|
||||
const sortedLocations = useMemo(() => {
|
||||
return [...locations].sort((a, b) => {
|
||||
const aVal = String((a as unknown as Record<string, unknown>)[sortKey] ?? '');
|
||||
const bVal = String((b as unknown as Record<string, unknown>)[sortKey] ?? '');
|
||||
const cmp = aVal.localeCompare(bVal);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [locations, sortKey, sortDir]);
|
||||
|
||||
const frequentLocations = useMemo(
|
||||
() => sortedLocations.filter((l) => l.is_frequent),
|
||||
[sortedLocations]
|
||||
);
|
||||
|
||||
const filteredLocations = useMemo(
|
||||
() =>
|
||||
sortedLocations.filter((l) => {
|
||||
if (showPinned && l.is_frequent) return false;
|
||||
if (activeFilters.length > 0 && !activeFilters.includes(l.category)) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
if (
|
||||
!l.name.toLowerCase().includes(q) &&
|
||||
!(l.address?.toLowerCase().includes(q)) &&
|
||||
!(l.category?.toLowerCase().includes(q))
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[sortedLocations, activeFilters, search, showPinned]
|
||||
);
|
||||
|
||||
const selectedLocation = useMemo(
|
||||
() => locations.find((l) => l.id === selectedLocationId) ?? null,
|
||||
[locations, selectedLocationId]
|
||||
);
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (id: number) => {
|
||||
setSelectedLocationId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!selectedLocation) return;
|
||||
setEditingLocation(selectedLocation);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
@ -62,104 +123,216 @@ export default function LocationsPage() {
|
||||
setEditingLocation(null);
|
||||
};
|
||||
|
||||
const handleToggleCategory = (cat: string) => {
|
||||
setActiveFilters((prev) =>
|
||||
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Location>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Location',
|
||||
sortable: true,
|
||||
visibilityLevel: 'essential',
|
||||
render: (l) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1 rounded-md bg-accent/10 shrink-0">
|
||||
<MapPin
|
||||
className="h-3.5 w-3.5"
|
||||
style={{ color: 'hsl(var(--accent-color))' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium truncate">{l.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
label: 'Address',
|
||||
sortable: true,
|
||||
visibilityLevel: 'essential',
|
||||
render: (l) => <span className="text-muted-foreground truncate">{l.address}</span>,
|
||||
},
|
||||
{
|
||||
key: 'contact_number',
|
||||
label: 'Contact',
|
||||
sortable: false,
|
||||
visibilityLevel: 'filtered',
|
||||
render: (l) => (
|
||||
<span className="text-muted-foreground">{l.contact_number || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
sortable: true,
|
||||
visibilityLevel: 'filtered',
|
||||
render: (l) => (
|
||||
<span className="text-muted-foreground truncate">{l.email || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
sortable: true,
|
||||
visibilityLevel: 'all',
|
||||
render: (l) => (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded"
|
||||
style={{
|
||||
backgroundColor: 'hsl(var(--accent-color) / 0.1)',
|
||||
color: 'hsl(var(--accent-color))',
|
||||
}}
|
||||
>
|
||||
{l.category}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const panelFields: PanelField[] = [
|
||||
{ label: 'Address', key: 'address', copyable: true, icon: MapPin },
|
||||
{ label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone },
|
||||
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
||||
{ label: 'Category', key: 'category' },
|
||||
{ label: 'Notes', key: 'notes' },
|
||||
];
|
||||
|
||||
const renderPanel = () => (
|
||||
<EntityDetailPanel<Location>
|
||||
item={selectedLocation}
|
||||
fields={panelFields}
|
||||
onEdit={handleEdit}
|
||||
onDelete={() => deleteMutation.mutate()}
|
||||
deleteLoading={deleteMutation.isPending}
|
||||
onClose={() => setSelectedLocationId(null)}
|
||||
renderHeader={(l) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/10">
|
||||
<MapPin
|
||||
className="h-5 w-5"
|
||||
style={{ color: 'hsl(var(--accent-color))' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-heading text-lg font-semibold truncate">{l.name}</h3>
|
||||
<span className="text-xs text-muted-foreground">{l.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
getUpdatedAt={(l) => l.updated_at}
|
||||
getValue={(l, key) =>
|
||||
(l as unknown as Record<string, string | undefined>)[key] ?? undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
|
||||
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
||||
{categoryFilters.map((cf) => (
|
||||
<button
|
||||
key={cf.value}
|
||||
onClick={() => setFilter(cf.value)}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
||||
filter === cf.value
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
filter === cf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: filter === cf.value ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
{cf.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative ml-2">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-52 h-8 pl-8 text-sm"
|
||||
<div className="flex-1 min-w-0">
|
||||
<CategoryFilterBar
|
||||
categories={allCategories}
|
||||
activeFilters={activeFilters}
|
||||
pinnedLabel="Frequent"
|
||||
showPinned={showPinned}
|
||||
onToggleAll={() => setActiveFilters([])}
|
||||
onTogglePinned={() => setShowPinned((v) => !v)}
|
||||
onToggleCategory={handleToggleCategory}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button onClick={() => setShowForm(true)} size="sm">
|
||||
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Location
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && locations.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-2 mb-5">
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||
<MapPin className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Total
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-purple-500/10">
|
||||
<Tag className="h-4 w-4 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
Categories
|
||||
</p>
|
||||
<p className="font-heading text-xl font-bold tabular-nums">{categoryCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* Table */}
|
||||
<div
|
||||
ref={tableRef}
|
||||
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 pb-6 pt-5">
|
||||
{isLoading ? (
|
||||
<EntityTable<Location>
|
||||
columns={columns}
|
||||
rows={[]}
|
||||
pinnedRows={[]}
|
||||
pinnedLabel="Frequent"
|
||||
showPinned={false}
|
||||
selectedId={null}
|
||||
onRowClick={() => {}}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
visibilityMode={visibilityMode}
|
||||
loading={true}
|
||||
/>
|
||||
) : locations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
title="No locations yet"
|
||||
description="Add locations to organise your favourite places, workspaces, and more."
|
||||
actionLabel="Add Location"
|
||||
onAction={() => setShowForm(true)}
|
||||
/>
|
||||
) : (
|
||||
<EntityTable<Location>
|
||||
columns={columns}
|
||||
rows={filteredLocations}
|
||||
pinnedRows={frequentLocations}
|
||||
pinnedLabel="Frequent"
|
||||
showPinned={showPinned}
|
||||
selectedId={selectedLocationId}
|
||||
onRowClick={handleRowClick}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
visibilityMode={visibilityMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<GridSkeleton cards={6} />
|
||||
) : filteredLocations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
title="No locations yet"
|
||||
description="Add locations to organise your favourite places, workspaces, and more."
|
||||
actionLabel="Add Location"
|
||||
onAction={() => setShowForm(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredLocations.map((location) => (
|
||||
<LocationCard key={location.id} location={location} onEdit={handleEdit} />
|
||||
))}
|
||||
{/* 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>
|
||||
|
||||
{showForm && <LocationForm location={editingLocation} onClose={handleCloseForm} />}
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && selectedLocation && (
|
||||
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
||||
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
||||
{renderPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<LocationForm
|
||||
location={editingLocation}
|
||||
categories={allCategories}
|
||||
onClose={handleCloseForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
export const CATEGORIES = ['home', 'work', 'restaurant', 'shop', 'other'] as const;
|
||||
|
||||
export const categoryColors: Record<string, string> = {
|
||||
home: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
work: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||
restaurant: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
||||
shop: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
other: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||
};
|
||||
|
||||
const FALLBACK = 'bg-gray-500/10 text-gray-400 border-gray-500/20';
|
||||
|
||||
export function getCategoryColor(category: string | undefined): string {
|
||||
if (!category) return FALLBACK;
|
||||
return categoryColors[category] ?? FALLBACK;
|
||||
const colors: Record<string, string> = {
|
||||
home: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
work: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||
restaurant: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
||||
shop: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
other: FALLBACK,
|
||||
};
|
||||
return colors[category] ?? FALLBACK;
|
||||
}
|
||||
|
||||
@ -543,7 +543,7 @@ export default function ProjectDetail() {
|
||||
{/* Main content: task list/kanban + detail panel */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* 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">
|
||||
{topLevelTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
@ -631,20 +631,22 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
|
||||
{/* Right panel: task detail (hidden on small screens) */}
|
||||
{selectedTaskId && (
|
||||
<div className="hidden lg:flex lg:w-[45%] border-l border-border bg-card">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
projectId={parseInt(id!)}
|
||||
onDelete={handleDeleteTask}
|
||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card ${
|
||||
selectedTaskId ? 'hidden lg:flex lg:w-[45%]' : 'w-0 opacity-0 border-l-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 overflow-hidden min-w-[360px]">
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
projectId={parseInt(id!)}
|
||||
onDelete={handleDeleteTask}
|
||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
87
frontend/src/components/shared/CategoryAutocomplete.tsx
Normal file
87
frontend/src/components/shared/CategoryAutocomplete.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
categories: string[];
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function CategoryAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
categories,
|
||||
placeholder,
|
||||
id,
|
||||
}: CategoryAutocompleteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = categories.filter(
|
||||
(c) => c.toLowerCase().includes(value.toLowerCase()) && c.toLowerCase() !== value.toLowerCase()
|
||||
);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, []);
|
||||
|
||||
const handleBlur = () => {
|
||||
// Normalise casing if input matches an existing category
|
||||
setTimeout(() => {
|
||||
const match = categories.find((c) => c.toLowerCase() === value.toLowerCase());
|
||||
if (match && match !== value) onChange(match);
|
||||
setOpen(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleSelect = (cat: string) => {
|
||||
onChange(cat);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open && filtered.length > 0}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="absolute z-10 mt-1 w-full bg-card border border-border rounded-md shadow-lg max-h-40 overflow-y-auto"
|
||||
>
|
||||
{filtered.map((cat) => (
|
||||
<li
|
||||
key={cat}
|
||||
role="option"
|
||||
aria-selected={cat === value}
|
||||
onMouseDown={() => handleSelect(cat)}
|
||||
className="px-3 py-1.5 text-sm hover:bg-card-elevated cursor-pointer transition-colors duration-150"
|
||||
>
|
||||
{cat}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/src/components/shared/CategoryFilterBar.tsx
Normal file
165
frontend/src/components/shared/CategoryFilterBar.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface CategoryFilterBarProps {
|
||||
activeFilters: string[];
|
||||
pinnedLabel: string;
|
||||
showPinned: boolean;
|
||||
categories: string[];
|
||||
onToggleAll: () => void;
|
||||
onTogglePinned: () => void;
|
||||
onToggleCategory: (cat: string) => void;
|
||||
searchValue: string;
|
||||
onSearchChange: (val: string) => void;
|
||||
}
|
||||
|
||||
const pillBase =
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md transition-colors duration-150 whitespace-nowrap shrink-0';
|
||||
|
||||
const activePillStyle = {
|
||||
backgroundColor: 'hsl(var(--accent-color) / 0.15)',
|
||||
color: 'hsl(var(--accent-color))',
|
||||
};
|
||||
|
||||
export default function CategoryFilterBar({
|
||||
activeFilters,
|
||||
pinnedLabel,
|
||||
showPinned,
|
||||
categories,
|
||||
onToggleAll,
|
||||
onTogglePinned,
|
||||
onToggleCategory,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
}: CategoryFilterBarProps) {
|
||||
const [otherOpen, setOtherOpen] = useState(false);
|
||||
const [searchCollapsed, setSearchCollapsed] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isAllActive = activeFilters.length === 0;
|
||||
|
||||
// Collapse search if there are many categories
|
||||
useEffect(() => {
|
||||
setSearchCollapsed(categories.length >= 4);
|
||||
}, [categories.length]);
|
||||
|
||||
const handleExpandSearch = () => {
|
||||
setSearchCollapsed(false);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{/* All pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAll}
|
||||
aria-label="Show all"
|
||||
className={pillBase}
|
||||
style={isAllActive ? activePillStyle : undefined}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isAllActive ? '' : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}
|
||||
>
|
||||
All
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Pinned pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePinned}
|
||||
aria-label={`Toggle ${pinnedLabel}`}
|
||||
className={pillBase}
|
||||
style={showPinned ? activePillStyle : undefined}
|
||||
>
|
||||
<span className={showPinned ? '' : 'text-muted-foreground hover:text-foreground'}>
|
||||
{pinnedLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Other pill + expandable chips */}
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOtherOpen((p) => !p)}
|
||||
aria-label="Toggle category filters"
|
||||
className={pillBase}
|
||||
style={otherOpen ? activePillStyle : undefined}
|
||||
>
|
||||
<span className={otherOpen ? '' : 'text-muted-foreground hover:text-foreground'}>
|
||||
Other
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 overflow-x-auto transition-all duration-200 ease-out"
|
||||
style={{
|
||||
maxWidth: otherOpen ? '600px' : '0px',
|
||||
opacity: otherOpen ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{categories.map((cat) => {
|
||||
const isActive = activeFilters.includes(cat);
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => onToggleCategory(cat)}
|
||||
aria-label={`Filter by ${cat}`}
|
||||
aria-pressed={isActive}
|
||||
className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0"
|
||||
style={
|
||||
isActive
|
||||
? activePillStyle
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className={isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
|
||||
{cat}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search */}
|
||||
{searchCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpandSearch}
|
||||
aria-label="Expand search"
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-card-elevated transition-colors duration-150"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative transition-all duration-200">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (!searchValue && categories.length >= 4) setSearchCollapsed(true);
|
||||
}}
|
||||
className="w-44 h-8 pl-8 text-sm"
|
||||
aria-label="Search"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/shared/CopyableField.tsx
Normal file
34
frontend/src/components/shared/CopyableField.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, type LucideIcon } from 'lucide-react';
|
||||
|
||||
interface CopyableFieldProps {
|
||||
value: string;
|
||||
icon?: LucideIcon;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function CopyableField({ value, icon: Icon, label }: CopyableFieldProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2">
|
||||
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="text-sm truncate flex-1">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={`Copy ${label || value}`}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend/src/components/shared/EntityDetailPanel.tsx
Normal file
126
frontend/src/components/shared/EntityDetailPanel.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useCallback } from 'react';
|
||||
import { X, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { formatUpdatedAt } from './utils';
|
||||
import CopyableField from './CopyableField';
|
||||
|
||||
export interface PanelField {
|
||||
label: string;
|
||||
key: string;
|
||||
copyable?: boolean;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
interface EntityDetailPanelProps<T> {
|
||||
item: T | null;
|
||||
fields: PanelField[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
deleteLoading?: boolean;
|
||||
onClose: () => void;
|
||||
renderHeader: (item: T) => React.ReactNode;
|
||||
getUpdatedAt: (item: T) => string;
|
||||
getValue: (item: T, key: string) => string | undefined;
|
||||
}
|
||||
|
||||
export function EntityDetailPanel<T>({
|
||||
item,
|
||||
fields,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteLoading = false,
|
||||
onClose,
|
||||
renderHeader,
|
||||
getUpdatedAt,
|
||||
getValue,
|
||||
}: EntityDetailPanelProps<T>) {
|
||||
const executeDelete = useCallback(() => onDelete(), [onDelete]);
|
||||
const { confirming, handleClick: handleDelete } = useConfirmAction(executeDelete);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">{renderHeader(item)}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
className="h-7 w-7 shrink-0 ml-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||
{fields.map((field) => {
|
||||
const value = getValue(item, field.key);
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div key={field.key}>
|
||||
{field.copyable ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{field.label}
|
||||
</p>
|
||||
<CopyableField value={value} icon={field.icon} label={field.label} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{field.label}
|
||||
</p>
|
||||
<p className="text-sm">{value}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
{confirming ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteLoading}
|
||||
aria-label="Confirm delete"
|
||||
className="h-8 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||
>
|
||||
Sure?
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteLoading}
|
||||
aria-label="Delete"
|
||||
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend/src/components/shared/EntityTable.tsx
Normal file
147
frontend/src/components/shared/EntityTable.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { VisibilityMode } from '@/hooks/useTableVisibility';
|
||||
|
||||
export interface ColumnDef<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
render: (item: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
visibilityLevel: VisibilityMode;
|
||||
}
|
||||
|
||||
interface EntityTableProps<T extends { id: number }> {
|
||||
columns: ColumnDef<T>[];
|
||||
rows: T[];
|
||||
pinnedRows: T[];
|
||||
pinnedLabel: string;
|
||||
showPinned: boolean;
|
||||
selectedId: number | null;
|
||||
onRowClick: (id: number) => void;
|
||||
sortKey: string;
|
||||
sortDir: 'asc' | 'desc';
|
||||
onSort: (key: string) => void;
|
||||
visibilityMode: VisibilityMode;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
|
||||
|
||||
function isVisible(colLevel: VisibilityMode, mode: VisibilityMode): boolean {
|
||||
return LEVEL_ORDER.indexOf(colLevel) <= LEVEL_ORDER.indexOf(mode);
|
||||
}
|
||||
|
||||
function SkeletonRow({ colCount }: { colCount: number }) {
|
||||
return (
|
||||
<tr className="border-b border-border/50">
|
||||
{Array.from({ length: colCount }).map((_, i) => (
|
||||
<td key={i} className="px-3 py-2.5">
|
||||
<div className="animate-pulse rounded-md bg-muted h-4 w-full" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityTable<T extends { id: number }>({
|
||||
columns,
|
||||
rows,
|
||||
pinnedRows,
|
||||
pinnedLabel,
|
||||
showPinned,
|
||||
selectedId,
|
||||
onRowClick,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
visibilityMode,
|
||||
loading = false,
|
||||
}: EntityTableProps<T>) {
|
||||
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
|
||||
const colCount = visibleColumns.length;
|
||||
const showPinnedSection = showPinned && pinnedRows.length > 0;
|
||||
|
||||
const SortIcon = ({ colKey }: { colKey: string }) => {
|
||||
if (sortKey !== colKey) return <ArrowUpDown className="h-3.5 w-3.5 ml-1 opacity-40" />;
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="h-3.5 w-3.5 ml-1" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5 ml-1" />
|
||||
);
|
||||
};
|
||||
|
||||
const DataRow = ({ item }: { item: T }) => (
|
||||
<tr
|
||||
className={`border-b border-border/50 cursor-pointer hover:bg-card-elevated transition-colors duration-150 ${
|
||||
selectedId === item.id ? 'bg-accent/10' : ''
|
||||
}`}
|
||||
onClick={() => onRowClick(item.id)}
|
||||
aria-selected={selectedId === item.id}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col.key} className="px-3 py-2.5 text-sm">
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{visibleColumns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
|
||||
>
|
||||
{col.sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(col.key)}
|
||||
aria-label={`Sort by ${col.label}`}
|
||||
className="flex items-center hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
{col.label}
|
||||
<SortIcon colKey={col.key} />
|
||||
</button>
|
||||
) : (
|
||||
col.label
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} colCount={colCount} />)
|
||||
) : (
|
||||
<>
|
||||
{showPinnedSection && (
|
||||
<>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={colCount}
|
||||
className="px-3 py-1.5 text-[11px] uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{pinnedLabel}
|
||||
</td>
|
||||
</tr>
|
||||
{pinnedRows.map((item) => (
|
||||
<DataRow key={item.id} item={item} />
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={colCount} className="border-b border-border/50" />
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{rows.map((item) => (
|
||||
<DataRow key={item.id} item={item} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
frontend/src/components/shared/index.ts
Normal file
8
frontend/src/components/shared/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { EntityTable } from './EntityTable';
|
||||
export type { ColumnDef } from './EntityTable';
|
||||
export { EntityDetailPanel } from './EntityDetailPanel';
|
||||
export type { PanelField } from './EntityDetailPanel';
|
||||
export { default as CategoryFilterBar } from './CategoryFilterBar';
|
||||
export { default as CopyableField } from './CopyableField';
|
||||
export { default as CategoryAutocomplete } from './CategoryAutocomplete';
|
||||
export * from './utils';
|
||||
56
frontend/src/components/shared/utils.ts
Normal file
56
frontend/src/components/shared/utils.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { formatDistanceToNow, parseISO, addYears, differenceInDays } from 'date-fns';
|
||||
|
||||
// Deterministic avatar color from name hash
|
||||
export const avatarColors = [
|
||||
'bg-rose-500/20 text-rose-400',
|
||||
'bg-blue-500/20 text-blue-400',
|
||||
'bg-purple-500/20 text-purple-400',
|
||||
'bg-pink-500/20 text-pink-400',
|
||||
'bg-teal-500/20 text-teal-400',
|
||||
'bg-orange-500/20 text-orange-400',
|
||||
'bg-green-500/20 text-green-400',
|
||||
'bg-amber-500/20 text-amber-400',
|
||||
];
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function getAvatarColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return avatarColors[Math.abs(hash) % avatarColors.length];
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(updatedAt: string): string {
|
||||
try {
|
||||
return `Updated ${formatDistanceToNow(parseISO(updatedAt), { addSuffix: true })}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextBirthday(birthday: string): Date {
|
||||
const today = new Date();
|
||||
const parsed = parseISO(birthday);
|
||||
const thisYear = new Date(today.getFullYear(), parsed.getMonth(), parsed.getDate());
|
||||
if (thisYear < today) {
|
||||
return addYears(thisYear, 1);
|
||||
}
|
||||
return thisYear;
|
||||
}
|
||||
|
||||
export function getDaysUntilBirthday(birthday: string): number {
|
||||
const next = getNextBirthday(birthday);
|
||||
return differenceInDays(next, new Date());
|
||||
}
|
||||
|
||||
export function splitName(name: string): { firstName: string; lastName: string } {
|
||||
const idx = name.indexOf(' ');
|
||||
if (idx === -1) return { firstName: name, lastName: '' };
|
||||
return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) };
|
||||
}
|
||||
64
frontend/src/hooks/useTableVisibility.ts
Normal file
64
frontend/src/hooks/useTableVisibility.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export type VisibilityMode = 'all' | 'filtered' | 'essential';
|
||||
|
||||
/**
|
||||
* Observes container width via ResizeObserver and returns a visibility mode
|
||||
* for table columns. Adjusts thresholds when a side panel is open.
|
||||
*/
|
||||
export function useTableVisibility(
|
||||
containerRef: React.RefObject<HTMLElement>,
|
||||
panelOpen: boolean
|
||||
): VisibilityMode {
|
||||
const [mode, setMode] = useState<VisibilityMode>('all');
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const calculate = (width: number): VisibilityMode => {
|
||||
if (panelOpen) {
|
||||
return width >= 600 ? 'filtered' : 'essential';
|
||||
}
|
||||
if (width >= 900) return 'all';
|
||||
if (width >= 600) return 'filtered';
|
||||
return 'essential';
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setMode(calculate(entry.contentRect.width));
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
// Set initial value
|
||||
setMode(calculate(el.getBoundingClientRect().width));
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [containerRef, panelOpen]);
|
||||
|
||||
// Recalculate when panelOpen changes
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const width = el.getBoundingClientRect().width;
|
||||
if (panelOpen) {
|
||||
setMode(width >= 600 ? 'filtered' : 'essential');
|
||||
} else {
|
||||
if (width >= 900) setMode('all');
|
||||
else if (width >= 600) setMode('filtered');
|
||||
else setMode('essential');
|
||||
}
|
||||
}, [panelOpen, containerRef]);
|
||||
|
||||
return mode;
|
||||
}
|
||||
@ -142,11 +142,19 @@ export interface ProjectTask {
|
||||
export interface Person {
|
||||
id: number;
|
||||
name: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
address?: string;
|
||||
birthday?: string;
|
||||
category?: string;
|
||||
relationship?: string;
|
||||
is_favourite: boolean;
|
||||
company?: string;
|
||||
job_title?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@ -157,6 +165,9 @@ export interface Location {
|
||||
name: string;
|
||||
address: string;
|
||||
category: string;
|
||||
contact_number?: string;
|
||||
email?: string;
|
||||
is_frequent: boolean;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user