Initial commit
This commit is contained in:
commit
1f6519635f
8
.env.example
Normal file
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Database
|
||||||
|
POSTGRES_USER=lifemanager
|
||||||
|
POSTGRES_PASSWORD=changeme_in_production
|
||||||
|
POSTGRES_DB=lifemanager
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
DATABASE_URL=postgresql+asyncpg://lifemanager:changeme_in_production@db:5432/lifemanager
|
||||||
|
SECRET_KEY=change-this-to-a-random-secret-key-in-production
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yaml
|
||||||
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# CLAUDE.md - LifeManager
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
- **Naive datetimes only.** The DB uses `TIMESTAMP WITHOUT TIME ZONE`. Never send timezone-aware strings (no `Z` suffix, no `.toISOString()`). Use local datetime formatting helpers instead.
|
||||||
|
- **Eager load relationships in async SQLAlchemy.** Any `response_model` that includes a relationship (e.g. `ProjectResponse.tasks`) must use `selectinload()` when querying. Lazy loading raises `MissingGreenlet` in async context.
|
||||||
|
- **Never shadow SQLAlchemy names.** Model columns must not be named `relationship`, `column`, `metadata`, or other SQLAlchemy reserved names. The `person.py` model aliases the import as `sa_relationship` for this reason.
|
||||||
|
- **Frontend date inputs require exact formats.** `<input type="date">` needs `YYYY-MM-DD`, `<input type="datetime-local">` needs `YYYY-MM-DDThh:mm`. Backend may return `2026-02-15T00:00:00` which must be sliced/converted before binding.
|
||||||
|
- **All API routes are prefixed with `/api`.** Frontend axios base URL is `/api`. Nginx proxies `/api/` to backend:8000. Never duplicate the prefix.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Python 3.12** (slim Docker image - no curl, use `urllib` for healthchecks)
|
||||||
|
- **FastAPI** with async lifespan, Pydantic v2 (`model_dump()`, `ConfigDict(from_attributes=True)`)
|
||||||
|
- **SQLAlchemy 2.0** async with `Mapped[]` type hints, `mapped_column()`, `async_sessionmaker`
|
||||||
|
- **PostgreSQL 16** (Alpine) via `asyncpg`
|
||||||
|
- **Alembic** for migrations
|
||||||
|
- **Auth:** PIN-based with bcrypt + itsdangerous signed cookies (not JWT)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **React 18** + TypeScript + Vite 6
|
||||||
|
- **TanStack Query v5** for server state (`useQuery`, `useMutation`, `invalidateQueries`)
|
||||||
|
- **FullCalendar 6** (dayGrid, timeGrid, interaction plugins)
|
||||||
|
- **Tailwind CSS 3** with custom dark theme + accent color CSS variables
|
||||||
|
- **Sonner** for toast notifications
|
||||||
|
- **Lucide React** for icons
|
||||||
|
- Custom shadcn/ui-style components in `frontend/src/components/ui/`
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- **Docker Compose** - 3 services: `db`, `backend`, `frontend`
|
||||||
|
- **Nginx** (Alpine) serves frontend SPA, proxies `/api/` to backend
|
||||||
|
- Frontend served on port **80**, backend on port **8000**
|
||||||
|
|
||||||
|
## Authority Links
|
||||||
|
|
||||||
|
- [progress.md](progress.md) - Project tracker, milestone status, fix history, outstanding items
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
main.py # FastAPI app, router registration, health endpoint
|
||||||
|
config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY)
|
||||||
|
database.py # Async engine, session factory, get_db dependency
|
||||||
|
models/ # SQLAlchemy 2.0 models (Mapped[] style)
|
||||||
|
schemas/ # Pydantic v2 request/response schemas
|
||||||
|
routers/ # One router per feature (auth, todos, events, etc.)
|
||||||
|
|
||||||
|
frontend/src/
|
||||||
|
App.tsx # Routes + ProtectedRoute wrapper
|
||||||
|
lib/api.ts # Axios instance + getErrorMessage helper
|
||||||
|
hooks/ # useAuth, useSettings, useTheme
|
||||||
|
types/index.ts # TypeScript interfaces matching backend schemas
|
||||||
|
components/
|
||||||
|
ui/ # 12 base components (Button, Dialog, Input, etc.)
|
||||||
|
layout/ # AppLayout + Sidebar
|
||||||
|
auth/ # LockScreen (PIN setup/login)
|
||||||
|
dashboard/ # DashboardPage + widgets
|
||||||
|
calendar/ # CalendarPage + EventForm
|
||||||
|
todos/ # TodosPage + TodoList + TodoItem + TodoForm
|
||||||
|
reminders/ # RemindersPage + ReminderList + ReminderForm
|
||||||
|
projects/ # ProjectsPage + ProjectCard + ProjectDetail + forms
|
||||||
|
people/ # PeoplePage + PersonCard + PersonForm
|
||||||
|
locations/ # LocationsPage + LocationCard + LocationForm
|
||||||
|
settings/ # SettingsPage (accent color, PIN change)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run all services
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# Rebuild single service after changes
|
||||||
|
docker-compose up --build backend
|
||||||
|
docker-compose up --build frontend
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
docker-compose logs -f backend
|
||||||
|
|
||||||
|
# Reset database (destroys all data)
|
||||||
|
docker-compose down -v && docker-compose up --build
|
||||||
|
|
||||||
|
# Stop everything
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
All routes require auth (signed cookie) except `/api/auth/*` and `/health`.
|
||||||
|
|
||||||
|
| Prefix | Resource |
|
||||||
|
|--------------------|-----------------|
|
||||||
|
| `/api/auth` | PIN setup/login/logout/status |
|
||||||
|
| `/api/todos` | Todos CRUD + toggle |
|
||||||
|
| `/api/events` | Calendar events CRUD |
|
||||||
|
| `/api/reminders` | Reminders CRUD + dismiss |
|
||||||
|
| `/api/projects` | Projects + nested tasks CRUD |
|
||||||
|
| `/api/people` | People CRUD |
|
||||||
|
| `/api/locations` | Locations CRUD |
|
||||||
|
| `/api/settings` | Settings + PIN change |
|
||||||
|
| `/api/dashboard` | Dashboard aggregation |
|
||||||
|
| `/api/upcoming` | Unified upcoming items |
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- **Do not** add timezone info to datetime strings sent to the backend
|
||||||
|
- **Do not** use `datetime.utcnow()` - use `datetime.now(timezone.utc)` instead (deprecated in 3.12)
|
||||||
|
- **Do not** return relationships from async endpoints without `selectinload()`
|
||||||
|
- **Do not** use `curl` in backend Docker healthchecks (not available in python:slim)
|
||||||
|
- **Do not** use `git push --force` or destructive git operations without explicit approval
|
||||||
194
README.md
Normal file
194
README.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# LifeManager
|
||||||
|
|
||||||
|
A self-hosted personal life administration app with a dark-themed UI. Manage your todos, calendar events, projects, reminders, contacts, and locations from a single dashboard.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Dashboard** - At-a-glance overview with today's events, upcoming todos, active reminders, and project stats
|
||||||
|
- **Todos** - Task management with priorities, due dates, and completion tracking
|
||||||
|
- **Calendar** - Full interactive calendar (month/week/day views) with drag-and-drop event rescheduling
|
||||||
|
- **Projects** - Project boards with nested task lists, status tracking, and progress indicators
|
||||||
|
- **Reminders** - Time-based reminders with dismiss functionality
|
||||||
|
- **People** - Contact directory with relationship tracking and task assignment
|
||||||
|
- **Locations** - Location management with categories
|
||||||
|
- **Settings** - Customizable accent color, upcoming days range, and PIN management
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
*Coming soon*
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|--------------|------------|
|
||||||
|
| Frontend | React 18, TypeScript, Vite, Tailwind CSS |
|
||||||
|
| UI Components | Custom shadcn/ui-style components, FullCalendar, Lucide icons |
|
||||||
|
| State | TanStack Query v5, React Router v6 |
|
||||||
|
| Backend | FastAPI, Python 3.12, Pydantic v2 |
|
||||||
|
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic |
|
||||||
|
| Auth | PIN-based with bcrypt + signed cookies |
|
||||||
|
| Deployment | Docker Compose (3 services), Nginx reverse proxy |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone https://your-gitea-instance/youruser/lifemanager.git
|
||||||
|
cd lifemanager
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment variables**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
Edit `.env` and set secure values:
|
||||||
|
```env
|
||||||
|
POSTGRES_USER=lifemanager
|
||||||
|
POSTGRES_PASSWORD=your-secure-password
|
||||||
|
POSTGRES_DB=lifemanager
|
||||||
|
DATABASE_URL=postgresql+asyncpg://lifemanager:your-secure-password@db:5432/lifemanager
|
||||||
|
SECRET_KEY=your-random-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Build and run**
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Open the app**
|
||||||
|
|
||||||
|
Navigate to `http://localhost` in your browser. On first launch you'll be prompted to create a PIN.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------+
|
||||||
|
| Browser |
|
||||||
|
+-----+-----+
|
||||||
|
|
|
||||||
|
port 80 (HTTP)
|
||||||
|
|
|
||||||
|
+-------+-------+
|
||||||
|
| Nginx |
|
||||||
|
| (frontend) |
|
||||||
|
+---+-------+---+
|
||||||
|
| |
|
||||||
|
static | | /api/*
|
||||||
|
files | |
|
||||||
|
v v
|
||||||
|
+---+-------+---+
|
||||||
|
| FastAPI |
|
||||||
|
| (backend) |
|
||||||
|
| port 8000 |
|
||||||
|
+-------+-------+
|
||||||
|
|
|
||||||
|
+-------+-------+
|
||||||
|
| PostgreSQL |
|
||||||
|
| (db) |
|
||||||
|
| port 5432 |
|
||||||
|
+---------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Frontend** is built as static files and served by Nginx. Nginx also reverse-proxies API requests to the backend.
|
||||||
|
- **Backend** runs Alembic migrations on startup, then serves the FastAPI application.
|
||||||
|
- **Database** uses a named Docker volume (`postgres_data`) for persistence.
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
All endpoints require authentication (signed session cookie) except auth routes and the health check.
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|--------------------|-------------|
|
||||||
|
| `GET /health` | Health check |
|
||||||
|
| `/api/auth/*` | PIN setup, login, logout, status |
|
||||||
|
| `/api/todos/*` | Todos CRUD + toggle completion |
|
||||||
|
| `/api/events/*` | Calendar events CRUD |
|
||||||
|
| `/api/reminders/*` | Reminders CRUD + dismiss |
|
||||||
|
| `/api/projects/*` | Projects + nested tasks CRUD |
|
||||||
|
| `/api/people/*` | People CRUD |
|
||||||
|
| `/api/locations/*` | Locations CRUD |
|
||||||
|
| `/api/settings/*` | App settings + PIN change |
|
||||||
|
| `/api/dashboard` | Dashboard aggregation |
|
||||||
|
| `/api/upcoming` | Unified upcoming items feed |
|
||||||
|
|
||||||
|
Full API documentation is available at `http://localhost:8000/docs` (Swagger UI) when the backend is running.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Rebuild a single service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build backend # Backend only
|
||||||
|
docker-compose up --build frontend # Frontend only
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f # All services
|
||||||
|
docker-compose logs -f backend # Backend only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down -v && docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop all services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lifemanager/
|
||||||
|
├── docker-compose.yaml
|
||||||
|
├── .env / .env.example
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── start.sh
|
||||||
|
│ ├── alembic.ini
|
||||||
|
│ ├── alembic/ # Database migrations
|
||||||
|
│ └── app/
|
||||||
|
│ ├── main.py # FastAPI app entry point
|
||||||
|
│ ├── config.py # Environment settings
|
||||||
|
│ ├── database.py # Async SQLAlchemy setup
|
||||||
|
│ ├── models/ # SQLAlchemy ORM models
|
||||||
|
│ ├── schemas/ # Pydantic request/response schemas
|
||||||
|
│ └── routers/ # API route handlers
|
||||||
|
└── frontend/
|
||||||
|
├── Dockerfile
|
||||||
|
├── nginx.conf
|
||||||
|
├── package.json
|
||||||
|
└── src/
|
||||||
|
├── App.tsx # Routes and auth guard
|
||||||
|
├── lib/api.ts # Axios client
|
||||||
|
├── hooks/ # Auth, settings, theme hooks
|
||||||
|
├── types/ # TypeScript interfaces
|
||||||
|
└── components/
|
||||||
|
├── ui/ # Base UI components
|
||||||
|
├── layout/ # App shell and sidebar
|
||||||
|
├── auth/ # PIN login screen
|
||||||
|
├── dashboard/ # Dashboard and widgets
|
||||||
|
├── calendar/ # Calendar and event form
|
||||||
|
├── todos/ # Todo management
|
||||||
|
├── reminders/ # Reminder management
|
||||||
|
├── projects/ # Project boards and tasks
|
||||||
|
├── people/ # Contact directory
|
||||||
|
├── locations/ # Location management
|
||||||
|
└── settings/ # App settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is for personal use. Feel free to fork and adapt for your own needs.
|
||||||
2
backend/.env.example
Normal file
2
backend/.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production-use-a-long-random-string
|
||||||
47
backend/.gitignore
vendored
Normal file
47
backend/.gitignore
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run migrations and start server
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||||
200
backend/README.md
Normal file
200
backend/README.md
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
# LifeManager Backend
|
||||||
|
|
||||||
|
A complete FastAPI backend for the LifeManager application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **FastAPI** with async/await support
|
||||||
|
- **SQLAlchemy 2.0** with async engine
|
||||||
|
- **PostgreSQL** with asyncpg driver
|
||||||
|
- **Alembic** for database migrations
|
||||||
|
- **bcrypt** for password hashing
|
||||||
|
- **itsdangerous** for session management
|
||||||
|
- **PIN-based authentication** with secure session cookies
|
||||||
|
- **Full CRUD operations** for all entities
|
||||||
|
- **Dashboard** with aggregated data
|
||||||
|
- **CORS enabled** for frontend integration
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── alembic/ # Database migrations
|
||||||
|
│ ├── versions/ # Migration files
|
||||||
|
│ ├── env.py # Alembic environment
|
||||||
|
│ └── script.py.mako # Migration template
|
||||||
|
├── app/
|
||||||
|
│ ├── models/ # SQLAlchemy models
|
||||||
|
│ ├── schemas/ # Pydantic schemas
|
||||||
|
│ ├── routers/ # API route handlers
|
||||||
|
│ ├── config.py # Configuration
|
||||||
|
│ ├── database.py # Database setup
|
||||||
|
│ └── main.py # FastAPI application
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Dockerfile # Docker configuration
|
||||||
|
├── alembic.ini # Alembic configuration
|
||||||
|
└── start.sh # Startup script
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment
|
||||||
|
|
||||||
|
Create a `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
createdb lifemanager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Start Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the start script
|
||||||
|
chmod +x start.sh
|
||||||
|
./start.sh
|
||||||
|
|
||||||
|
# Or directly with uvicorn
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:8000`
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
Interactive API documentation is available at:
|
||||||
|
- **Swagger UI**: http://localhost:8000/docs
|
||||||
|
- **ReDoc**: http://localhost:8000/redoc
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/setup` - Initial PIN setup
|
||||||
|
- `POST /api/auth/login` - Login with PIN
|
||||||
|
- `POST /api/auth/logout` - Logout
|
||||||
|
- `GET /api/auth/status` - Check auth status
|
||||||
|
|
||||||
|
### Todos
|
||||||
|
- `GET /api/todos` - List todos (with filters)
|
||||||
|
- `POST /api/todos` - Create todo
|
||||||
|
- `GET /api/todos/{id}` - Get todo
|
||||||
|
- `PUT /api/todos/{id}` - Update todo
|
||||||
|
- `DELETE /api/todos/{id}` - Delete todo
|
||||||
|
- `PATCH /api/todos/{id}/toggle` - Toggle completion
|
||||||
|
|
||||||
|
### Calendar Events
|
||||||
|
- `GET /api/events` - List events (with date range)
|
||||||
|
- `POST /api/events` - Create event
|
||||||
|
- `GET /api/events/{id}` - Get event
|
||||||
|
- `PUT /api/events/{id}` - Update event
|
||||||
|
- `DELETE /api/events/{id}` - Delete event
|
||||||
|
|
||||||
|
### Reminders
|
||||||
|
- `GET /api/reminders` - List reminders (with filters)
|
||||||
|
- `POST /api/reminders` - Create reminder
|
||||||
|
- `GET /api/reminders/{id}` - Get reminder
|
||||||
|
- `PUT /api/reminders/{id}` - Update reminder
|
||||||
|
- `DELETE /api/reminders/{id}` - Delete reminder
|
||||||
|
- `PATCH /api/reminders/{id}/dismiss` - Dismiss reminder
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
- `GET /api/projects` - List projects
|
||||||
|
- `POST /api/projects` - Create project
|
||||||
|
- `GET /api/projects/{id}` - Get project
|
||||||
|
- `PUT /api/projects/{id}` - Update project
|
||||||
|
- `DELETE /api/projects/{id}` - Delete project
|
||||||
|
- `GET /api/projects/{id}/tasks` - List project tasks
|
||||||
|
- `POST /api/projects/{id}/tasks` - Create project task
|
||||||
|
- `PUT /api/projects/{id}/tasks/{task_id}` - Update task
|
||||||
|
- `DELETE /api/projects/{id}/tasks/{task_id}` - Delete task
|
||||||
|
|
||||||
|
### People
|
||||||
|
- `GET /api/people` - List people (with search)
|
||||||
|
- `POST /api/people` - Create person
|
||||||
|
- `GET /api/people/{id}` - Get person
|
||||||
|
- `PUT /api/people/{id}` - Update person
|
||||||
|
- `DELETE /api/people/{id}` - Delete person
|
||||||
|
|
||||||
|
### Locations
|
||||||
|
- `GET /api/locations` - List locations (with category filter)
|
||||||
|
- `POST /api/locations` - Create location
|
||||||
|
- `GET /api/locations/{id}` - Get location
|
||||||
|
- `PUT /api/locations/{id}` - Update location
|
||||||
|
- `DELETE /api/locations/{id}` - Delete location
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- `GET /api/settings` - Get settings
|
||||||
|
- `PUT /api/settings` - Update settings
|
||||||
|
- `PUT /api/settings/pin` - Change PIN
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- `GET /api/dashboard` - Get dashboard data
|
||||||
|
- `GET /api/upcoming?days=7` - Get upcoming items
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The application uses the following tables:
|
||||||
|
- `settings` - Application settings and PIN
|
||||||
|
- `todos` - Task items
|
||||||
|
- `calendar_events` - Calendar events
|
||||||
|
- `reminders` - Reminders
|
||||||
|
- `projects` - Projects
|
||||||
|
- `project_tasks` - Tasks within projects
|
||||||
|
- `people` - Contacts/people
|
||||||
|
- `locations` - Physical locations
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build and run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t lifemanager-backend .
|
||||||
|
docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... lifemanager-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Create New Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic revision --autogenerate -m "Description of changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic downgrade -1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Change `SECRET_KEY` in production
|
||||||
|
- Use strong PINs (minimum 4 digits recommended)
|
||||||
|
- Session cookies are httpOnly and last 30 days
|
||||||
|
- All API endpoints (except auth) require authentication
|
||||||
|
- PINs are hashed with bcrypt before storage
|
||||||
114
backend/alembic.ini
Normal file
114
backend/alembic.ini
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
99
backend/alembic/env.py
Normal file
99
backend/alembic/env.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import app config and models
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import (
|
||||||
|
Settings,
|
||||||
|
Todo,
|
||||||
|
CalendarEvent,
|
||||||
|
Reminder,
|
||||||
|
Project,
|
||||||
|
ProjectTask,
|
||||||
|
Person,
|
||||||
|
Location,
|
||||||
|
)
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Set database URL from app config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
173
backend/alembic/versions/001_initial_migration.py
Normal file
173
backend/alembic/versions/001_initial_migration.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"""Initial migration - create all tables
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-01-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '001'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create settings table
|
||||||
|
op.create_table(
|
||||||
|
'settings',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('pin_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('accent_color', sa.String(length=20), nullable=False, server_default='cyan'),
|
||||||
|
sa.Column('upcoming_days', sa.Integer(), nullable=False, server_default='7'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_settings_id'), 'settings', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create people table
|
||||||
|
op.create_table(
|
||||||
|
'people',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('phone', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('address', sa.Text(), nullable=True),
|
||||||
|
sa.Column('birthday', sa.Date(), nullable=True),
|
||||||
|
sa.Column('relationship', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_people_id'), 'people', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create locations table
|
||||||
|
op.create_table(
|
||||||
|
'locations',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('address', sa.Text(), nullable=False),
|
||||||
|
sa.Column('category', sa.String(length=100), nullable=False, server_default='other'),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_locations_id'), 'locations', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create projects table
|
||||||
|
op.create_table(
|
||||||
|
'projects',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False, server_default='not_started'),
|
||||||
|
sa.Column('color', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('due_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_projects_id'), 'projects', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create reminders table
|
||||||
|
op.create_table(
|
||||||
|
'reminders',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('remind_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('is_dismissed', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('recurrence_rule', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_reminders_id'), 'reminders', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create calendar_events table
|
||||||
|
op.create_table(
|
||||||
|
'calendar_events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('start_datetime', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('end_datetime', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('all_day', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('color', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('location_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('recurrence_rule', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_calendar_events_id'), 'calendar_events', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create todos table
|
||||||
|
op.create_table(
|
||||||
|
'todos',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('priority', sa.String(length=20), nullable=False, server_default='medium'),
|
||||||
|
sa.Column('due_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('completed', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('category', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('recurrence_rule', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('project_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_todos_id'), 'todos', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create project_tasks table
|
||||||
|
op.create_table(
|
||||||
|
'project_tasks',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
|
||||||
|
sa.Column('priority', sa.String(length=20), nullable=False, server_default='medium'),
|
||||||
|
sa.Column('due_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('person_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.ForeignKeyConstraint(['person_id'], ['people.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_project_tasks_id'), 'project_tasks', ['id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_project_tasks_id'), table_name='project_tasks')
|
||||||
|
op.drop_table('project_tasks')
|
||||||
|
op.drop_index(op.f('ix_todos_id'), table_name='todos')
|
||||||
|
op.drop_table('todos')
|
||||||
|
op.drop_index(op.f('ix_calendar_events_id'), table_name='calendar_events')
|
||||||
|
op.drop_table('calendar_events')
|
||||||
|
op.drop_index(op.f('ix_reminders_id'), table_name='reminders')
|
||||||
|
op.drop_table('reminders')
|
||||||
|
op.drop_index(op.f('ix_projects_id'), table_name='projects')
|
||||||
|
op.drop_table('projects')
|
||||||
|
op.drop_index(op.f('ix_locations_id'), table_name='locations')
|
||||||
|
op.drop_table('locations')
|
||||||
|
op.drop_index(op.f('ix_people_id'), table_name='people')
|
||||||
|
op.drop_table('people')
|
||||||
|
op.drop_index(op.f('ix_settings_id'), table_name='settings')
|
||||||
|
op.drop_table('settings')
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
15
backend/app/config.py
Normal file
15
backend/app/config.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager"
|
||||||
|
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
33
backend/app/database.py
Normal file
33
backend/app/database.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# Create async engine
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=True,
|
||||||
|
future=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create async session factory
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base class for models
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency to get database session
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
54
backend/app/main.py
Normal file
54
backend/app/main.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from app.database import engine, Base
|
||||||
|
from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup: Create tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
# Shutdown: Clean up resources
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="LifeManager API",
|
||||||
|
description="Backend API for LifeManager application",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS configuration for development
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers with /api prefix
|
||||||
|
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
||||||
|
app.include_router(todos.router, prefix="/api/todos", tags=["Todos"])
|
||||||
|
app.include_router(events.router, prefix="/api/events", tags=["Calendar Events"])
|
||||||
|
app.include_router(reminders.router, prefix="/api/reminders", tags=["Reminders"])
|
||||||
|
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"])
|
||||||
|
app.include_router(people.router, prefix="/api/people", tags=["People"])
|
||||||
|
app.include_router(locations.router, prefix="/api/locations", tags=["Locations"])
|
||||||
|
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
|
||||||
|
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "LifeManager API is running"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from app.models.settings import Settings
|
||||||
|
from app.models.todo import Todo
|
||||||
|
from app.models.calendar_event import CalendarEvent
|
||||||
|
from app.models.reminder import Reminder
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.project_task import ProjectTask
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.location import Location
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Settings",
|
||||||
|
"Todo",
|
||||||
|
"CalendarEvent",
|
||||||
|
"Reminder",
|
||||||
|
"Project",
|
||||||
|
"ProjectTask",
|
||||||
|
"Person",
|
||||||
|
"Location",
|
||||||
|
]
|
||||||
24
backend/app/models/calendar_event.py
Normal file
24
backend/app/models/calendar_event.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEvent(Base):
|
||||||
|
__tablename__ = "calendar_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
start_datetime: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
|
end_datetime: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
|
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True)
|
||||||
|
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
location: Mapped[Optional["Location"]] = relationship(back_populates="events")
|
||||||
20
backend/app/models/location.py
Normal file
20
backend/app/models/location.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Location(Base):
|
||||||
|
__tablename__ = "locations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
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")
|
||||||
|
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())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
events: Mapped[List["CalendarEvent"]] = relationship(back_populates="location")
|
||||||
23
backend/app/models/person.py
Normal file
23
backend/app/models/person.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import String, Text, Date, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Person(Base):
|
||||||
|
__tablename__ = "people"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
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)
|
||||||
|
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")
|
||||||
22
backend/app/models/project.py
Normal file
22
backend/app/models/project.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import String, Text, Date, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="not_started")
|
||||||
|
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||||
|
todos: Mapped[List["Todo"]] = relationship(back_populates="project")
|
||||||
25
backend/app/models/project_task.py
Normal file
25
backend/app/models/project_task.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTask(Base):
|
||||||
|
__tablename__ = "project_tasks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||||
|
priority: Mapped[str] = mapped_column(String(20), default="medium")
|
||||||
|
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
|
person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id"), nullable=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
project: Mapped["Project"] = relationship(back_populates="tasks")
|
||||||
|
person: Mapped[Optional["Person"]] = relationship(back_populates="assigned_tasks")
|
||||||
19
backend/app/models/reminder.py
Normal file
19
backend/app/models/reminder.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import String, Text, Boolean, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Reminder(Base):
|
||||||
|
__tablename__ = "reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
remind_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
15
backend/app/models/settings.py
Normal file
15
backend/app/models/settings.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import String, Integer, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(Base):
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
|
||||||
|
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
25
backend/app/models/todo.py
Normal file
25
backend/app/models/todo.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from sqlalchemy import String, Text, Boolean, Date, Integer, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Todo(Base):
|
||||||
|
__tablename__ = "todos"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
priority: Mapped[str] = mapped_column(String(20), default="medium")
|
||||||
|
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
|
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
|
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
project: Mapped[Optional["Project"]] = relationship(back_populates="todos")
|
||||||
13
backend/app/routers/__init__.py
Normal file
13
backend/app/routers/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from app.routers import auth, todos, events, reminders, projects, people, locations, settings, dashboard
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"auth",
|
||||||
|
"todos",
|
||||||
|
"events",
|
||||||
|
"reminders",
|
||||||
|
"projects",
|
||||||
|
"people",
|
||||||
|
"locations",
|
||||||
|
"settings",
|
||||||
|
"dashboard",
|
||||||
|
]
|
||||||
151
backend/app/routers/auth.py
Normal file
151
backend/app/routers/auth.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import Optional
|
||||||
|
import bcrypt
|
||||||
|
from itsdangerous import TimestampSigner, BadSignature
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.schemas.settings import SettingsCreate
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Initialize signer for session management
|
||||||
|
signer = TimestampSigner(app_settings.SECRET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_pin(pin: str) -> str:
|
||||||
|
"""Hash a PIN using bcrypt."""
|
||||||
|
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_pin(pin: str, hashed: str) -> bool:
|
||||||
|
"""Verify a PIN against its hash."""
|
||||||
|
return bcrypt.checkpw(pin.encode(), hashed.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_token(user_id: int) -> str:
|
||||||
|
"""Create a signed session token."""
|
||||||
|
return signer.sign(str(user_id)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_session_token(token: str) -> Optional[int]:
|
||||||
|
"""Verify and extract user ID from session token."""
|
||||||
|
try:
|
||||||
|
unsigned = signer.unsign(token, max_age=86400 * 30) # 30 days
|
||||||
|
return int(unsigned)
|
||||||
|
except (BadSignature, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_session(
|
||||||
|
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> Settings:
|
||||||
|
"""Dependency to verify session and return current settings."""
|
||||||
|
if not session_cookie:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
user_id = verify_session_token(session_cookie)
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired session")
|
||||||
|
|
||||||
|
result = await db.execute(select(Settings).where(Settings.id == user_id))
|
||||||
|
settings_obj = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not settings_obj:
|
||||||
|
raise HTTPException(status_code=401, detail="Session invalid")
|
||||||
|
|
||||||
|
return settings_obj
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/setup")
|
||||||
|
async def setup_pin(
|
||||||
|
data: SettingsCreate,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create initial PIN. Only works if no settings exist."""
|
||||||
|
result = await db.execute(select(Settings))
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Setup already completed")
|
||||||
|
|
||||||
|
pin_hash = hash_pin(data.pin)
|
||||||
|
new_settings = Settings(pin_hash=pin_hash)
|
||||||
|
db.add(new_settings)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_settings)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
token = create_session_token(new_settings.id)
|
||||||
|
response.set_cookie(
|
||||||
|
key="session",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=86400 * 30, # 30 days
|
||||||
|
samesite="lax"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Setup completed successfully", "authenticated": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(
|
||||||
|
data: SettingsCreate,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Verify PIN and create session."""
|
||||||
|
result = await db.execute(select(Settings))
|
||||||
|
settings_obj = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not settings_obj:
|
||||||
|
raise HTTPException(status_code=400, detail="Setup required")
|
||||||
|
|
||||||
|
if not verify_pin(data.pin, settings_obj.pin_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid PIN")
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
token = create_session_token(settings_obj.id)
|
||||||
|
response.set_cookie(
|
||||||
|
key="session",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=86400 * 30, # 30 days
|
||||||
|
samesite="lax"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Login successful", "authenticated": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(response: Response):
|
||||||
|
"""Clear session cookie."""
|
||||||
|
response.delete_cookie(key="session")
|
||||||
|
return {"message": "Logout successful"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def auth_status(
|
||||||
|
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Check authentication status."""
|
||||||
|
result = await db.execute(select(Settings))
|
||||||
|
settings_obj = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
setup_required = settings_obj is None
|
||||||
|
authenticated = False
|
||||||
|
|
||||||
|
if not setup_required and session_cookie:
|
||||||
|
user_id = verify_session_token(session_cookie)
|
||||||
|
authenticated = user_id is not None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": authenticated,
|
||||||
|
"setup_required": setup_required
|
||||||
|
}
|
||||||
192
backend/app/routers/dashboard.py
Normal file
192
backend/app/routers/dashboard.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.models.todo import Todo
|
||||||
|
from app.models.calendar_event import CalendarEvent
|
||||||
|
from app.models.reminder import Reminder
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.location import Location
|
||||||
|
from app.routers.auth import get_current_session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
async def get_dashboard(
|
||||||
|
client_date: Optional[date] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get aggregated dashboard data."""
|
||||||
|
today = client_date or date.today()
|
||||||
|
upcoming_cutoff = today + timedelta(days=current_user.upcoming_days)
|
||||||
|
|
||||||
|
# Today's events
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today, datetime.max.time())
|
||||||
|
events_query = select(CalendarEvent).where(
|
||||||
|
CalendarEvent.start_datetime >= today_start,
|
||||||
|
CalendarEvent.start_datetime <= today_end
|
||||||
|
)
|
||||||
|
events_result = await db.execute(events_query)
|
||||||
|
todays_events = events_result.scalars().all()
|
||||||
|
|
||||||
|
# Upcoming todos (not completed, with due date within upcoming_days)
|
||||||
|
todos_query = select(Todo).where(
|
||||||
|
Todo.completed == False,
|
||||||
|
Todo.due_date.isnot(None),
|
||||||
|
Todo.due_date <= upcoming_cutoff
|
||||||
|
).order_by(Todo.due_date.asc())
|
||||||
|
todos_result = await db.execute(todos_query)
|
||||||
|
upcoming_todos = todos_result.scalars().all()
|
||||||
|
|
||||||
|
# Active reminders (not dismissed, is_active = true)
|
||||||
|
reminders_query = select(Reminder).where(
|
||||||
|
Reminder.is_active == True,
|
||||||
|
Reminder.is_dismissed == False
|
||||||
|
).order_by(Reminder.remind_at.asc())
|
||||||
|
reminders_result = await db.execute(reminders_query)
|
||||||
|
active_reminders = reminders_result.scalars().all()
|
||||||
|
|
||||||
|
# Project stats
|
||||||
|
total_projects_result = await db.execute(select(func.count(Project.id)))
|
||||||
|
total_projects = total_projects_result.scalar()
|
||||||
|
|
||||||
|
projects_by_status_query = select(
|
||||||
|
Project.status,
|
||||||
|
func.count(Project.id).label("count")
|
||||||
|
).group_by(Project.status)
|
||||||
|
projects_by_status_result = await db.execute(projects_by_status_query)
|
||||||
|
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
|
||||||
|
|
||||||
|
# Total people
|
||||||
|
total_people_result = await db.execute(select(func.count(Person.id)))
|
||||||
|
total_people = total_people_result.scalar()
|
||||||
|
|
||||||
|
# Total locations
|
||||||
|
total_locations_result = await db.execute(select(func.count(Location.id)))
|
||||||
|
total_locations = total_locations_result.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"todays_events": [
|
||||||
|
{
|
||||||
|
"id": event.id,
|
||||||
|
"title": event.title,
|
||||||
|
"start_datetime": event.start_datetime,
|
||||||
|
"end_datetime": event.end_datetime,
|
||||||
|
"all_day": event.all_day,
|
||||||
|
"color": event.color
|
||||||
|
}
|
||||||
|
for event in todays_events
|
||||||
|
],
|
||||||
|
"upcoming_todos": [
|
||||||
|
{
|
||||||
|
"id": todo.id,
|
||||||
|
"title": todo.title,
|
||||||
|
"due_date": todo.due_date,
|
||||||
|
"priority": todo.priority,
|
||||||
|
"category": todo.category
|
||||||
|
}
|
||||||
|
for todo in upcoming_todos
|
||||||
|
],
|
||||||
|
"active_reminders": [
|
||||||
|
{
|
||||||
|
"id": reminder.id,
|
||||||
|
"title": reminder.title,
|
||||||
|
"remind_at": reminder.remind_at
|
||||||
|
}
|
||||||
|
for reminder in active_reminders
|
||||||
|
],
|
||||||
|
"project_stats": {
|
||||||
|
"total": total_projects,
|
||||||
|
"by_status": projects_by_status
|
||||||
|
},
|
||||||
|
"total_people": total_people,
|
||||||
|
"total_locations": total_locations
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/upcoming")
|
||||||
|
async def get_upcoming(
|
||||||
|
days: int = Query(default=7, ge=1, le=90),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
|
||||||
|
today = date.today()
|
||||||
|
cutoff_date = today + timedelta(days=days)
|
||||||
|
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
|
||||||
|
|
||||||
|
# Get upcoming todos with due dates
|
||||||
|
todos_query = select(Todo).where(
|
||||||
|
Todo.completed == False,
|
||||||
|
Todo.due_date.isnot(None),
|
||||||
|
Todo.due_date <= cutoff_date
|
||||||
|
)
|
||||||
|
todos_result = await db.execute(todos_query)
|
||||||
|
todos = todos_result.scalars().all()
|
||||||
|
|
||||||
|
# Get upcoming events
|
||||||
|
events_query = select(CalendarEvent).where(
|
||||||
|
CalendarEvent.start_datetime <= cutoff_datetime
|
||||||
|
)
|
||||||
|
events_result = await db.execute(events_query)
|
||||||
|
events = events_result.scalars().all()
|
||||||
|
|
||||||
|
# Get upcoming reminders
|
||||||
|
reminders_query = select(Reminder).where(
|
||||||
|
Reminder.is_active == True,
|
||||||
|
Reminder.is_dismissed == False,
|
||||||
|
Reminder.remind_at <= cutoff_datetime
|
||||||
|
)
|
||||||
|
reminders_result = await db.execute(reminders_query)
|
||||||
|
reminders = reminders_result.scalars().all()
|
||||||
|
|
||||||
|
# Combine into unified list
|
||||||
|
upcoming_items: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for todo in todos:
|
||||||
|
upcoming_items.append({
|
||||||
|
"type": "todo",
|
||||||
|
"id": todo.id,
|
||||||
|
"title": todo.title,
|
||||||
|
"date": todo.due_date.isoformat() if todo.due_date else None,
|
||||||
|
"datetime": None,
|
||||||
|
"priority": todo.priority,
|
||||||
|
"category": todo.category
|
||||||
|
})
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
upcoming_items.append({
|
||||||
|
"type": "event",
|
||||||
|
"id": event.id,
|
||||||
|
"title": event.title,
|
||||||
|
"date": event.start_datetime.date().isoformat(),
|
||||||
|
"datetime": event.start_datetime.isoformat(),
|
||||||
|
"all_day": event.all_day,
|
||||||
|
"color": event.color
|
||||||
|
})
|
||||||
|
|
||||||
|
for reminder in reminders:
|
||||||
|
upcoming_items.append({
|
||||||
|
"type": "reminder",
|
||||||
|
"id": reminder.id,
|
||||||
|
"title": reminder.title,
|
||||||
|
"date": reminder.remind_at.date().isoformat(),
|
||||||
|
"datetime": reminder.remind_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by date/datetime
|
||||||
|
upcoming_items.sort(key=lambda x: x["datetime"] if x["datetime"] else x["date"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": upcoming_items,
|
||||||
|
"days": days,
|
||||||
|
"cutoff_date": cutoff_date.isoformat()
|
||||||
|
}
|
||||||
122
backend/app/routers/events.py
Normal file
122
backend/app/routers/events.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.calendar_event import CalendarEvent
|
||||||
|
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
|
||||||
|
from app.routers.auth import get_current_session
|
||||||
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[CalendarEventResponse])
|
||||||
|
async def get_events(
|
||||||
|
start: Optional[date] = Query(None),
|
||||||
|
end: Optional[date] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get all calendar events with optional date range filtering."""
|
||||||
|
query = select(CalendarEvent)
|
||||||
|
|
||||||
|
if start:
|
||||||
|
query = query.where(CalendarEvent.start_datetime >= start)
|
||||||
|
|
||||||
|
if end:
|
||||||
|
query = query.where(CalendarEvent.end_datetime <= end)
|
||||||
|
|
||||||
|
query = query.order_by(CalendarEvent.start_datetime.asc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
events = result.scalars().all()
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=CalendarEventResponse, status_code=201)
|
||||||
|
async def create_event(
|
||||||
|
event: CalendarEventCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Create a new calendar event."""
|
||||||
|
if event.end_datetime < event.start_datetime:
|
||||||
|
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
|
||||||
|
|
||||||
|
new_event = CalendarEvent(**event.model_dump())
|
||||||
|
db.add(new_event)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_event)
|
||||||
|
|
||||||
|
return new_event
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_id}", response_model=CalendarEventResponse)
|
||||||
|
async def get_event(
|
||||||
|
event_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get a specific calendar event by ID."""
|
||||||
|
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{event_id}", response_model=CalendarEventResponse)
|
||||||
|
async def update_event(
|
||||||
|
event_id: int,
|
||||||
|
event_update: CalendarEventUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update a calendar event."""
|
||||||
|
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||||
|
|
||||||
|
update_data = event_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Validate datetime order if both are being updated
|
||||||
|
start = update_data.get("start_datetime", event.start_datetime)
|
||||||
|
end = update_data.get("end_datetime", event.end_datetime)
|
||||||
|
|
||||||
|
if end < start:
|
||||||
|
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(event, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{event_id}", status_code=204)
|
||||||
|
async def delete_event(
|
||||||
|
event_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a calendar event."""
|
||||||
|
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||||
|
|
||||||
|
await db.delete(event)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
107
backend/app/routers/locations.py
Normal file
107
backend/app/routers/locations.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.location import Location
|
||||||
|
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse
|
||||||
|
from app.routers.auth import get_current_session
|
||||||
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[LocationResponse])
|
||||||
|
async def get_locations(
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get all locations with optional category filter."""
|
||||||
|
query = select(Location)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.where(Location.category == category)
|
||||||
|
|
||||||
|
query = query.order_by(Location.name.asc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
locations = result.scalars().all()
|
||||||
|
|
||||||
|
return locations
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=LocationResponse, status_code=201)
|
||||||
|
async def create_location(
|
||||||
|
location: LocationCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Create a new location."""
|
||||||
|
new_location = Location(**location.model_dump())
|
||||||
|
db.add(new_location)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_location)
|
||||||
|
|
||||||
|
return new_location
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{location_id}", response_model=LocationResponse)
|
||||||
|
async def get_location(
|
||||||
|
location_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get a specific location by ID."""
|
||||||
|
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||||
|
location = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{location_id}", response_model=LocationResponse)
|
||||||
|
async def update_location(
|
||||||
|
location_id: int,
|
||||||
|
location_update: LocationUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update a location."""
|
||||||
|
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||||
|
location = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
update_data = location_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(location, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(location)
|
||||||
|
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{location_id}", status_code=204)
|
||||||
|
async def delete_location(
|
||||||
|
location_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a location."""
|
||||||
|
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||||
|
location = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
await db.delete(location)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
107
backend/app/routers/people.py
Normal file
107
backend/app/routers/people.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
||||||
|
from app.routers.auth import get_current_session
|
||||||
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[PersonResponse])
|
||||||
|
async def get_people(
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get all people with optional search."""
|
||||||
|
query = select(Person)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.where(Person.name.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
query = query.order_by(Person.name.asc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
people = result.scalars().all()
|
||||||
|
|
||||||
|
return people
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=PersonResponse, status_code=201)
|
||||||
|
async def create_person(
|
||||||
|
person: PersonCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Create a new person."""
|
||||||
|
new_person = Person(**person.model_dump())
|
||||||
|
db.add(new_person)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_person)
|
||||||
|
|
||||||
|
return new_person
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{person_id}", response_model=PersonResponse)
|
||||||
|
async def get_person(
|
||||||
|
person_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get a specific person by ID."""
|
||||||
|
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||||
|
person = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(status_code=404, detail="Person not found")
|
||||||
|
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{person_id}", response_model=PersonResponse)
|
||||||
|
async def update_person(
|
||||||
|
person_id: int,
|
||||||
|
person_update: PersonUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update a person."""
|
||||||
|
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||||
|
person = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(status_code=404, detail="Person not found")
|
||||||
|
|
||||||
|
update_data = person_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(person, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(person)
|
||||||
|
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{person_id}", status_code=204)
|
||||||
|
async def delete_person(
|
||||||
|
person_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a person."""
|
||||||
|
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||||
|
person = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(status_code=404, detail="Person not found")
|
||||||
|
|
||||||
|
await db.delete(person)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
208
backend/app/routers/projects.py
Normal file
208
backend/app/routers/projects.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.project_task import ProjectTask
|
||||||
|
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||||
|
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
||||||
|
from app.routers.auth import get_current_session
|
||||||
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ProjectResponse])
|
||||||
|
async def get_projects(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get all projects with their tasks."""
|
||||||
|
query = select(Project).options(selectinload(Project.tasks)).order_by(Project.created_at.desc())
|
||||||
|
result = await db.execute(query)
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ProjectResponse, status_code=201)
|
||||||
|
async def create_project(
|
||||||
|
project: ProjectCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Create a new project."""
|
||||||
|
new_project = Project(**project.model_dump())
|
||||||
|
db.add(new_project)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Re-fetch with eagerly loaded tasks for response serialization
|
||||||
|
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == new_project.id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||||
|
async def get_project(
|
||||||
|
project_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get a specific project by ID with its tasks."""
|
||||||
|
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||||
|
async def update_project(
|
||||||
|
project_id: int,
|
||||||
|
project_update: ProjectUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update a project."""
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
update_data = project_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(project, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Re-fetch with eagerly loaded tasks for response serialization
|
||||||
|
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}", status_code=204)
|
||||||
|
async def delete_project(
|
||||||
|
project_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a project and all its tasks."""
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
await db.delete(project)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
|
||||||
|
async def get_project_tasks(
|
||||||
|
project_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get all tasks for a specific project."""
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
query = select(ProjectTask).where(ProjectTask.project_id == project_id).order_by(ProjectTask.sort_order.asc())
|
||||||
|
result = await db.execute(query)
|
||||||
|
tasks = result.scalars().all()
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
|
||||||
|
async def create_project_task(
|
||||||
|
project_id: int,
|
||||||
|
task: ProjectTaskCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Create a new task for a project."""
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
task_data = task.model_dump()
|
||||||
|
task_data["project_id"] = project_id
|
||||||
|
new_task = ProjectTask(**task_data)
|
||||||
|
db.add(new_task)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_task)
|
||||||
|
|
||||||
|
return new_task
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
|
||||||
|
async def update_project_task(
|
||||||
|
project_id: int,
|
||||||
|
task_id: int,
|
||||||
|
task_update: ProjectTaskUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update a project task."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProjectTask).where(
|
||||||
|
ProjectTask.id == task_id,
|
||||||
|
ProjectTask.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
update_data = task_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(task, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
|
||||||
|
async def delete_project_task(
|
||||||
|
project_id: int,
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a project task."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProjectTask).where(
|
||||||
|
ProjectTask.id == task_id,
|
||||||
|
ProjectTask.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
await db.delete(task)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
132
backend/app/routers/reminders.py
Normal file
132
backend/app/routers/reminders.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.reminder import Reminder
|
||||||
|
from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse
|
||||||
|
from app.routers.auth import get_current_session
|
||||||
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ReminderResponse])
|
||||||
|
async def get_reminders(
|
||||||
|
active: Optional[bool] = Query(None),
|
||||||
|
dismissed: Optional[bool] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get all reminders with optional filters."""
|
||||||
|
query = select(Reminder)
|
||||||
|
|
||||||
|
if active is not None:
|
||||||
|
query = query.where(Reminder.is_active == active)
|
||||||
|
|
||||||
|
if dismissed is not None:
|
||||||
|
query = query.where(Reminder.is_dismissed == dismissed)
|
||||||
|
|
||||||
|
query = query.order_by(Reminder.remind_at.asc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
reminders = result.scalars().all()
|
||||||
|
|
||||||
|
return reminders
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ReminderResponse, status_code=201)
|
||||||
|
async def create_reminder(
|
||||||
|
reminder: ReminderCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Create a new reminder."""
|
||||||
|
new_reminder = Reminder(**reminder.model_dump())
|
||||||
|
db.add(new_reminder)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_reminder)
|
||||||
|
|
||||||
|
return new_reminder
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{reminder_id}", response_model=ReminderResponse)
|
||||||
|
async def get_reminder(
|
||||||
|
reminder_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get a specific reminder by ID."""
|
||||||
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{reminder_id}", response_model=ReminderResponse)
|
||||||
|
async def update_reminder(
|
||||||
|
reminder_id: int,
|
||||||
|
reminder_update: ReminderUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update a reminder."""
|
||||||
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
update_data = reminder_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(reminder, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reminder)
|
||||||
|
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{reminder_id}", status_code=204)
|
||||||
|
async def delete_reminder(
|
||||||
|
reminder_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a reminder."""
|
||||||
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
await db.delete(reminder)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse)
|
||||||
|
async def dismiss_reminder(
|
||||||
|
reminder_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Dismiss a reminder."""
|
||||||
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
reminder.is_dismissed = True
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reminder)
|
||||||
|
|
||||||
|
return reminder
|
||||||
54
backend/app/routers/settings.py
Normal file
54
backend/app/routers/settings.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.schemas.settings import SettingsUpdate, SettingsResponse, ChangePinRequest
|
||||||
|
from app.routers.auth import get_current_session, hash_pin, verify_pin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=SettingsResponse)
|
||||||
|
async def get_settings(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get current settings (excluding PIN hash)."""
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/", response_model=SettingsResponse)
|
||||||
|
async def update_settings(
|
||||||
|
settings_update: SettingsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update settings (accent color, upcoming days)."""
|
||||||
|
update_data = settings_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(current_user, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/pin")
|
||||||
|
async def change_pin(
|
||||||
|
pin_change: ChangePinRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Change PIN. Requires old PIN verification."""
|
||||||
|
if not verify_pin(pin_change.old_pin, current_user.pin_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid old PIN")
|
||||||
|
|
||||||
|
current_user.pin_hash = hash_pin(pin_change.new_pin)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "PIN changed successfully"}
|
||||||
149
backend/app/routers/todos.py
Normal file
149
backend/app/routers/todos.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.todo import Todo
|
||||||
|
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
|
||||||
|
from app.routers.auth import get_current_session
|
||||||
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[TodoResponse])
|
||||||
|
async def get_todos(
|
||||||
|
completed: Optional[bool] = Query(None),
|
||||||
|
priority: Optional[str] = Query(None),
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get all todos with optional filters."""
|
||||||
|
query = select(Todo)
|
||||||
|
|
||||||
|
if completed is not None:
|
||||||
|
query = query.where(Todo.completed == completed)
|
||||||
|
|
||||||
|
if priority:
|
||||||
|
query = query.where(Todo.priority == priority)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.where(Todo.category == category)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.where(Todo.title.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
query = query.order_by(Todo.created_at.desc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
todos = result.scalars().all()
|
||||||
|
|
||||||
|
return todos
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=TodoResponse, status_code=201)
|
||||||
|
async def create_todo(
|
||||||
|
todo: TodoCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Create a new todo."""
|
||||||
|
new_todo = Todo(**todo.model_dump())
|
||||||
|
db.add(new_todo)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_todo)
|
||||||
|
|
||||||
|
return new_todo
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{todo_id}", response_model=TodoResponse)
|
||||||
|
async def get_todo(
|
||||||
|
todo_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Get a specific todo by ID."""
|
||||||
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
|
todo = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
|
||||||
|
return todo
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{todo_id}", response_model=TodoResponse)
|
||||||
|
async def update_todo(
|
||||||
|
todo_id: int,
|
||||||
|
todo_update: TodoUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Update a todo."""
|
||||||
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
|
todo = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
|
||||||
|
update_data = todo_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle completion timestamp
|
||||||
|
if "completed" in update_data:
|
||||||
|
if update_data["completed"] and not todo.completed:
|
||||||
|
update_data["completed_at"] = datetime.now(timezone.utc)
|
||||||
|
elif not update_data["completed"]:
|
||||||
|
update_data["completed_at"] = None
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(todo, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(todo)
|
||||||
|
|
||||||
|
return todo
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{todo_id}", status_code=204)
|
||||||
|
async def delete_todo(
|
||||||
|
todo_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a todo."""
|
||||||
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
|
todo = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
|
||||||
|
await db.delete(todo)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
|
||||||
|
async def toggle_todo(
|
||||||
|
todo_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Toggle todo completion status."""
|
||||||
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
|
todo = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
|
||||||
|
todo.completed = not todo.completed
|
||||||
|
todo.completed_at = datetime.now(timezone.utc) if todo.completed else None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(todo)
|
||||||
|
|
||||||
|
return todo
|
||||||
36
backend/app/schemas/__init__.py
Normal file
36
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from app.schemas.settings import SettingsCreate, SettingsUpdate, SettingsResponse, ChangePinRequest
|
||||||
|
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
|
||||||
|
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
|
||||||
|
from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse
|
||||||
|
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||||
|
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
||||||
|
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
||||||
|
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SettingsCreate",
|
||||||
|
"SettingsUpdate",
|
||||||
|
"SettingsResponse",
|
||||||
|
"ChangePinRequest",
|
||||||
|
"TodoCreate",
|
||||||
|
"TodoUpdate",
|
||||||
|
"TodoResponse",
|
||||||
|
"CalendarEventCreate",
|
||||||
|
"CalendarEventUpdate",
|
||||||
|
"CalendarEventResponse",
|
||||||
|
"ReminderCreate",
|
||||||
|
"ReminderUpdate",
|
||||||
|
"ReminderResponse",
|
||||||
|
"ProjectCreate",
|
||||||
|
"ProjectUpdate",
|
||||||
|
"ProjectResponse",
|
||||||
|
"ProjectTaskCreate",
|
||||||
|
"ProjectTaskUpdate",
|
||||||
|
"ProjectTaskResponse",
|
||||||
|
"PersonCreate",
|
||||||
|
"PersonUpdate",
|
||||||
|
"PersonResponse",
|
||||||
|
"LocationCreate",
|
||||||
|
"LocationUpdate",
|
||||||
|
"LocationResponse",
|
||||||
|
]
|
||||||
41
backend/app/schemas/calendar_event.py
Normal file
41
backend/app/schemas/calendar_event.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
start_datetime: datetime
|
||||||
|
end_datetime: datetime
|
||||||
|
all_day: bool = False
|
||||||
|
color: Optional[str] = None
|
||||||
|
location_id: Optional[int] = None
|
||||||
|
recurrence_rule: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
start_datetime: Optional[datetime] = None
|
||||||
|
end_datetime: Optional[datetime] = None
|
||||||
|
all_day: Optional[bool] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
location_id: Optional[int] = None
|
||||||
|
recurrence_rule: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
start_datetime: datetime
|
||||||
|
end_datetime: datetime
|
||||||
|
all_day: bool
|
||||||
|
color: Optional[str]
|
||||||
|
location_id: Optional[int]
|
||||||
|
recurrence_rule: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
29
backend/app/schemas/location.py
Normal file
29
backend/app/schemas/location.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class LocationCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
category: str = "other"
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
category: str
|
||||||
|
notes: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
38
backend/app/schemas/person.py
Normal file
38
backend/app/schemas/person.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PersonCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
birthday: Optional[date] = None
|
||||||
|
relationship: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
birthday: Optional[date] = None
|
||||||
|
relationship: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
email: Optional[str]
|
||||||
|
phone: Optional[str]
|
||||||
|
address: Optional[str]
|
||||||
|
birthday: Optional[date]
|
||||||
|
relationship: Optional[str]
|
||||||
|
notes: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
34
backend/app/schemas/project.py
Normal file
34
backend/app/schemas/project.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.schemas.project_task import ProjectTaskResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: str = "not_started"
|
||||||
|
color: Optional[str] = None
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
status: str
|
||||||
|
color: Optional[str]
|
||||||
|
due_date: Optional[date]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
tasks: List[ProjectTaskResponse] = []
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
39
backend/app/schemas/project_task.py
Normal file
39
backend/app/schemas/project_task.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTaskCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: str = "pending"
|
||||||
|
priority: str = "medium"
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
person_id: Optional[int] = None
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTaskUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
person_id: Optional[int] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTaskResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
project_id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
status: str
|
||||||
|
priority: str
|
||||||
|
due_date: Optional[date]
|
||||||
|
person_id: Optional[int]
|
||||||
|
sort_order: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
34
backend/app/schemas/reminder.py
Normal file
34
backend/app/schemas/reminder.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
remind_at: datetime
|
||||||
|
is_active: bool = True
|
||||||
|
recurrence_rule: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
remind_at: Optional[datetime] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
is_dismissed: Optional[bool] = None
|
||||||
|
recurrence_rule: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
remind_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
is_dismissed: bool
|
||||||
|
recurrence_rule: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
26
backend/app/schemas/settings.py
Normal file
26
backend/app/schemas/settings.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsCreate(BaseModel):
|
||||||
|
pin: str
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
accent_color: str | None = None
|
||||||
|
upcoming_days: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
accent_color: str
|
||||||
|
upcoming_days: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePinRequest(BaseModel):
|
||||||
|
old_pin: str
|
||||||
|
new_pin: str
|
||||||
41
backend/app/schemas/todo.py
Normal file
41
backend/app/schemas/todo.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class TodoCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
priority: str = "medium"
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
recurrence_rule: Optional[str] = None
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TodoUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
completed: Optional[bool] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
recurrence_rule: Optional[str] = None
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TodoResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
priority: str
|
||||||
|
due_date: Optional[date]
|
||||||
|
completed: bool
|
||||||
|
completed_at: Optional[datetime]
|
||||||
|
category: Optional[str]
|
||||||
|
recurrence_rule: Optional[str]
|
||||||
|
project_id: Optional[int]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
alembic==1.14.1
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
bcrypt==4.2.1
|
||||||
|
python-multipart==0.0.20
|
||||||
|
python-dateutil==2.9.0
|
||||||
|
itsdangerous==2.2.0
|
||||||
9
backend/start.sh
Normal file
9
backend/start.sh
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
echo "Running database migrations..."
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Start the FastAPI application
|
||||||
|
echo "Starting FastAPI application..."
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
39
docker-compose.yaml
Normal file
39
docker-compose.yaml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
32
frontend/.gitignore
vendored
Normal file
32
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files from build stage
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
117
frontend/README.md
Normal file
117
frontend/README.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# LifeManager Frontend
|
||||||
|
|
||||||
|
A modern, dark-themed React application for managing your life - todos, calendar events, reminders, projects, people, and locations.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **React 18** with TypeScript
|
||||||
|
- **Vite** for fast builds
|
||||||
|
- **Tailwind CSS v3** for styling
|
||||||
|
- **shadcn/ui** components (manually implemented)
|
||||||
|
- **React Router v6** for routing
|
||||||
|
- **TanStack Query v5** for data fetching
|
||||||
|
- **Axios** for HTTP requests
|
||||||
|
- **FullCalendar** for calendar view
|
||||||
|
- **Lucide React** for icons
|
||||||
|
- **date-fns** for date formatting
|
||||||
|
- **sonner** for toast notifications
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- PIN-based authentication with setup wizard
|
||||||
|
- Dark theme with customizable accent colors (cyan, blue, purple, orange, green)
|
||||||
|
- Dashboard with stats and widgets
|
||||||
|
- Todos with priority, category, and recurrence
|
||||||
|
- Calendar with event management
|
||||||
|
- Reminders with dismiss functionality
|
||||||
|
- Projects with tasks and progress tracking
|
||||||
|
- People management with relationships
|
||||||
|
- Locations categorization
|
||||||
|
- Responsive design with collapsible sidebar
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20 or higher
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The application expects a backend API at `/api`. During development, Vite proxies `/api` requests to `http://localhost:8000`.
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── auth/ # Authentication components
|
||||||
|
│ │ ├── calendar/ # Calendar page and forms
|
||||||
|
│ │ ├── dashboard/ # Dashboard widgets
|
||||||
|
│ │ ├── layout/ # Layout components (sidebar, etc)
|
||||||
|
│ │ ├── locations/ # Locations management
|
||||||
|
│ │ ├── people/ # People management
|
||||||
|
│ │ ├── projects/ # Projects and tasks
|
||||||
|
│ │ ├── reminders/ # Reminders management
|
||||||
|
│ │ ├── settings/ # Settings page
|
||||||
|
│ │ ├── todos/ # Todos management
|
||||||
|
│ │ └── ui/ # shadcn/ui components
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── lib/ # Utilities (API client, utils)
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ ├── App.tsx # Main app with routing
|
||||||
|
│ ├── main.tsx # Entry point
|
||||||
|
│ └── index.css # Global styles
|
||||||
|
├── Dockerfile # Production Docker image
|
||||||
|
├── nginx.conf # Nginx configuration
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
Build and run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t lifemanager-frontend .
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
docker run -p 80:80 lifemanager-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
No environment variables required. API base URL is configured to `/api` and proxied by nginx in production.
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Accent Colors
|
||||||
|
|
||||||
|
Accent colors are defined in `src/hooks/useTheme.ts` and can be changed in Settings. The application uses CSS custom properties for theming.
|
||||||
|
|
||||||
|
### Adding New Pages
|
||||||
|
|
||||||
|
1. Create component in `src/components/<feature>/`
|
||||||
|
2. Add route in `src/App.tsx`
|
||||||
|
3. Add navigation item in `src/components/layout/Sidebar.tsx`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private project - all rights reserved.
|
||||||
16
frontend/components.json
Normal file
16
frontend/components.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>LifeManager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
frontend/nginx.conf
Normal file
41
frontend/nginx.conf
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback - serve index.html for all routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
||||||
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "lifemanager",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
60
frontend/src/App.tsx
Normal file
60
frontend/src/App.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import LockScreen from '@/components/auth/LockScreen';
|
||||||
|
import AppLayout from '@/components/layout/AppLayout';
|
||||||
|
import DashboardPage from '@/components/dashboard/DashboardPage';
|
||||||
|
import TodosPage from '@/components/todos/TodosPage';
|
||||||
|
import CalendarPage from '@/components/calendar/CalendarPage';
|
||||||
|
import RemindersPage from '@/components/reminders/RemindersPage';
|
||||||
|
import ProjectsPage from '@/components/projects/ProjectsPage';
|
||||||
|
import ProjectDetail from '@/components/projects/ProjectDetail';
|
||||||
|
import PeoplePage from '@/components/people/PeoplePage';
|
||||||
|
import LocationsPage from '@/components/locations/LocationsPage';
|
||||||
|
import SettingsPage from '@/components/settings/SettingsPage';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { authStatus, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authStatus?.authenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LockScreen />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="todos" element={<TodosPage />} />
|
||||||
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
|
<Route path="reminders" element={<RemindersPage />} />
|
||||||
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
|
<Route path="people" element={<PeoplePage />} />
|
||||||
|
<Route path="locations" element={<LocationsPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
110
frontend/src/components/auth/LockScreen.tsx
Normal file
110
frontend/src/components/auth/LockScreen.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export default function LockScreen() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { authStatus, login, setup, isLoginPending, isSetupPending } = useAuth();
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [confirmPin, setConfirmPin] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (authStatus?.setup_required) {
|
||||||
|
if (pin !== confirmPin) {
|
||||||
|
toast.error('PINs do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pin.length < 4) {
|
||||||
|
toast.error('PIN must be at least 4 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setup(pin);
|
||||||
|
toast.success('PIN created successfully');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to create PIN');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await login(pin);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Invalid PIN');
|
||||||
|
setPin('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSetup = authStatus?.setup_required;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-4 text-center">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-accent/10">
|
||||||
|
<Lock className="h-8 w-8 text-accent" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">
|
||||||
|
{isSetup ? 'Welcome to LifeManager' : 'Enter PIN'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isSetup
|
||||||
|
? 'Create a PIN to secure your account'
|
||||||
|
: 'Enter your PIN to access your dashboard'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="pin">{isSetup ? 'Create PIN' : 'PIN'}</Label>
|
||||||
|
<Input
|
||||||
|
id="pin"
|
||||||
|
type="password"
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) => setPin(e.target.value)}
|
||||||
|
placeholder="Enter PIN"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="text-center text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isSetup && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-pin">Confirm PIN</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-pin"
|
||||||
|
type="password"
|
||||||
|
value={confirmPin}
|
||||||
|
onChange={(e) => setConfirmPin(e.target.value)}
|
||||||
|
placeholder="Confirm PIN"
|
||||||
|
required
|
||||||
|
className="text-center text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoginPending || isSetupPending}
|
||||||
|
>
|
||||||
|
{isLoginPending || isSetupPending
|
||||||
|
? 'Please wait...'
|
||||||
|
: isSetup
|
||||||
|
? 'Create PIN'
|
||||||
|
: 'Unlock'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/components/calendar/CalendarPage.tsx
Normal file
126
frontend/src/components/calendar/CalendarPage.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import FullCalendar from '@fullcalendar/react';
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
import type { EventClickArg, DateSelectArg, EventDropArg } from '@fullcalendar/core';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { CalendarEvent } from '@/types';
|
||||||
|
import EventForm from './EventForm';
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: events = [] } = useQuery({
|
||||||
|
queryKey: ['calendar-events'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<CalendarEvent[]>('/events');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventDropMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, start, end, allDay }: { id: number; start: string; end: string; allDay: boolean }) => {
|
||||||
|
const response = await api.put(`/events/${id}`, {
|
||||||
|
start_datetime: start,
|
||||||
|
end_datetime: end,
|
||||||
|
all_day: allDay,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
toast.success('Event moved');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to move event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarEvents = events.map((event) => ({
|
||||||
|
id: event.id.toString(),
|
||||||
|
title: event.title,
|
||||||
|
start: event.start_datetime,
|
||||||
|
end: event.end_datetime || undefined,
|
||||||
|
allDay: event.all_day,
|
||||||
|
backgroundColor: event.color || 'hsl(var(--accent-color))',
|
||||||
|
borderColor: event.color || 'hsl(var(--accent-color))',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleEventClick = (info: EventClickArg) => {
|
||||||
|
const event = events.find((e) => e.id.toString() === info.event.id);
|
||||||
|
if (event) {
|
||||||
|
setEditingEvent(event);
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toLocalDatetime = (d: Date): string => {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEventDrop = (info: EventDropArg) => {
|
||||||
|
const id = parseInt(info.event.id);
|
||||||
|
const start = info.event.allDay
|
||||||
|
? info.event.startStr
|
||||||
|
: info.event.start ? toLocalDatetime(info.event.start) : info.event.startStr;
|
||||||
|
const end = info.event.allDay
|
||||||
|
? info.event.endStr || info.event.startStr
|
||||||
|
: info.event.end ? toLocalDatetime(info.event.end) : start;
|
||||||
|
eventDropMutation.mutate({ id, start, end, allDay: info.event.allDay });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
||||||
|
setSelectedDate(selectInfo.startStr);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
setSelectedDate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<h1 className="text-3xl font-bold">Calendar</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="bg-card rounded-lg border p-4">
|
||||||
|
<FullCalendar
|
||||||
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
|
initialView="dayGridMonth"
|
||||||
|
headerToolbar={{
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||||
|
}}
|
||||||
|
events={calendarEvents}
|
||||||
|
editable={true}
|
||||||
|
selectable={true}
|
||||||
|
selectMirror={true}
|
||||||
|
dayMaxEvents={true}
|
||||||
|
weekends={true}
|
||||||
|
eventClick={handleEventClick}
|
||||||
|
eventDrop={handleEventDrop}
|
||||||
|
select={handleDateSelect}
|
||||||
|
height="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<EventForm event={editingEvent} initialDate={selectedDate} onClose={handleCloseForm} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
frontend/src/components/calendar/EventForm.tsx
Normal file
260
frontend/src/components/calendar/EventForm.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { CalendarEvent, Location } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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 { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
|
interface EventFormProps {
|
||||||
|
event: CalendarEvent | null;
|
||||||
|
initialDate?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorPresets = [
|
||||||
|
{ name: 'Accent', value: '' },
|
||||||
|
{ name: 'Red', value: '#ef4444' },
|
||||||
|
{ name: 'Orange', value: '#f97316' },
|
||||||
|
{ name: 'Yellow', value: '#eab308' },
|
||||||
|
{ name: 'Green', value: '#22c55e' },
|
||||||
|
{ name: 'Blue', value: '#3b82f6' },
|
||||||
|
{ name: 'Purple', value: '#8b5cf6' },
|
||||||
|
{ name: 'Pink', value: '#ec4899' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Extract just the date portion (YYYY-MM-DD) from any date/datetime string
|
||||||
|
function toDateOnly(dt: string): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
return dt.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a datetime string is in datetime-local format (YYYY-MM-DDThh:mm)
|
||||||
|
function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
if (dt.includes('T')) return dt.slice(0, 16); // trim seconds/timezone
|
||||||
|
return `${dt}T${fallbackTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a date/datetime string for the correct input type
|
||||||
|
function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventForm({ event, initialDate, onClose }: EventFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isAllDay = event?.all_day ?? false;
|
||||||
|
const rawStart = event?.start_datetime || initialDate || '';
|
||||||
|
const rawEnd = event?.end_datetime || initialDate || '';
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: event?.title || '',
|
||||||
|
description: event?.description || '',
|
||||||
|
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
|
||||||
|
end_datetime: formatForInput(rawEnd, isAllDay, '10:00'),
|
||||||
|
all_day: isAllDay,
|
||||||
|
location_id: event?.location_id?.toString() || '',
|
||||||
|
color: event?.color || '',
|
||||||
|
recurrence_rule: event?.recurrence_rule || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: locations = [] } = useQuery({
|
||||||
|
queryKey: ['locations'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Location[]>('/locations');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
location_id: data.location_id ? parseInt(data.location_id) : null,
|
||||||
|
};
|
||||||
|
if (event) {
|
||||||
|
const response = await api.put(`/events/${event.id}`, payload);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await api.post('/events', payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
toast.success(event ? 'Event updated' : 'Event created');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, event ? 'Failed to update event' : 'Failed to create event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.delete(`/events/${event?.id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
toast.success('Event deleted');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{event ? 'Edit Event' : 'New Event'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="all_day"
|
||||||
|
checked={formData.all_day}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
all_day: checked,
|
||||||
|
start_datetime: formatForInput(formData.start_datetime, checked, '09:00'),
|
||||||
|
end_datetime: formatForInput(formData.end_datetime, checked, '10:00'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="all_day">All day event</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="start">Start</Label>
|
||||||
|
<Input
|
||||||
|
id="start"
|
||||||
|
type={formData.all_day ? 'date' : 'datetime-local'}
|
||||||
|
value={formData.start_datetime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, start_datetime: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="end">End</Label>
|
||||||
|
<Input
|
||||||
|
id="end"
|
||||||
|
type={formData.all_day ? 'date' : 'datetime-local'}
|
||||||
|
value={formData.end_datetime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, end_datetime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="location">Location</Label>
|
||||||
|
<Select
|
||||||
|
id="location"
|
||||||
|
value={formData.location_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, location_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<option key={loc.id} value={loc.id}>
|
||||||
|
{loc.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="color">Color</Label>
|
||||||
|
<Select
|
||||||
|
id="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||||
|
>
|
||||||
|
{colorPresets.map((preset) => (
|
||||||
|
<option key={preset.name} value={preset.value}>
|
||||||
|
{preset.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recurrence">Recurrence</Label>
|
||||||
|
<Select
|
||||||
|
id="recurrence"
|
||||||
|
value={formData.recurrence_rule}
|
||||||
|
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{event && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="mr-auto"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : event ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
frontend/src/components/dashboard/CalendarWidget.tsx
Normal file
63
frontend/src/components/dashboard/CalendarWidget.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Calendar, Clock } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface DashboardEvent {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
start_datetime: string;
|
||||||
|
end_datetime: string;
|
||||||
|
all_day: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarWidgetProps {
|
||||||
|
events: DashboardEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Today's Events
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No events scheduled for today
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-1 h-full min-h-[2rem] rounded-full"
|
||||||
|
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">{event.title}</p>
|
||||||
|
{!event.all_day && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{format(new Date(event.start_datetime), 'h:mm a')}
|
||||||
|
{event.end_datetime && ` - ${format(new Date(event.end_datetime), 'h:mm a')}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.all_day && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">All day</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/components/dashboard/DashboardPage.tsx
Normal file
113
frontend/src/components/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { DashboardData, UpcomingResponse } from '@/types';
|
||||||
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
|
import StatsWidget from './StatsWidget';
|
||||||
|
import TodoWidget from './TodoWidget';
|
||||||
|
import CalendarWidget from './CalendarWidget';
|
||||||
|
import UpcomingWidget from './UpcomingWidget';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { DashboardSkeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: upcomingData } = useQuery({
|
||||||
|
queryKey: ['upcoming', settings?.upcoming_days],
|
||||||
|
queryFn: async () => {
|
||||||
|
const days = settings?.upcoming_days || 7;
|
||||||
|
const { data } = await api.get<UpcomingResponse>(`/upcoming?days=${days}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Welcome back. Here's your overview.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<DashboardSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Failed to load dashboard</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Welcome back. Here's your overview.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StatsWidget
|
||||||
|
projectStats={data.project_stats}
|
||||||
|
totalPeople={data.total_people}
|
||||||
|
totalLocations={data.total_locations}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{upcomingData && upcomingData.items.length > 0 && (
|
||||||
|
<UpcomingWidget items={upcomingData.items} days={upcomingData.days} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<TodoWidget todos={data.upcoming_todos} />
|
||||||
|
<CalendarWidget events={data.todays_events} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.active_reminders.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
Active Reminders
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.active_reminders.map((reminder) => (
|
||||||
|
<div
|
||||||
|
key={reminder.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4 text-accent" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{reminder.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/components/dashboard/ProjectsWidget.tsx
Normal file
72
frontend/src/components/dashboard/ProjectsWidget.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { Project } from '@/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface ProjectsWidgetProps {
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
|
||||||
|
in_progress: 'bg-accent/10 text-accent border-accent/20',
|
||||||
|
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectsWidget({ projects }: ProjectsWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Projects</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No active projects. Create one to get started!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const completedTasks = project.tasks?.filter((t) => t.status === 'completed').length || 0;
|
||||||
|
const totalTasks = project.tasks?.length || 0;
|
||||||
|
const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors cursor-pointer"
|
||||||
|
onClick={() => navigate(`/projects/${project.id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<p className="font-medium">{project.name}</p>
|
||||||
|
<Badge className={statusColors[project.status]}>
|
||||||
|
{project.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{totalTasks > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>
|
||||||
|
{completedTasks}/{totalTasks} tasks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/dashboard/StatsWidget.tsx
Normal file
58
frontend/src/components/dashboard/StatsWidget.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { FolderKanban, Users, MapPin } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface StatsWidgetProps {
|
||||||
|
projectStats: {
|
||||||
|
total: number;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
};
|
||||||
|
totalPeople: number;
|
||||||
|
totalLocations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsWidget({ projectStats, totalPeople, totalLocations }: StatsWidgetProps) {
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: 'Total Projects',
|
||||||
|
value: projectStats.total,
|
||||||
|
icon: FolderKanban,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'In Progress',
|
||||||
|
value: projectStats.by_status['in_progress'] || 0,
|
||||||
|
icon: FolderKanban,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'People',
|
||||||
|
value: totalPeople,
|
||||||
|
icon: Users,
|
||||||
|
color: 'text-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Locations',
|
||||||
|
value: totalLocations,
|
||||||
|
icon: MapPin,
|
||||||
|
color: 'text-orange-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<Card key={stat.label}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
||||||
|
<p className="text-3xl font-bold mt-2">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
<stat.icon className={`h-8 w-8 ${stat.color}`} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/components/dashboard/TodoWidget.tsx
Normal file
75
frontend/src/components/dashboard/TodoWidget.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { format, isPast } from 'date-fns';
|
||||||
|
import { Calendar, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DashboardTodo {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
due_date: string;
|
||||||
|
priority: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TodoWidgetProps {
|
||||||
|
todos: DashboardTodo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||||
|
high: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TodoWidget({ todos }: TodoWidgetProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
|
Upcoming Todos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{todos.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No upcoming todos. You're all caught up!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{todos.slice(0, 5).map((todo) => {
|
||||||
|
const isOverdue = isPast(new Date(todo.due_date));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={todo.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">{todo.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-1 text-xs",
|
||||||
|
isOverdue ? "text-destructive" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{format(new Date(todo.due_date), 'MMM d, yyyy')}
|
||||||
|
{isOverdue && <span className="font-medium">(Overdue)</span>}
|
||||||
|
</div>
|
||||||
|
{todo.category && (
|
||||||
|
<Badge variant="outline" className="text-xs">{todo.category}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={priorityColors[todo.priority] || priorityColors.medium}>
|
||||||
|
{todo.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
frontend/src/components/dashboard/UpcomingWidget.tsx
Normal file
76
frontend/src/components/dashboard/UpcomingWidget.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import { CheckSquare, Calendar, Bell } from 'lucide-react';
|
||||||
|
import type { UpcomingItem } from '@/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
interface UpcomingWidgetProps {
|
||||||
|
items: UpcomingItem[];
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||||
|
high: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'todo':
|
||||||
|
return <CheckSquare className="h-5 w-5 text-blue-500" />;
|
||||||
|
case 'event':
|
||||||
|
return <Calendar className="h-5 w-5 text-purple-500" />;
|
||||||
|
case 'reminder':
|
||||||
|
return <Bell className="h-5 w-5 text-orange-500" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Upcoming ({days} days)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No upcoming items in the next few days
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`${item.type}-${item.id}-${index}`}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
|
||||||
|
>
|
||||||
|
{getIcon(item.type)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.datetime
|
||||||
|
? format(new Date(item.datetime), 'MMM d, yyyy h:mm a')
|
||||||
|
: format(new Date(item.date), 'MMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{item.type}
|
||||||
|
</Badge>
|
||||||
|
{item.priority && (
|
||||||
|
<Badge className={priorityColors[item.priority]}>{item.priority}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/layout/AppLayout.tsx
Normal file
35
frontend/src/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
useTheme();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
|
<Sidebar
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggle={() => setCollapsed(!collapsed)}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Mobile header */}
|
||||||
|
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-lg font-bold text-accent ml-3">LifeManager</h1>
|
||||||
|
</div>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/components/layout/Sidebar.tsx
Normal file
114
frontend/src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
CheckSquare,
|
||||||
|
Calendar,
|
||||||
|
Bell,
|
||||||
|
FolderKanban,
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
Settings,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
{ to: '/todos', icon: CheckSquare, label: 'Todos' },
|
||||||
|
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||||
|
{ to: '/reminders', icon: Bell, label: 'Reminders' },
|
||||||
|
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||||
|
{ to: '/people', icon: Users, label: 'People' },
|
||||||
|
{ to: '/locations', icon: MapPin, label: 'Locations' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
mobileOpen: boolean;
|
||||||
|
onMobileClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
|
||||||
|
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/10 hover:text-accent'
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<>
|
||||||
|
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||||
|
{!collapsed && <h1 className="text-xl font-bold text-accent">LifeManager</h1>}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={mobileOpen ? onMobileClose : onToggle}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
{mobileOpen ? (
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
) : collapsed ? (
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1 p-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
onClick={mobileOpen ? onMobileClose : undefined}
|
||||||
|
className={navLinkClass}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 shrink-0" />
|
||||||
|
{(!collapsed || mobileOpen) && <span>{item.label}</span>}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t p-2">
|
||||||
|
<NavLink
|
||||||
|
to="/settings"
|
||||||
|
onClick={mobileOpen ? onMobileClose : undefined}
|
||||||
|
className={navLinkClass}
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5 shrink-0" />
|
||||||
|
{(!collapsed || mobileOpen) && <span>Settings</span>}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'hidden md:flex flex-col border-r bg-card transition-all duration-300',
|
||||||
|
collapsed ? 'w-16' : 'w-64'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sidebarContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 md:hidden">
|
||||||
|
<div className="absolute inset-0 bg-background/80" onClick={onMobileClose} />
|
||||||
|
<aside className="relative z-50 flex flex-col w-64 h-full bg-card border-r">
|
||||||
|
{sidebarContent}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/components/locations/LocationCard.tsx
Normal file
75
frontend/src/components/locations/LocationCard.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/components/locations/LocationForm.tsx
Normal file
124
frontend/src/components/locations/LocationForm.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Location } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface LocationFormProps {
|
||||||
|
location: Location | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LocationForm({ location, onClose }: LocationFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: location?.name || '',
|
||||||
|
address: location?.address || '',
|
||||||
|
category: location?.category || 'other',
|
||||||
|
notes: location?.notes || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
if (location) {
|
||||||
|
const response = await api.put(`/locations/${location.id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await api.post('/locations', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||||
|
toast.success(location ? 'Location updated' : 'Location created');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, location ? 'Failed to update location' : 'Failed to create location'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{location ? 'Edit Location' : 'New Location'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="address">Address</Label>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<Select
|
||||||
|
id="category"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="home">Home</option>
|
||||||
|
<option value="work">Work</option>
|
||||||
|
<option value="restaurant">Restaurant</option>
|
||||||
|
<option value="shop">Shop</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : location ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/components/locations/LocationsPage.tsx
Normal file
92
frontend/src/components/locations/LocationsPage.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, MapPin } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Location } from '@/types';
|
||||||
|
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 LocationCard from './LocationCard';
|
||||||
|
import LocationForm from './LocationForm';
|
||||||
|
|
||||||
|
export default function LocationsPage() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
|
|
||||||
|
const { data: locations = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['locations'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Location[]>('/locations');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredLocations = categoryFilter
|
||||||
|
? locations.filter((loc) => loc.category === categoryFilter)
|
||||||
|
: locations;
|
||||||
|
|
||||||
|
const handleEdit = (location: Location) => {
|
||||||
|
setEditingLocation(location);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingLocation(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-3xl font-bold">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">
|
||||||
|
<Label htmlFor="category-filter">Filter by category:</Label>
|
||||||
|
<Select
|
||||||
|
id="category-filter"
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="home">Home</option>
|
||||||
|
<option value="work">Work</option>
|
||||||
|
<option value="restaurant">Restaurant</option>
|
||||||
|
<option value="shop">Shop</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<GridSkeleton cards={6} />
|
||||||
|
) : filteredLocations.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={MapPin}
|
||||||
|
title="No locations yet"
|
||||||
|
description="Add locations to organise your favourite places, workspaces, and more."
|
||||||
|
actionLabel="Add Location"
|
||||||
|
onAction={() => setShowForm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredLocations.map((location) => (
|
||||||
|
<LocationCard key={location.id} location={location} onEdit={handleEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <LocationForm location={editingLocation} onClose={handleCloseForm} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/components/people/PeoplePage.tsx
Normal file
82
frontend/src/components/people/PeoplePage.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Users } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Person } from '@/types';
|
||||||
|
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 PersonCard from './PersonCard';
|
||||||
|
import PersonForm from './PersonForm';
|
||||||
|
|
||||||
|
export default function PeoplePage() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const { data: people = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['people'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Person[]>('/people');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredPeople = people.filter((person) =>
|
||||||
|
person.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = (person: Person) => {
|
||||||
|
setEditingPerson(person);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingPerson(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-3xl font-bold">People</h1>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Person
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Search people..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<GridSkeleton cards={6} />
|
||||||
|
) : filteredPeople.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)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredPeople.map((person) => (
|
||||||
|
<PersonCard key={person.id} person={person} onEdit={handleEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <PersonForm person={editingPerson} onClose={handleCloseForm} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/components/people/PersonCard.tsx
Normal file
91
frontend/src/components/people/PersonCard.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
frontend/src/components/people/PersonForm.tsx
Normal file
154
frontend/src/components/people/PersonForm.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Person } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface PersonFormProps {
|
||||||
|
person: Person | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersonForm({ person, onClose }: PersonFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: person?.name || '',
|
||||||
|
email: person?.email || '',
|
||||||
|
phone: person?.phone || '',
|
||||||
|
address: person?.address || '',
|
||||||
|
birthday: person?.birthday || '',
|
||||||
|
relationship: person?.relationship || '',
|
||||||
|
notes: person?.notes || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
if (person) {
|
||||||
|
const response = await api.put(`/people/${person.id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await api.post('/people', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||||
|
toast.success(person ? 'Person updated' : 'Person created');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{person ? 'Edit Person' : 'New Person'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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">
|
||||||
|
<Label htmlFor="birthday">Birthday</Label>
|
||||||
|
<Input
|
||||||
|
id="birthday"
|
||||||
|
type="date"
|
||||||
|
value={formData.birthday}
|
||||||
|
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="relationship">Relationship</Label>
|
||||||
|
<Input
|
||||||
|
id="relationship"
|
||||||
|
value={formData.relationship}
|
||||||
|
onChange={(e) => setFormData({ ...formData, relationship: e.target.value })}
|
||||||
|
placeholder="e.g., Friend, Family, Colleague"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/projects/ProjectCard.tsx
Normal file
66
frontend/src/components/projects/ProjectCard.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Calendar } from 'lucide-react';
|
||||||
|
import type { Project } from '@/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: Project;
|
||||||
|
onEdit: (project: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
|
||||||
|
in_progress: 'bg-accent/10 text-accent border-accent/20',
|
||||||
|
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectCard({ project }: ProjectCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const completedTasks = project.tasks?.filter((t) => t.status === 'completed').length || 0;
|
||||||
|
const totalTasks = project.tasks?.length || 0;
|
||||||
|
const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer transition-colors hover:bg-accent/5"
|
||||||
|
onClick={() => navigate(`/projects/${project.id}`)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="text-xl">{project.name}</CardTitle>
|
||||||
|
<Badge className={statusColors[project.status]}>{project.status.replace('_', ' ')}</Badge>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<CardDescription className="line-clamp-2">{project.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{totalTasks > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-muted-foreground">Progress</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{completedTasks}/{totalTasks} tasks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.due_date && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Due {format(new Date(project.due_date), 'MMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/projects/ProjectDetail.tsx
Normal file
197
frontend/src/components/projects/ProjectDetail.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Project, ProjectTask } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import TaskForm from './TaskForm';
|
||||||
|
import ProjectForm from './ProjectForm';
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
|
||||||
|
in_progress: 'bg-accent/10 text-accent border-accent/20',
|
||||||
|
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskStatusColors = {
|
||||||
|
pending: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
|
||||||
|
in_progress: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||||
|
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors = {
|
||||||
|
low: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||||
|
high: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showTaskForm, setShowTaskForm] = useState(false);
|
||||||
|
const [showProjectForm, setShowProjectForm] = useState(false);
|
||||||
|
const [editingTask, setEditingTask] = useState<ProjectTask | null>(null);
|
||||||
|
|
||||||
|
const { data: project, isLoading } = useQuery({
|
||||||
|
queryKey: ['projects', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Project>(`/projects/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleTaskMutation = useMutation({
|
||||||
|
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||||
|
const newStatus = status === 'completed' ? 'pending' : 'completed';
|
||||||
|
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTaskMutation = useMutation({
|
||||||
|
mutationFn: async (taskId: number) => {
|
||||||
|
await api.delete(`/projects/${id}/tasks/${taskId}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||||
|
toast.success('Task deleted');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteProjectMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.delete(`/projects/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
toast.success('Project deleted');
|
||||||
|
navigate('/projects');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete project');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-6 text-center text-muted-foreground">Loading project...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return <div className="p-6 text-center text-muted-foreground">Project not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-3xl font-bold flex-1">{project.name}</h1>
|
||||||
|
<Badge className={statusColors[project.status]}>{project.status.replace('_', ' ')}</Badge>
|
||||||
|
<Button variant="outline" onClick={() => setShowProjectForm(true)}>
|
||||||
|
Edit Project
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteProjectMutation.mutate()}
|
||||||
|
disabled={deleteProjectMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-muted-foreground mb-4">{project.description}</p>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => setShowTaskForm(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{!project.tasks || project.tasks.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">No tasks yet. Add one to get started!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{project.tasks.map((task) => (
|
||||||
|
<Card key={task.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={task.status === 'completed'}
|
||||||
|
onChange={() =>
|
||||||
|
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
|
||||||
|
}
|
||||||
|
disabled={toggleTaskMutation.isPending}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-lg">{task.title}</CardTitle>
|
||||||
|
{task.description && (
|
||||||
|
<CardDescription className="mt-1">{task.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge className={taskStatusColors[task.status]}>
|
||||||
|
{task.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingTask(task);
|
||||||
|
setShowTaskForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteTaskMutation.mutate(task.id)}
|
||||||
|
disabled={deleteTaskMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTaskForm && (
|
||||||
|
<TaskForm
|
||||||
|
projectId={parseInt(id!)}
|
||||||
|
task={editingTask}
|
||||||
|
onClose={() => {
|
||||||
|
setShowTaskForm(false);
|
||||||
|
setEditingTask(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showProjectForm && (
|
||||||
|
<ProjectForm
|
||||||
|
project={project}
|
||||||
|
onClose={() => setShowProjectForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/components/projects/ProjectForm.tsx
Normal file
138
frontend/src/components/projects/ProjectForm.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Project } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface ProjectFormProps {
|
||||||
|
project: Project | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectForm({ project, onClose }: ProjectFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: project?.name || '',
|
||||||
|
description: project?.description || '',
|
||||||
|
status: project?.status || 'not_started',
|
||||||
|
color: project?.color || '',
|
||||||
|
due_date: project?.due_date || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
if (project) {
|
||||||
|
const response = await api.put(`/projects/${project.id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await api.post('/projects', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
if (project) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', project.id.toString()] });
|
||||||
|
}
|
||||||
|
toast.success(project ? 'Project updated' : 'Project created');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, project ? 'Failed to update project' : 'Failed to create project'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{project ? 'Edit Project' : 'New Project'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
id="status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="not_started">Not Started</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="color">Color</Label>
|
||||||
|
<Input
|
||||||
|
id="color"
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
value={formData.due_date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : project ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/components/projects/ProjectsPage.tsx
Normal file
90
frontend/src/components/projects/ProjectsPage.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, FolderKanban } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Project } from '@/types';
|
||||||
|
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 ProjectCard from './ProjectCard';
|
||||||
|
import ProjectForm from './ProjectForm';
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
|
||||||
|
const { data: projects = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Project[]>('/projects');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredProjects = statusFilter
|
||||||
|
? projects.filter((p) => p.status === statusFilter)
|
||||||
|
: projects;
|
||||||
|
|
||||||
|
const handleEdit = (project: Project) => {
|
||||||
|
setEditingProject(project);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingProject(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-3xl font-bold">Projects</h1>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Label htmlFor="status-filter">Filter by status:</Label>
|
||||||
|
<Select
|
||||||
|
id="status-filter"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="not_started">Not Started</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<GridSkeleton cards={6} />
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={FolderKanban}
|
||||||
|
title="No projects yet"
|
||||||
|
description="Create your first project to start tracking tasks and progress."
|
||||||
|
actionLabel="New Project"
|
||||||
|
onAction={() => setShowForm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} onEdit={handleEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <ProjectForm project={editingProject} onClose={handleCloseForm} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/src/components/projects/TaskForm.tsx
Normal file
168
frontend/src/components/projects/TaskForm.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { ProjectTask, Person } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface TaskFormProps {
|
||||||
|
projectId: number;
|
||||||
|
task: ProjectTask | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: task?.title || '',
|
||||||
|
description: task?.description || '',
|
||||||
|
status: task?.status || 'pending',
|
||||||
|
priority: task?.priority || 'medium',
|
||||||
|
due_date: task?.due_date || '',
|
||||||
|
person_id: task?.person_id?.toString() || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: people = [] } = useQuery({
|
||||||
|
queryKey: ['people'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Person[]>('/people');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
person_id: data.person_id ? parseInt(data.person_id) : null,
|
||||||
|
};
|
||||||
|
if (task) {
|
||||||
|
const response = await api.put(`/projects/${projectId}/tasks/${task.id}`, payload);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await api.post(`/projects/${projectId}/tasks`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||||
|
toast.success(task ? 'Task updated' : 'Task created');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, task ? 'Failed to update task' : 'Failed to create task'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{task ? 'Edit Task' : 'New Task'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
id="status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="priority">Priority</Label>
|
||||||
|
<Select
|
||||||
|
id="priority"
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
value={formData.due_date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="person">Assign To</Label>
|
||||||
|
<Select
|
||||||
|
id="person"
|
||||||
|
value={formData.person_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, person_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{people.map((person) => (
|
||||||
|
<option key={person.id} value={person.id}>
|
||||||
|
{person.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : task ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/components/reminders/ReminderForm.tsx
Normal file
124
frontend/src/components/reminders/ReminderForm.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Reminder } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface ReminderFormProps {
|
||||||
|
reminder: Reminder | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: reminder?.title || '',
|
||||||
|
description: reminder?.description || '',
|
||||||
|
remind_at: reminder?.remind_at || '',
|
||||||
|
recurrence_rule: reminder?.recurrence_rule || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
if (reminder) {
|
||||||
|
const response = await api.put(`/reminders/${reminder.id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await api.post('/reminders', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
||||||
|
toast.success(reminder ? 'Reminder updated' : 'Reminder created');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, reminder ? 'Failed to update reminder' : 'Failed to create reminder'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{reminder ? 'Edit Reminder' : 'New Reminder'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="remind_at">Remind At</Label>
|
||||||
|
<Input
|
||||||
|
id="remind_at"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.remind_at}
|
||||||
|
onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recurrence">Recurrence</Label>
|
||||||
|
<Select
|
||||||
|
id="recurrence"
|
||||||
|
value={formData.recurrence_rule}
|
||||||
|
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : reminder ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/components/reminders/ReminderList.tsx
Normal file
114
frontend/src/components/reminders/ReminderList.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { format, isPast } from 'date-fns';
|
||||||
|
import { Bell, BellOff, Trash2, Calendar } from 'lucide-react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Reminder } from '@/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface ReminderListProps {
|
||||||
|
reminders: Reminder[];
|
||||||
|
onEdit: (reminder: Reminder) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReminderList({ reminders, onEdit }: ReminderListProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const dismissMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const { data } = await api.patch(`/reminders/${id}/dismiss`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
||||||
|
toast.success('Reminder dismissed');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to dismiss reminder');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await api.delete(`/reminders/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
||||||
|
toast.success('Reminder deleted');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete reminder');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reminders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">No reminders found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{reminders.map((reminder) => {
|
||||||
|
const isOverdue = !reminder.is_dismissed && isPast(new Date(reminder.remind_at));
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={reminder.id}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-colors hover:bg-accent/5',
|
||||||
|
isOverdue && 'border-destructive'
|
||||||
|
)}
|
||||||
|
onClick={() => onEdit(reminder)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{reminder.is_dismissed ? (
|
||||||
|
<BellOff className="h-5 w-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Bell className={cn('h-5 w-5', isOverdue && 'text-destructive')} />
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-lg">{reminder.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{reminder.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">{reminder.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')}
|
||||||
|
{isOverdue && <span className="text-destructive font-medium">(Overdue)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{!reminder.is_dismissed && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => dismissMutation.mutate(reminder.id)}
|
||||||
|
disabled={dismissMutation.isPending}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => deleteMutation.mutate(reminder.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/reminders/RemindersPage.tsx
Normal file
84
frontend/src/components/reminders/RemindersPage.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Reminder } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { GridSkeleton } from '@/components/ui/skeleton';
|
||||||
|
import ReminderList from './ReminderList';
|
||||||
|
import ReminderForm from './ReminderForm';
|
||||||
|
|
||||||
|
export default function RemindersPage() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'active' | 'dismissed'>('active');
|
||||||
|
|
||||||
|
const { data: reminders = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['reminders'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Reminder[]>('/reminders');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredReminders = reminders.filter((reminder) => {
|
||||||
|
if (filter === 'active') return !reminder.is_dismissed;
|
||||||
|
if (filter === 'dismissed') return reminder.is_dismissed;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (reminder: Reminder) => {
|
||||||
|
setEditingReminder(reminder);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingReminder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-3xl font-bold">Reminders</h1>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Reminder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={filter === 'active' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setFilter('active')}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === 'dismissed' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setFilter('dismissed')}
|
||||||
|
>
|
||||||
|
Dismissed
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === 'all' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<GridSkeleton cards={6} />
|
||||||
|
) : (
|
||||||
|
<ReminderList reminders={filteredReminders} onEdit={handleEdit} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <ReminderForm reminder={editingReminder} onClose={handleCloseForm} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
frontend/src/components/settings/SettingsPage.tsx
Normal file
193
frontend/src/components/settings/SettingsPage.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { useState, FormEvent, CSSProperties } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const accentColors = [
|
||||||
|
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
|
||||||
|
{ name: 'blue', label: 'Blue', color: '#3b82f6' },
|
||||||
|
{ name: 'purple', label: 'Purple', color: '#8b5cf6' },
|
||||||
|
{ name: 'orange', label: 'Orange', color: '#f97316' },
|
||||||
|
{ name: 'green', label: 'Green', color: '#22c55e' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
|
||||||
|
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
||||||
|
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
||||||
|
const [pinForm, setPinForm] = useState({
|
||||||
|
oldPin: '',
|
||||||
|
newPin: '',
|
||||||
|
confirmPin: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleColorChange = async (color: string) => {
|
||||||
|
setSelectedColor(color);
|
||||||
|
try {
|
||||||
|
await updateSettings({ accent_color: color });
|
||||||
|
toast.success('Accent color updated');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update accent color');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpcomingDaysSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await updateSettings({ upcoming_days: upcomingDays });
|
||||||
|
toast.success('Settings updated');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (pinForm.newPin !== pinForm.confirmPin) {
|
||||||
|
toast.error('New PINs do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pinForm.newPin.length < 4) {
|
||||||
|
toast.error('PIN must be at least 4 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await changePin({ oldPin: pinForm.oldPin, newPin: pinForm.newPin });
|
||||||
|
toast.success('PIN changed successfully');
|
||||||
|
setPinForm({ oldPin: '', newPin: '', confirmPin: '' });
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to change PIN');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
<CardDescription>Customize the look and feel of your application</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Accent Color</Label>
|
||||||
|
<div className="flex gap-3 mt-3">
|
||||||
|
{accentColors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleColorChange(color.name)}
|
||||||
|
className={cn(
|
||||||
|
'h-12 w-12 rounded-full border-2 transition-all hover:scale-110',
|
||||||
|
selectedColor === color.name
|
||||||
|
? 'border-white ring-2 ring-offset-2 ring-offset-background'
|
||||||
|
: 'border-transparent'
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
backgroundColor: color.color,
|
||||||
|
'--tw-ring-color': color.color,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
title={color.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dashboard</CardTitle>
|
||||||
|
<CardDescription>Configure your dashboard preferences</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleUpcomingDaysSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
id="upcoming_days"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
value={upcomingDays}
|
||||||
|
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">days</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
How many days ahead to show in the upcoming items widget
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isUpdating}>
|
||||||
|
{isUpdating ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security</CardTitle>
|
||||||
|
<CardDescription>Change your PIN</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handlePinSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="old_pin">Current PIN</Label>
|
||||||
|
<Input
|
||||||
|
id="old_pin"
|
||||||
|
type="password"
|
||||||
|
value={pinForm.oldPin}
|
||||||
|
onChange={(e) => setPinForm({ ...pinForm, oldPin: e.target.value })}
|
||||||
|
required
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new_pin">New PIN</Label>
|
||||||
|
<Input
|
||||||
|
id="new_pin"
|
||||||
|
type="password"
|
||||||
|
value={pinForm.newPin}
|
||||||
|
onChange={(e) => setPinForm({ ...pinForm, newPin: e.target.value })}
|
||||||
|
required
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm_pin">Confirm New PIN</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm_pin"
|
||||||
|
type="password"
|
||||||
|
value={pinForm.confirmPin}
|
||||||
|
onChange={(e) => setPinForm({ ...pinForm, confirmPin: e.target.value })}
|
||||||
|
required
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isChangingPin}>
|
||||||
|
{isChangingPin ? 'Changing...' : 'Change PIN'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/components/todos/TodoForm.tsx
Normal file
150
frontend/src/components/todos/TodoForm.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Todo } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface TodoFormProps {
|
||||||
|
todo: Todo | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: todo?.title || '',
|
||||||
|
description: todo?.description || '',
|
||||||
|
priority: todo?.priority || 'medium',
|
||||||
|
due_date: todo?.due_date || '',
|
||||||
|
category: todo?.category || '',
|
||||||
|
recurrence_rule: todo?.recurrence_rule || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: typeof formData) => {
|
||||||
|
if (todo) {
|
||||||
|
const response = await api.put(`/todos/${todo.id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
const response = await api.post('/todos', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
toast.success(todo ? 'Todo updated' : 'Todo created');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, todo ? 'Failed to update todo' : 'Failed to create todo'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={onClose} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{todo ? 'Edit Todo' : 'New Todo'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="priority">Priority</Label>
|
||||||
|
<Select
|
||||||
|
id="priority"
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
value={formData.due_date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<Input
|
||||||
|
id="category"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||||
|
placeholder="e.g., Work, Personal, Shopping"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recurrence">Recurrence</Label>
|
||||||
|
<Select
|
||||||
|
id="recurrence"
|
||||||
|
value={formData.recurrence_rule}
|
||||||
|
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : todo ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/components/todos/TodoItem.tsx
Normal file
93
frontend/src/components/todos/TodoItem.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Trash2, Calendar } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Todo } from '@/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface TodoItemProps {
|
||||||
|
todo: Todo;
|
||||||
|
onEdit: (todo: Todo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors = {
|
||||||
|
low: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||||
|
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||||
|
high: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TodoItem({ todo, onEdit }: TodoItemProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.patch(`/todos/${todo.id}/toggle`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
toast.success(todo.completed ? 'Todo marked incomplete' : 'Todo completed!');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to update todo');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.delete(`/todos/${todo.id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
toast.success('Todo deleted');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete todo');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent/5 transition-colors">
|
||||||
|
<Checkbox
|
||||||
|
checked={todo.completed}
|
||||||
|
onChange={() => toggleMutation.mutate()}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onEdit(todo)}>
|
||||||
|
<h3
|
||||||
|
className={cn(
|
||||||
|
'font-medium',
|
||||||
|
todo.completed && 'line-through text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{todo.title}
|
||||||
|
</h3>
|
||||||
|
{todo.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">{todo.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge className={priorityColors[todo.priority]}>{todo.priority}</Badge>
|
||||||
|
{todo.category && <Badge variant="outline">{todo.category}</Badge>}
|
||||||
|
{todo.due_date && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{format(new Date(todo.due_date), 'MMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/todos/TodoList.tsx
Normal file
25
frontend/src/components/todos/TodoList.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { Todo } from '@/types';
|
||||||
|
import TodoItem from './TodoItem';
|
||||||
|
|
||||||
|
interface TodoListProps {
|
||||||
|
todos: Todo[];
|
||||||
|
onEdit: (todo: Todo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TodoList({ todos, onEdit }: TodoListProps) {
|
||||||
|
if (todos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">No todos found. Create one to get started!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{todos.map((todo) => (
|
||||||
|
<TodoItem key={todo.id} todo={todo} onEdit={onEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/components/todos/TodosPage.tsx
Normal file
110
frontend/src/components/todos/TodosPage.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Todo } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||||
|
import TodoList from './TodoList';
|
||||||
|
import TodoForm from './TodoForm';
|
||||||
|
|
||||||
|
export default function TodosPage() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
priority: '',
|
||||||
|
category: '',
|
||||||
|
showCompleted: true,
|
||||||
|
search: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: todos = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Todo[]>('/todos');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTodos = todos.filter((todo) => {
|
||||||
|
if (filters.priority && todo.priority !== filters.priority) return false;
|
||||||
|
if (filters.category && todo.category !== filters.category) return false;
|
||||||
|
if (!filters.showCompleted && todo.completed) return false;
|
||||||
|
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase()))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (todo: Todo) => {
|
||||||
|
setEditingTodo(todo);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingTodo(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="border-b bg-card px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-3xl font-bold">Todos</h1>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Todo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<Input
|
||||||
|
placeholder="Search todos..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={filters.priority}
|
||||||
|
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by category..."
|
||||||
|
value={filters.category}
|
||||||
|
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="show-completed"
|
||||||
|
checked={filters.showCompleted}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-completed">Show completed</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<ListSkeleton rows={6} />
|
||||||
|
) : (
|
||||||
|
<TodoList todos={filteredTodos} onEdit={handleEdit} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/ui/badge.tsx
Normal file
30
frontend/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-accent text-accent-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
48
frontend/src/components/ui/button.tsx
Normal file
48
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent/10 hover:text-accent',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent/10 hover:text-accent',
|
||||||
|
link: 'text-accent underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
55
frontend/src/components/ui/card.tsx
Normal file
55
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
27
frontend/src/components/ui/checkbox.tsx
Normal file
27
frontend/src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none checked:bg-accent checked:border-accent',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<Check className="absolute left-0 h-4 w-4 text-accent-foreground opacity-0 peer-checked:opacity-100 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Checkbox.displayName = 'Checkbox';
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
106
frontend/src/components/ui/dialog.tsx
Normal file
106
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onOpenChange(false);
|
||||||
|
};
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative z-50">{children}</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative grid w-full max-w-lg gap-4 border bg-card p-6 shadow-lg rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
DialogContent.displayName = 'DialogContent';
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h2
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
DialogTitle.displayName = 'DialogTitle';
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = 'DialogDescription';
|
||||||
|
|
||||||
|
const DialogClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
DialogClose.displayName = 'DialogClose';
|
||||||
|
|
||||||
|
export { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, DialogClose };
|
||||||
67
frontend/src/components/ui/dropdown-menu.tsx
Normal file
67
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DropdownMenuProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownMenu: React.FC<DropdownMenuProps> = ({ children }) => {
|
||||||
|
return <div className="relative inline-block">{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<button ref={ref} className={cn(className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
));
|
||||||
|
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0 z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
DropdownMenuContent.displayName = 'DropdownMenuContent';
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
};
|
||||||
29
frontend/src/components/ui/empty-state.tsx
Normal file
29
frontend/src/components/ui/empty-state.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Button } from './button';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||||
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
|
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">{title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">{description}</p>
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<Button onClick={onAction}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/ui/input.tsx
Normal file
23
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
20
frontend/src/components/ui/label.tsx
Normal file
20
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||||
|
|
||||||
|
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Label.displayName = 'Label';
|
||||||
|
|
||||||
|
export { Label };
|
||||||
13
frontend/src/components/ui/scroll-area.tsx
Normal file
13
frontend/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('relative overflow-auto', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ScrollArea.displayName = 'ScrollArea';
|
||||||
|
|
||||||
|
export { ScrollArea };
|
||||||
28
frontend/src/components/ui/select.tsx
Normal file
28
frontend/src/components/ui/select.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
||||||
|
|
||||||
|
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 opacity-50 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Select.displayName = 'Select';
|
||||||
|
|
||||||
|
export { Select };
|
||||||
26
frontend/src/components/ui/separator.tsx
Normal file
26
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
decorative?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
||||||
|
({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role={decorative ? 'none' : 'separator'}
|
||||||
|
aria-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Separator.displayName = 'Separator';
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
81
frontend/src/components/ui/skeleton.tsx
Normal file
81
frontend/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-pulse rounded-md bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<Skeleton className="h-5 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListSkeleton({ rows = 5 }: { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-lg border bg-card p-4">
|
||||||
|
<Skeleton className="h-5 w-5 rounded" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSkeleton({ cards = 6 }: { cards?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: cards }).map((_, i) => (
|
||||||
|
<CardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-lg border bg-card p-6 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-8 w-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/textarea.tsx
Normal file
22
frontend/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user