diff --git a/backend/alembic/versions/019_extend_person_model.py b/backend/alembic/versions/019_extend_person_model.py new file mode 100644 index 0000000..7464216 --- /dev/null +++ b/backend/alembic/versions/019_extend_person_model.py @@ -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') diff --git a/backend/alembic/versions/020_extend_location_model.py b/backend/alembic/versions/020_extend_location_model.py new file mode 100644 index 0000000..4fe921d --- /dev/null +++ b/backend/alembic/versions/020_extend_location_model.py @@ -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') diff --git a/backend/alembic/versions/021_drop_person_relationship_column.py b/backend/alembic/versions/021_drop_person_relationship_column.py new file mode 100644 index 0000000..4c222ee --- /dev/null +++ b/backend/alembic/versions/021_drop_person_relationship_column.py @@ -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)) diff --git a/backend/app/models/location.py b/backend/app/models/location.py index f9a116a..7dbea4a 100644 --- a/backend/app/models/location.py +++ b/backend/app/models/location.py @@ -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 datetime import datetime from typing import Optional, List @@ -12,6 +12,9 @@ class Location(Base): name: Mapped[str] = mapped_column(String(255), nullable=False) address: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str] = mapped_column(String(100), default="other") + is_frequent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, 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) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index 05118db..fbf7984 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -1,5 +1,5 @@ -from sqlalchemy import String, Text, Date, func -from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship +from sqlalchemy import String, Text, Date, Boolean, func, text +from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List from app.database import Base @@ -14,10 +14,18 @@ class Person(Base): phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) address: Mapped[Optional[str]] = mapped_column(Text, 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) + # 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()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) # Relationships - assigned_tasks: Mapped[List["ProjectTask"]] = sa_relationship(back_populates="person") + assigned_tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="person") diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index 644769b..1329f18 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ +from datetime import datetime, timezone from typing import Optional, List import asyncio import json @@ -151,6 +152,9 @@ async def update_location( for key, value in update_data.items(): 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.refresh(location) diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 890dd55..451aca0 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query 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 app.database import get_db @@ -12,17 +13,46 @@ from app.models.settings import Settings router = APIRouter() +def _compute_display_name( + first_name: Optional[str], + last_name: Optional[str], + nickname: Optional[str], + name: Optional[str], +) -> str: + """Denormalise a display name. Nickname wins; else 'First Last'; else legacy name; 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]) async def get_people( search: Optional[str] = Query(None), + category: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Get all people with optional search.""" + """Get all people with optional search and category filter.""" query = select(Person) if search: - query = query.where(Person.name.ilike(f"%{search}%")) + 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()) @@ -38,8 +68,20 @@ async def create_person( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Create a new person.""" - new_person = Person(**person.model_dump()) + """Create a new person with denormalised display name.""" + 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) await db.commit() await db.refresh(new_person) @@ -70,7 +112,7 @@ async def update_person( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Update a person.""" + """Update a person and refresh the denormalised display name.""" result = await db.execute(select(Person).where(Person.id == person_id)) person = result.scalar_one_or_none() @@ -82,6 +124,16 @@ async def update_person( for key, value in update_data.items(): setattr(person, key, value) + # Recompute display name after applying updates + person.name = _compute_display_name( + person.first_name, + person.last_name, + person.nickname, + person.name, + ) + # Guarantee timestamp refresh regardless of DB driver behaviour + person.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + await db.commit() await db.refresh(person) diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py index 35c0a89..e436d57 100644 --- a/backend/app/schemas/location.py +++ b/backend/app/schemas/location.py @@ -1,7 +1,10 @@ -from pydantic import BaseModel, ConfigDict +import re +from pydantic import BaseModel, ConfigDict, field_validator from datetime import datetime from typing import Optional, Literal +_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + class LocationSearchResult(BaseModel): source: Literal["local", "nominatim"] @@ -15,6 +18,16 @@ class LocationCreate(BaseModel): address: str category: str = "other" notes: Optional[str] = None + is_frequent: bool = False + contact_number: Optional[str] = None + email: Optional[str] = None + + @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): @@ -22,6 +35,16 @@ class LocationUpdate(BaseModel): address: Optional[str] = None category: Optional[str] = None notes: Optional[str] = None + is_frequent: Optional[bool] = None + contact_number: Optional[str] = None + email: Optional[str] = None + + @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): @@ -30,6 +53,9 @@ class LocationResponse(BaseModel): address: str category: str notes: Optional[str] + is_frequent: bool + contact_number: Optional[str] + email: Optional[str] created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index 379c833..ebe37c7 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -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 typing import Optional +_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + 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 phone: Optional[str] = None + mobile: Optional[str] = None address: Optional[str] = None birthday: Optional[date] = None - relationship: Optional[str] = None + category: Optional[str] = None + is_favourite: bool = False + company: Optional[str] = None + job_title: Optional[str] = None notes: Optional[str] = None + @model_validator(mode='after') + def require_some_name(self) -> 'PersonCreate': + if not any([ + self.name 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): - 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 phone: Optional[str] = None + mobile: Optional[str] = None address: Optional[str] = None birthday: Optional[date] = None - relationship: Optional[str] = None + category: Optional[str] = None + is_favourite: Optional[bool] = None + company: Optional[str] = None + job_title: Optional[str] = None notes: Optional[str] = None + @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): id: int name: str + first_name: Optional[str] + last_name: Optional[str] + nickname: Optional[str] email: Optional[str] phone: Optional[str] + mobile: Optional[str] address: Optional[str] birthday: Optional[date] - relationship: Optional[str] + category: Optional[str] + is_favourite: bool + company: Optional[str] + job_title: Optional[str] notes: Optional[str] created_at: datetime updated_at: datetime diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 342227e..83aa677 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "umbra", "version": "1.0.0", "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", @@ -330,6 +334,73 @@ "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -3055,6 +3126,12 @@ "dev": true, "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": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9be121..4e34964 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,24 +9,25 @@ "preview": "vite preview" }, "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-dom": "^18.3.1", "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", - "clsx": "^2.1.1", - "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" + "tailwind-merge": "^2.6.0" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/frontend/src/components/locations/LocationCard.tsx b/frontend/src/components/locations/LocationCard.tsx deleted file mode 100644 index 0599843..0000000 --- a/frontend/src/components/locations/LocationCard.tsx +++ /dev/null @@ -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 = { - 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 ( - - -
-
- - - {location.name} - - - {location.category} - -
-
- - -
-
-
- -

