Compare commits
8 Commits
4d5667a78a
...
5feb67bf13
| Author | SHA1 | Date | |
|---|---|---|---|
| 5feb67bf13 | |||
| 1806e15487 | |||
| 1231c4b36d | |||
| 1b78dadf75 | |||
| 8cbc95939a | |||
| cb9f74a387 | |||
| f4b1239904 | |||
| 765f692304 |
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')
|
||||||
@ -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))
|
||||||
@ -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())
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
<SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Contact Number + Email */}
|
||||||
<Label htmlFor="category">Category</Label>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Select
|
<div className="space-y-2">
|
||||||
id="category"
|
<Label htmlFor="loc-contact">Contact Number</Label>
|
||||||
value={formData.category}
|
<Input
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })}
|
id="loc-contact"
|
||||||
>
|
type="tel"
|
||||||
<option value="home">Home</option>
|
value={formData.contact_number}
|
||||||
<option value="work">Work</option>
|
onChange={(e) =>
|
||||||
<option value="restaurant">Restaurant</option>
|
setFormData({ ...formData, contact_number: e.target.value })
|
||||||
<option value="shop">Shop</option>
|
}
|
||||||
<option value="other">Other</option>
|
placeholder="+61..."
|
||||||
</Select>
|
/>
|
||||||
|
</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>
|
</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>
|
||||||
|
|||||||
@ -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,69 +41,360 @@ 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)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Location
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex-1 min-w-0">
|
||||||
<Label htmlFor="category-filter">Filter by category:</Label>
|
<CategoryFilterBar
|
||||||
<Select
|
categories={orderedCategories}
|
||||||
id="category-filter"
|
activeFilters={activeFilters}
|
||||||
value={categoryFilter}
|
pinnedLabel="Frequent"
|
||||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
showPinned={showPinned}
|
||||||
>
|
onToggleAll={() => setActiveFilters([])}
|
||||||
<option value="">All</option>
|
onTogglePinned={() => setShowPinned((v) => !v)}
|
||||||
<option value="home">Home</option>
|
onToggleCategory={handleToggleCategory}
|
||||||
<option value="work">Work</option>
|
onSelectAllCategories={selectAllCategories}
|
||||||
<option value="restaurant">Restaurant</option>
|
onReorderCategories={reorderCategories}
|
||||||
<option value="shop">Shop</option>
|
searchValue={search}
|
||||||
<option value="other">Other</option>
|
onSearchChange={setSearch}
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{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>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredLocations.map((location) => (
|
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location">
|
||||||
<LocationCard key={location.id} location={location} onEdit={handleEdit} />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
))}
|
Add Location
|
||||||
</div>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && <LocationForm location={editingLocation} onClose={handleCloseForm} />}
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden flex">
|
||||||
|
{/* Table */}
|
||||||
|
<div
|
||||||
|
ref={tableRef}
|
||||||
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 pb-6 pt-5">
|
||||||
|
{isLoading ? (
|
||||||
|
<EntityTable<Location>
|
||||||
|
columns={columns}
|
||||||
|
groups={[]}
|
||||||
|
pinnedRows={[]}
|
||||||
|
pinnedLabel="Frequent"
|
||||||
|
showPinned={false}
|
||||||
|
selectedId={null}
|
||||||
|
onRowClick={() => {}}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
visibilityMode={visibilityMode}
|
||||||
|
loading={true}
|
||||||
|
/>
|
||||||
|
) : locations.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={MapPin}
|
||||||
|
title="No locations yet"
|
||||||
|
description="Add locations to organise your favourite places, workspaces, and more."
|
||||||
|
actionLabel="Add Location"
|
||||||
|
onAction={() => setShowForm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EntityTable<Location>
|
||||||
|
columns={columns}
|
||||||
|
groups={groups}
|
||||||
|
pinnedRows={frequentLocations}
|
||||||
|
pinnedLabel="Frequent"
|
||||||
|
showPinned={showPinned}
|
||||||
|
selectedId={selectedLocationId}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
visibilityMode={visibilityMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail panel (desktop) */}
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{renderPanel()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile detail panel overlay */}
|
||||||
|
{panelOpen && selectedLocation && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
|
onClick={() => setSelectedLocationId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderPanel()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<LocationForm
|
||||||
|
location={editingLocation}
|
||||||
|
categories={allCategories}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,60 +205,355 @@ 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
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
activeFilters={activeFilters}
|
||||||
Add Person
|
pinnedLabel="Favourites"
|
||||||
</Button>
|
showPinned={showPinned}
|
||||||
</div>
|
categories={orderedCategories}
|
||||||
|
onToggleAll={toggleAll}
|
||||||
<Input
|
onTogglePinned={togglePinned}
|
||||||
placeholder="Search people..."
|
onToggleCategory={toggleCategory}
|
||||||
value={search}
|
onSelectAllCategories={selectAllCategories}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onReorderCategories={reorderCategories}
|
||||||
className="max-w-md"
|
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" />
|
||||||
|
Add Person
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
{isLoading ? (
|
{/* Stat bar */}
|
||||||
<GridSkeleton cards={6} />
|
{!isLoading && people.length > 0 && (
|
||||||
) : filteredPeople.length === 0 ? (
|
<div className="px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
|
||||||
<EmptyState
|
<div className="flex gap-6 shrink-0">
|
||||||
icon={Users}
|
<StatCounter
|
||||||
title="No contacts yet"
|
icon={Users}
|
||||||
description="Add people to your directory to keep track of contacts and relationships."
|
iconBg="bg-blue-500/10"
|
||||||
actionLabel="Add Person"
|
iconColor="text-blue-400"
|
||||||
onAction={() => setShowForm(true)}
|
label="Total"
|
||||||
/>
|
value={totalCount}
|
||||||
) : (
|
/>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<StatCounter
|
||||||
{filteredPeople.map((person) => (
|
icon={Star}
|
||||||
<PersonCard key={person.id} person={person} onEdit={handleEdit} />
|
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>
|
||||||
|
{/* 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 ? (
|
||||||
|
<EntityTable<Person>
|
||||||
|
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
|
||||||
|
icon={Users}
|
||||||
|
title="No contacts yet"
|
||||||
|
description="Add people to your directory to keep track of contacts and relationships."
|
||||||
|
actionLabel="Add Person"
|
||||||
|
onAction={() => setShowForm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EntityTable<Person>
|
||||||
|
columns={columns}
|
||||||
|
groups={groups}
|
||||||
|
pinnedRows={showPinned ? favourites : []}
|
||||||
|
pinnedLabel="Favourites"
|
||||||
|
showPinned={showPinned}
|
||||||
|
selectedId={selectedPersonId}
|
||||||
|
onRowClick={(id) =>
|
||||||
|
setSelectedPersonId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
visibilityMode={visibilityMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail panel (desktop) */}
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{renderPanel()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && <PersonForm person={editingPerson} onClose={handleCloseForm} />}
|
{/* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,154 +1,290 @@
|
|||||||
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
|
||||||
<div className="space-y-2">
|
type="button"
|
||||||
<Label htmlFor="name">Name</Label>
|
variant="ghost"
|
||||||
<Input
|
size="icon"
|
||||||
id="name"
|
className={`h-7 w-7 ${formData.is_favourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
|
||||||
value={formData.name}
|
onClick={() => set('is_favourite', !formData.is_favourite)}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
aria-label={formData.is_favourite ? 'Remove from favourites' : 'Add to favourites'}
|
||||||
required
|
>
|
||||||
/>
|
{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>
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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">
|
||||||
|
<Label htmlFor="first_name">First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="first_name"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={(e) => set('first_name', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="last_name">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="last_name"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={(e) => set('last_name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Nickname */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="nickname">Nickname</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="nickname"
|
||||||
type="email"
|
value={formData.nickname}
|
||||||
value={formData.email}
|
onChange={(e) => set('nickname', e.target.value)}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
placeholder="Optional display name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Birthday + Age */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="birthday">Birthday</Label>
|
||||||
|
<Input
|
||||||
|
id="birthday"
|
||||||
|
type="date"
|
||||||
|
value={formData.birthday}
|
||||||
|
onChange={(e) => set('birthday', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="age">Age</Label>
|
||||||
|
<Input
|
||||||
|
id="age"
|
||||||
|
value={age !== null ? String(age) : ''}
|
||||||
|
disabled
|
||||||
|
placeholder="—"
|
||||||
|
aria-label="Calculated age"
|
||||||
|
/>
|
||||||
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">Phone</Label>
|
<Label htmlFor="phone">Phone</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
onChange={(e) => set('phone', e.target.value)}
|
||||||
|
placeholder="Landline / work number"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Row 8: Address */}
|
||||||
<Label htmlFor="address">Address</Label>
|
|
||||||
<Input
|
|
||||||
id="address"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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="address">Address</Label>
|
||||||
<Input
|
<LocationPicker
|
||||||
id="birthday"
|
id="address"
|
||||||
type="date"
|
value={formData.address}
|
||||||
value={formData.birthday}
|
onChange={(val) => set('address', val)}
|
||||||
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })}
|
onSelect={(result) => set('address', result.address || result.name)}
|
||||||
|
placeholder="Search or enter address..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="relationship">Relationship</Label>
|
<Label htmlFor="notes">Notes</Label>
|
||||||
<Input
|
<Textarea
|
||||||
id="relationship"
|
id="notes"
|
||||||
value={formData.relationship}
|
value={formData.notes}
|
||||||
onChange={(e) => setFormData({ ...formData, relationship: e.target.value })}
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
placeholder="e.g., Friend, Family, Colleague"
|
rows={3}
|
||||||
|
placeholder="Any additional context..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SheetFooter>
|
||||||
<Label htmlFor="notes">Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
id="notes"
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,20 +631,22 @@ 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'
|
||||||
<TaskDetailPanel
|
}`}
|
||||||
task={selectedTask}
|
>
|
||||||
projectId={parseInt(id!)}
|
<div className="flex-1 overflow-hidden min-w-[360px]">
|
||||||
onDelete={handleDeleteTask}
|
<TaskDetailPanel
|
||||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
task={selectedTask}
|
||||||
onClose={() => setSelectedTaskId(null)}
|
projectId={parseInt(id!)}
|
||||||
onSelectTask={setSelectedTaskId}
|
onDelete={handleDeleteTask}
|
||||||
/>
|
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||||
</div>
|
onClose={() => setSelectedTaskId(null)}
|
||||||
|
onSelectTask={setSelectedTaskId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
142
frontend/src/components/shared/CategoryAutocomplete.tsx
Normal file
142
frontend/src/components/shared/CategoryAutocomplete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
285
frontend/src/components/shared/CategoryFilterBar.tsx
Normal file
285
frontend/src/components/shared/CategoryFilterBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/components/shared/CopyableField.tsx
Normal file
36
frontend/src/components/shared/CopyableField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/components/shared/EntityDetailPanel.tsx
Normal file
148
frontend/src/components/shared/EntityDetailPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
frontend/src/components/shared/EntityTable.tsx
Normal file
205
frontend/src/components/shared/EntityTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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, 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';
|
||||||
57
frontend/src/components/shared/utils.ts
Normal file
57
frontend/src/components/shared/utils.ts
Normal 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) };
|
||||||
|
}
|
||||||
41
frontend/src/hooks/useCategoryOrder.ts
Normal file
41
frontend/src/hooks/useCategoryOrder.ts
Normal 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 };
|
||||||
|
}
|
||||||
56
frontend/src/hooks/useTableVisibility.ts
Normal file
56
frontend/src/hooks/useTableVisibility.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user