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