{location.address}

- {location.notes && ( -

{location.notes}

- )} -
-
- ); -} diff --git a/frontend/src/components/locations/LocationForm.tsx b/frontend/src/components/locations/LocationForm.tsx index 3ee17d6..79dad1e 100644 --- a/frontend/src/components/locations/LocationForm.tsx +++ b/frontend/src/components/locations/LocationForm.tsx @@ -1,6 +1,7 @@ import { useState, FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { Star, StarOff, X } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import type { Location } from '@/types'; import { @@ -9,26 +10,30 @@ import { SheetHeader, SheetTitle, SheetFooter, - SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; -import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import LocationPicker from '@/components/ui/location-picker'; +import { CategoryAutocomplete } from '@/components/shared'; interface LocationFormProps { location: Location | null; + categories: string[]; onClose: () => void; } -export default function LocationForm({ location, onClose }: LocationFormProps) { +export default function LocationForm({ location, categories, onClose }: LocationFormProps) { const queryClient = useQueryClient(); + const [formData, setFormData] = useState({ name: location?.name || '', address: location?.address || '', category: location?.category || 'other', + contact_number: location?.contact_number || '', + email: location?.email || '', + is_frequent: location?.is_frequent ?? false, notes: location?.notes || '', }); @@ -44,11 +49,18 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['locations'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success(location ? 'Location updated' : 'Location created'); onClose(); }, onError: (error) => { - toast.error(getErrorMessage(error, location ? 'Failed to update location' : 'Failed to create location')); + toast.error( + getErrorMessage( + error, + location ? 'Failed to update location' : 'Failed to create location' + ) + ); }, }); @@ -60,26 +72,59 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { return ( - - {location ? 'Edit Location' : 'New Location'} +
+ {location ? 'Edit Location' : 'New Location'} +
+ + +
+
+
+ {/* Location Name */}
- + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. Home, Office, Coffee Shop" required />
+ {/* Address */}
- + setFormData({ ...formData, address: val })} onSelect={(result) => { @@ -93,27 +138,52 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { />
-
- - + {/* Contact Number + Email */} +
+
+ + + setFormData({ ...formData, contact_number: e.target.value }) + } + placeholder="+61..." + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="info@..." + /> +
+ {/* Category */}
- + + setFormData({ ...formData, category: val })} + categories={categories} + placeholder="e.g. work, restaurant, gym..." + /> +
+ + {/* Notes */} +
+