Merge security/pentest-remediation-2026-02-26: remediate F-01, F-02, F-06
This commit is contained in:
commit
132226120b
12
.env.example
12
.env.example
@ -16,5 +16,17 @@ SECRET_KEY=change-this-to-a-random-secret-key-in-production
|
|||||||
# Timezone (applied to backend + db containers via env_file)
|
# Timezone (applied to backend + db containers via env_file)
|
||||||
TZ=Australia/Perth
|
TZ=Australia/Perth
|
||||||
|
|
||||||
|
# Session cookie security
|
||||||
|
# Set to true when serving over HTTPS. Required before any TLS deployment.
|
||||||
|
# COOKIE_SECURE=true
|
||||||
|
|
||||||
# Integrations
|
# Integrations
|
||||||
OPENWEATHERMAP_API_KEY=your-openweathermap-api-key
|
OPENWEATHERMAP_API_KEY=your-openweathermap-api-key
|
||||||
|
|
||||||
|
# Production security checklist (enable all before any non-internal deployment):
|
||||||
|
# 1. Set SECRET_KEY to output of: openssl rand -hex 32
|
||||||
|
# 2. Set POSTGRES_PASSWORD to a strong unique value
|
||||||
|
# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000)
|
||||||
|
# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream)
|
||||||
|
# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
# 6. Complete user_id migration (migration 026) before enabling multi-user accounts
|
||||||
|
|||||||
@ -111,8 +111,8 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
|
|||||||
- **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root
|
- **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root
|
||||||
- **No external backend port** — port 8000 is internal-only; all traffic flows through nginx
|
- **No external backend port** — port 8000 is internal-only; all traffic flows through nginx
|
||||||
- **Server version suppression** — `server_tokens off` (nginx) and `--no-server-header` (uvicorn)
|
- **Server version suppression** — `server_tokens off` (nginx) and `--no-server-header` (uvicorn)
|
||||||
- **Rate limiting** — nginx `limit_req_zone` (10 req/min, burst=5) on `/api/auth/login`, `/verify-password`, `/change-password`, `/totp-verify`
|
- **Rate limiting** — nginx `limit_req_zone` (10 req/min) on `/api/auth/login` (burst=5), `/verify-password` (burst=5), `/change-password` (burst=5), `/totp-verify` (burst=5), `/setup` (burst=3)
|
||||||
- **Application-level rate limiting** — in-memory IP-based rate limit (5 attempts / 5 min) + DB-backed account lockout (10 failures = 30-min lock)
|
- **DB-backed account lockout** — 10 failed attempts triggers 30-minute lock per account
|
||||||
- **Dotfile blocking** — `/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME)
|
- **Dotfile blocking** — `/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME)
|
||||||
- **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts
|
- **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts
|
||||||
- **CORS** — configurable origins with explicit method/header allowlists
|
- **CORS** — configurable origins with explicit method/header allowlists
|
||||||
|
|||||||
@ -9,14 +9,12 @@ Session flow:
|
|||||||
GET /status → verify user exists + session valid
|
GET /status → verify user exists + session valid
|
||||||
|
|
||||||
Security layers:
|
Security layers:
|
||||||
1. IP-based in-memory rate limit (5 attempts / 5 min) — outer guard, username enumeration
|
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on all auth endpoints
|
||||||
2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard
|
2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard
|
||||||
3. Session revocation stored in DB (survives container restarts)
|
3. Session revocation stored in DB (survives container restarts)
|
||||||
4. bcrypt→Argon2id transparent upgrade on first login with migrated hash
|
4. bcrypt→Argon2id transparent upgrade on first login with migrated hash
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -40,41 +38,6 @@ from app.config import settings as app_settings
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# IP-based in-memory rate limit (retained as outer layer for all login attempts)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_failed_attempts: dict[str, list[float]] = defaultdict(list)
|
|
||||||
_MAX_IP_ATTEMPTS = 5
|
|
||||||
_IP_WINDOW_SECONDS = 300 # 5 minutes
|
|
||||||
_MAX_TRACKED_IPS = 10000 # cap to prevent unbounded memory growth
|
|
||||||
|
|
||||||
|
|
||||||
def _check_ip_rate_limit(ip: str) -> None:
|
|
||||||
"""Raise 429 if the IP has exceeded the failure window."""
|
|
||||||
now = time.time()
|
|
||||||
# Purge all stale entries if the dict is oversized (spray attack defense)
|
|
||||||
if len(_failed_attempts) > _MAX_TRACKED_IPS:
|
|
||||||
stale_ips = [k for k, v in _failed_attempts.items() if all(now - t >= _IP_WINDOW_SECONDS for t in v)]
|
|
||||||
for k in stale_ips:
|
|
||||||
del _failed_attempts[k]
|
|
||||||
# If still over cap after purge, clear everything (all entries are within window
|
|
||||||
# but we can't let memory grow unbounded — login will still hit account lockout)
|
|
||||||
if len(_failed_attempts) > _MAX_TRACKED_IPS:
|
|
||||||
_failed_attempts.clear()
|
|
||||||
_failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS]
|
|
||||||
if not _failed_attempts[ip]:
|
|
||||||
_failed_attempts.pop(ip, None)
|
|
||||||
elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail="Too many failed login attempts. Try again in a few minutes.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_ip_failure(ip: str) -> None:
|
|
||||||
_failed_attempts[ip].append(time.time())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cookie helper
|
# Cookie helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -267,14 +230,12 @@ async def login(
|
|||||||
HTTP 429 — IP rate limited
|
HTTP 429 — IP rate limited
|
||||||
"""
|
"""
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
_check_ip_rate_limit(client_ip)
|
|
||||||
|
|
||||||
# Lookup user — do NOT differentiate "user not found" from "wrong password"
|
# Lookup user — do NOT differentiate "user not found" from "wrong password"
|
||||||
result = await db.execute(select(User).where(User.username == data.username))
|
result = await db.execute(select(User).where(User.username == data.username))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
_record_ip_failure(client_ip)
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
await _check_account_lockout(user)
|
await _check_account_lockout(user)
|
||||||
@ -283,7 +244,6 @@ async def login(
|
|||||||
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
_record_ip_failure(client_ip)
|
|
||||||
await _record_failed_login(db, user)
|
await _record_failed_login(db, user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
@ -291,8 +251,6 @@ async def login(
|
|||||||
if new_hash:
|
if new_hash:
|
||||||
user.password_hash = new_hash
|
user.password_hash = new_hash
|
||||||
|
|
||||||
# Clear IP failures and update user state
|
|
||||||
_failed_attempts.pop(client_ip, None)
|
|
||||||
await _record_successful_login(db, user)
|
await _record_successful_login(db, user)
|
||||||
|
|
||||||
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
|
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
|
||||||
@ -376,7 +334,6 @@ async def auth_status(
|
|||||||
@router.post("/verify-password")
|
@router.post("/verify-password")
|
||||||
async def verify_password(
|
async def verify_password(
|
||||||
data: VerifyPasswordRequest,
|
data: VerifyPasswordRequest,
|
||||||
request: Request,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -384,20 +341,15 @@ async def verify_password(
|
|||||||
Verify the current user's password without changing anything.
|
Verify the current user's password without changing anything.
|
||||||
Used by the frontend lock screen to re-authenticate without a full login.
|
Used by the frontend lock screen to re-authenticate without a full login.
|
||||||
Also handles transparent bcrypt→Argon2id upgrade.
|
Also handles transparent bcrypt→Argon2id upgrade.
|
||||||
Shares the same rate-limit and lockout guards as /login.
|
Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting.
|
||||||
"""
|
"""
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
_check_ip_rate_limit(client_ip)
|
|
||||||
await _check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
_record_ip_failure(client_ip)
|
|
||||||
await _record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
_failed_attempts.pop(client_ip, None)
|
|
||||||
|
|
||||||
# Persist upgraded hash if migration happened
|
# Persist upgraded hash if migration happened
|
||||||
if new_hash:
|
if new_hash:
|
||||||
current_user.password_hash = new_hash
|
current_user.password_hash = new_hash
|
||||||
@ -409,22 +361,17 @@ async def verify_password(
|
|||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
data: ChangePasswordRequest,
|
data: ChangePasswordRequest,
|
||||||
request: Request,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Change the current user's password. Requires old password verification."""
|
"""Change the current user's password. Requires old password verification."""
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
_check_ip_rate_limit(client_ip)
|
|
||||||
await _check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
|
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
_record_ip_failure(client_ip)
|
|
||||||
await _record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||||
|
|
||||||
_failed_attempts.pop(client_ip, None)
|
|
||||||
current_user.password_hash = hash_password(data.new_password)
|
current_user.password_hash = hash_password(data.new_password)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@ -17,13 +17,18 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests
|
NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests
|
||||||
|
|
||||||
# Loopback + link-local only. Private IPs (RFC 1918) are intentionally allowed
|
# Loopback, link-local, and all RFC 1918 private ranges are blocked to prevent
|
||||||
# because UMBRA is self-hosted and the user's ntfy server is typically on the same LAN.
|
# SSRF against Docker-internal services. If a self-hosted ntfy server on the LAN
|
||||||
|
# is required, remove the RFC 1918 entries from _BLOCKED_NETWORKS and document the accepted risk.
|
||||||
_BLOCKED_NETWORKS = [
|
_BLOCKED_NETWORKS = [
|
||||||
ipaddress.ip_network("127.0.0.0/8"),
|
ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback
|
||||||
ipaddress.ip_network("169.254.0.0/16"),
|
ipaddress.ip_network("10.0.0.0/8"), # RFC 1918 private
|
||||||
ipaddress.ip_network("::1/128"),
|
ipaddress.ip_network("172.16.0.0/12"), # RFC 1918 private — covers Docker bridge 172.17-31.x
|
||||||
ipaddress.ip_network("fe80::/10"),
|
ipaddress.ip_network("192.168.0.0/16"), # RFC 1918 private
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"), # IPv4 link-local
|
||||||
|
ipaddress.ip_network("::1/128"), # IPv6 loopback
|
||||||
|
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
||||||
|
ipaddress.ip_network("fc00::/7"), # IPv6 ULA (covers fd00::/8)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,13 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /api/auth/setup {
|
||||||
|
# Tighter burst: setup is one-time-only, 3 attempts is sufficient
|
||||||
|
limit_req zone=auth_limit burst=3 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
# API proxy
|
# API proxy
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import FullCalendar from '@fullcalendar/react';
|
import FullCalendar from '@fullcalendar/react';
|
||||||
@ -6,20 +7,16 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
|||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
|
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { CalendarEvent, EventTemplate } from '@/types';
|
import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types';
|
||||||
import { useCalendars } from '@/hooks/useCalendars';
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import CalendarSidebar from './CalendarSidebar';
|
import CalendarSidebar from './CalendarSidebar';
|
||||||
import EventForm from './EventForm';
|
import EventDetailPanel from './EventDetailPanel';
|
||||||
|
import type { CreateDefaults } from './EventDetailPanel';
|
||||||
|
|
||||||
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
||||||
|
|
||||||
@ -29,32 +26,57 @@ const viewLabels: Record<CalendarView, string> = {
|
|||||||
timeGridDay: 'Day',
|
timeGridDay: 'Day',
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScopeAction = 'edit' | 'delete';
|
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const location = useLocation();
|
||||||
const calendarRef = useRef<FullCalendar>(null);
|
const calendarRef = useRef<FullCalendar>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
|
||||||
const [selectedStart, setSelectedStart] = useState<string | null>(null);
|
|
||||||
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
|
|
||||||
const [selectedAllDay, setSelectedAllDay] = useState(false);
|
|
||||||
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
||||||
const [calendarTitle, setCalendarTitle] = useState('');
|
const [calendarTitle, setCalendarTitle] = useState('');
|
||||||
|
|
||||||
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
|
const [eventSearch, setEventSearch] = useState('');
|
||||||
const [templateName, setTemplateName] = useState<string | null>(null);
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
|
||||||
// Scope dialog state
|
// Panel state
|
||||||
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
|
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
|
||||||
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||||
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
|
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||||
const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
|
||||||
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { data: calendars = [] } = useCalendars();
|
const { data: calendars = [] } = useCalendars();
|
||||||
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Location data for event panel
|
||||||
|
const { data: locations = [] } = useQuery({
|
||||||
|
queryKey: ['locations'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<LocationType[]>('/locations');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationMap = useMemo(() => {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
locations.forEach((l) => map.set(l.id, l.name));
|
||||||
|
return map;
|
||||||
|
}, [locations]);
|
||||||
|
|
||||||
|
// Handle navigation state from dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
|
||||||
|
if (!state) return;
|
||||||
|
const calApi = calendarRef.current?.getApi();
|
||||||
|
if (state.date && calApi) {
|
||||||
|
calApi.gotoDate(state.date);
|
||||||
|
if (state.view) calApi.changeView(state.view);
|
||||||
|
}
|
||||||
|
if (state.eventId) {
|
||||||
|
setSelectedEventId(state.eventId);
|
||||||
|
setPanelMode('view');
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '');
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
// Resize FullCalendar when container size changes (e.g. sidebar collapse)
|
// Resize FullCalendar when container size changes (e.g. sidebar collapse)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = calendarContainerRef.current;
|
const el = calendarContainerRef.current;
|
||||||
@ -66,6 +88,23 @@ export default function CalendarPage() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const panelOpen = panelMode !== 'closed';
|
||||||
|
|
||||||
|
// Continuously resize calendar during panel open/close CSS transition
|
||||||
|
useEffect(() => {
|
||||||
|
let rafId: number;
|
||||||
|
const start = performance.now();
|
||||||
|
const duration = 350; // slightly longer than the 300ms CSS transition
|
||||||
|
const tick = () => {
|
||||||
|
calendarRef.current?.getApi().updateSize();
|
||||||
|
if (performance.now() - start < duration) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, [panelOpen]);
|
||||||
|
|
||||||
// Scroll wheel navigation in month view
|
// Scroll wheel navigation in month view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = calendarContainerRef.current;
|
const el = calendarContainerRef.current;
|
||||||
@ -94,6 +133,21 @@ export default function CalendarPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedEvent = useMemo(
|
||||||
|
() => events.find((e) => e.id === selectedEventId) ?? null,
|
||||||
|
[selectedEventId, events],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Escape key closes detail panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelOpen) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') handlePanelClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [panelOpen]);
|
||||||
|
|
||||||
const visibleCalendarIds = useMemo(
|
const visibleCalendarIds = useMemo(
|
||||||
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
|
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
|
||||||
[calendars],
|
[calendars],
|
||||||
@ -152,28 +206,38 @@ export default function CalendarPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const scopeDeleteMutation = useMutation({
|
|
||||||
mutationFn: async ({ id, scope }: { id: number; scope: string }) => {
|
|
||||||
await api.delete(`/events/${id}?scope=${scope}`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
|
||||||
toast.success('Event(s) deleted');
|
|
||||||
setScopeDialogOpen(false);
|
|
||||||
setScopeEvent(null);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
if (calendars.length === 0) return events;
|
if (calendars.length === 0) return events;
|
||||||
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
||||||
}, [events, visibleCalendarIds, calendars.length]);
|
}, [events, visibleCalendarIds, calendars.length]);
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
if (!eventSearch.trim()) return [];
|
||||||
|
const q = eventSearch.toLowerCase();
|
||||||
|
return filteredEvents
|
||||||
|
.filter((e) => e.title.toLowerCase().includes(q))
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [filteredEvents, eventSearch]);
|
||||||
|
|
||||||
|
const handleSearchSelect = (event: CalendarEvent) => {
|
||||||
|
const calApi = calendarRef.current?.getApi();
|
||||||
|
if (!calApi) return;
|
||||||
|
const startDate = new Date(event.start_datetime);
|
||||||
|
calApi.gotoDate(startDate);
|
||||||
|
if (event.all_day) {
|
||||||
|
calApi.changeView('dayGridMonth');
|
||||||
|
} else {
|
||||||
|
calApi.changeView('timeGridDay');
|
||||||
|
}
|
||||||
|
setEventSearch('');
|
||||||
|
setSearchFocused(false);
|
||||||
|
// Also open the event in the panel
|
||||||
|
if (!event.is_virtual) {
|
||||||
|
setSelectedEventId(event.id);
|
||||||
|
setPanelMode('view');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const calendarEvents = filteredEvents.map((event) => ({
|
const calendarEvents = filteredEvents.map((event) => ({
|
||||||
id: String(event.id),
|
id: String(event.id),
|
||||||
title: event.title,
|
title: event.title,
|
||||||
@ -189,10 +253,6 @@ export default function CalendarPage() {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const isRecurring = (event: CalendarEvent): boolean => {
|
|
||||||
return !!(event.is_recurring || event.parent_event_id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEventClick = (info: EventClickArg) => {
|
const handleEventClick = (info: EventClickArg) => {
|
||||||
const event = events.find((e) => String(e.id) === info.event.id);
|
const event = events.find((e) => String(e.id) === info.event.id);
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
@ -200,27 +260,8 @@ export default function CalendarPage() {
|
|||||||
toast.info(`${event.title} — from People contacts`);
|
toast.info(`${event.title} — from People contacts`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSelectedEventId(event.id);
|
||||||
if (isRecurring(event)) {
|
setPanelMode('view');
|
||||||
setScopeEvent(event);
|
|
||||||
setScopeAction('edit');
|
|
||||||
setScopeDialogOpen(true);
|
|
||||||
} else {
|
|
||||||
setEditingEvent(event);
|
|
||||||
setShowForm(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
|
|
||||||
if (!scopeEvent) return;
|
|
||||||
if (scopeAction === 'edit') {
|
|
||||||
setEditingEvent(scopeEvent);
|
|
||||||
setActiveEditScope(scope);
|
|
||||||
setShowForm(true);
|
|
||||||
setScopeDialogOpen(false);
|
|
||||||
} else if (scopeAction === 'delete') {
|
|
||||||
scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEventDrop = (info: EventDropArg) => {
|
const handleEventDrop = (info: EventDropArg) => {
|
||||||
@ -228,7 +269,6 @@ export default function CalendarPage() {
|
|||||||
info.revert();
|
info.revert();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Prevent drag-drop on recurring events — user must use scope dialog via click
|
|
||||||
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
||||||
info.revert();
|
info.revert();
|
||||||
toast.info('Click the event to edit recurring events');
|
toast.info('Click the event to edit recurring events');
|
||||||
@ -253,7 +293,6 @@ export default function CalendarPage() {
|
|||||||
info.revert();
|
info.revert();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Prevent resize on recurring events — user must use scope dialog via click
|
|
||||||
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
||||||
info.revert();
|
info.revert();
|
||||||
toast.info('Click the event to edit recurring events');
|
toast.info('Click the event to edit recurring events');
|
||||||
@ -274,26 +313,33 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
||||||
setSelectedStart(selectInfo.startStr);
|
setSelectedEventId(null);
|
||||||
setSelectedEnd(selectInfo.endStr);
|
setPanelMode('create');
|
||||||
setSelectedAllDay(selectInfo.allDay);
|
setCreateDefaults({
|
||||||
setShowForm(true);
|
start: selectInfo.startStr,
|
||||||
|
end: selectInfo.endStr,
|
||||||
|
allDay: selectInfo.allDay,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCreateNew = () => {
|
||||||
|
setSelectedEventId(null);
|
||||||
|
setPanelMode('create');
|
||||||
|
setCreateDefaults(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePanelClose = () => {
|
||||||
calendarRef.current?.getApi().unselect();
|
calendarRef.current?.getApi().unselect();
|
||||||
setShowForm(false);
|
setPanelMode('closed');
|
||||||
setEditingEvent(null);
|
setSelectedEventId(null);
|
||||||
setTemplateEvent(null);
|
setCreateDefaults(null);
|
||||||
setTemplateName(null);
|
|
||||||
setActiveEditScope(null);
|
|
||||||
setSelectedStart(null);
|
|
||||||
setSelectedEnd(null);
|
|
||||||
setSelectedAllDay(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUseTemplate = (template: EventTemplate) => {
|
const handleUseTemplate = (template: EventTemplate) => {
|
||||||
setTemplateEvent({
|
setSelectedEventId(null);
|
||||||
|
setPanelMode('create');
|
||||||
|
setCreateDefaults({
|
||||||
|
templateData: {
|
||||||
title: template.title,
|
title: template.title,
|
||||||
description: template.description || '',
|
description: template.description || '',
|
||||||
all_day: template.all_day,
|
all_day: template.all_day,
|
||||||
@ -301,10 +347,9 @@ export default function CalendarPage() {
|
|||||||
location_id: template.location_id || undefined,
|
location_id: template.location_id || undefined,
|
||||||
is_starred: template.is_starred,
|
is_starred: template.is_starred,
|
||||||
recurrence_rule: template.recurrence_rule || undefined,
|
recurrence_rule: template.recurrence_rule || undefined,
|
||||||
} as Partial<CalendarEvent>);
|
} as Partial<CalendarEvent>,
|
||||||
setTemplateName(template.name);
|
templateName: template.name,
|
||||||
setEditingEvent(null);
|
});
|
||||||
setShowForm(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDatesSet = (arg: DatesSetArg) => {
|
const handleDatesSet = (arg: DatesSetArg) => {
|
||||||
@ -318,11 +363,11 @@ export default function CalendarPage() {
|
|||||||
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
|
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-full overflow-hidden animate-fade-in">
|
||||||
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
||||||
|
|
||||||
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Custom toolbar — h-16 matches sidebar header */}
|
{/* Custom toolbar */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
||||||
@ -335,7 +380,7 @@ export default function CalendarPage() {
|
|||||||
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
|
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
|
||||||
Today
|
Today
|
||||||
</Button>
|
</Button>
|
||||||
<h2 className="text-lg font-semibold font-heading flex-1">{calendarTitle}</h2>
|
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||||
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
||||||
<button
|
<button
|
||||||
@ -355,10 +400,61 @@ export default function CalendarPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Event search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search events..."
|
||||||
|
value={eventSearch}
|
||||||
|
onChange={(e) => setEventSearch(e.target.value)}
|
||||||
|
onFocus={() => setSearchFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
|
||||||
|
className="w-52 h-8 pl-8 text-sm ring-inset"
|
||||||
|
/>
|
||||||
|
{searchFocused && searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden">
|
||||||
|
{searchResults.map((event) => (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleSearchSelect(event)}
|
||||||
|
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium truncate block">{event.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(event.start_datetime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar grid */}
|
<Button size="sm" onClick={handleCreateNew}>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid + event detail panel */}
|
||||||
|
<div className="flex-1 overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
||||||
@ -384,60 +480,46 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && (
|
{/* Detail panel (desktop) */}
|
||||||
<EventForm
|
<div
|
||||||
event={editingEvent}
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
templateData={templateEvent}
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||||
templateName={templateName}
|
}`}
|
||||||
initialStart={selectedStart}
|
>
|
||||||
initialEnd={selectedEnd}
|
<EventDetailPanel
|
||||||
initialAllDay={selectedAllDay}
|
event={panelMode === 'view' ? selectedEvent : null}
|
||||||
editScope={activeEditScope}
|
isCreating={panelMode === 'create'}
|
||||||
onClose={handleCloseForm}
|
createDefaults={createDefaults}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={handlePanelClose}
|
||||||
|
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recurring event scope dialog */}
|
|
||||||
<Dialog open={scopeDialogOpen} onOpenChange={setScopeDialogOpen}>
|
|
||||||
<DialogContent className="max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This is a recurring event. How would you like to proceed?
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-center"
|
|
||||||
onClick={() => handleScopeChoice('this')}
|
|
||||||
>
|
|
||||||
This event only
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-center"
|
|
||||||
onClick={() => handleScopeChoice('this_and_future')}
|
|
||||||
>
|
|
||||||
This and all future events
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-center"
|
|
||||||
onClick={() => {
|
|
||||||
setScopeDialogOpen(false);
|
|
||||||
setScopeEvent(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile detail panel overlay */}
|
||||||
|
{panelOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
|
onClick={handlePanelClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<EventDetailPanel
|
||||||
|
event={panelMode === 'view' ? selectedEvent : null}
|
||||||
|
isCreating={panelMode === 'create'}
|
||||||
|
createDefaults={createDefaults}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={handlePanelClose}
|
||||||
|
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
915
frontend/src/components/calendar/EventDetailPanel.tsx
Normal file
915
frontend/src/components/calendar/EventDetailPanel.tsx
Normal file
@ -0,0 +1,915 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import {
|
||||||
|
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types';
|
||||||
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||||
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import LocationPicker from '@/components/ui/location-picker';
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function toDateOnly(dt: string): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
return dt.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocal(dt: string, fallbackTime = '09:00'): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
if (dt.includes('T')) return dt.slice(0, 16);
|
||||||
|
return `${dt}T${fallbackTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatForInput(dt: string, allDay: boolean, fallbackTime = '09:00'): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustAllDayEndForDisplay(dateStr: string): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustAllDayEndForSave(dateStr: string): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowLocal(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plusOneHour(dt: string): string {
|
||||||
|
const d = new Date(dt);
|
||||||
|
d.setHours(d.getHours() + 1);
|
||||||
|
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())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecurrenceRule(rule: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rule);
|
||||||
|
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
switch (parsed.type) {
|
||||||
|
case 'every_n_days':
|
||||||
|
return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`;
|
||||||
|
case 'weekly':
|
||||||
|
return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly';
|
||||||
|
case 'monthly_nth_weekday':
|
||||||
|
return parsed.week && parsed.weekday != null
|
||||||
|
? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}`
|
||||||
|
: 'Monthly';
|
||||||
|
case 'monthly_date':
|
||||||
|
return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly';
|
||||||
|
default:
|
||||||
|
return 'Recurring';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return 'Recurring';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n: number): string {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return s[(v - 20) % 10] || s[v] || s[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python weekday: 0=Monday, 6=Sunday
|
||||||
|
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface CreateDefaults {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
templateData?: Partial<CalendarEvent>;
|
||||||
|
templateName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventDetailPanelProps {
|
||||||
|
event: CalendarEvent | null;
|
||||||
|
isCreating?: boolean;
|
||||||
|
createDefaults?: CreateDefaults | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
locationName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditState {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
start_datetime: string;
|
||||||
|
end_datetime: string;
|
||||||
|
all_day: boolean;
|
||||||
|
location_id: string;
|
||||||
|
calendar_id: string;
|
||||||
|
is_starred: boolean;
|
||||||
|
recurrence_type: string;
|
||||||
|
recurrence_interval: number;
|
||||||
|
recurrence_weekday: number;
|
||||||
|
recurrence_week: number;
|
||||||
|
recurrence_day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditStateFromEvent(event: CalendarEvent): EditState {
|
||||||
|
const rule = parseRecurrenceRule(event.recurrence_rule);
|
||||||
|
const isAllDay = event.all_day;
|
||||||
|
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime;
|
||||||
|
return {
|
||||||
|
title: event.title,
|
||||||
|
description: event.description || '',
|
||||||
|
start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'),
|
||||||
|
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
|
||||||
|
all_day: isAllDay,
|
||||||
|
location_id: event.location_id?.toString() || '',
|
||||||
|
calendar_id: event.calendar_id?.toString() || '',
|
||||||
|
is_starred: event.is_starred || false,
|
||||||
|
recurrence_type: rule?.type || '',
|
||||||
|
recurrence_interval: rule?.interval || 2,
|
||||||
|
recurrence_weekday: rule?.weekday ?? 1,
|
||||||
|
recurrence_week: rule?.week || 1,
|
||||||
|
recurrence_day: rule?.day || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState {
|
||||||
|
const source = defaults?.templateData;
|
||||||
|
const isAllDay = source?.all_day ?? defaults?.allDay ?? false;
|
||||||
|
const defaultStart = nowLocal();
|
||||||
|
const defaultEnd = plusOneHour(defaultStart);
|
||||||
|
const rawStart = defaults?.start || defaultStart;
|
||||||
|
const rawEnd = defaults?.end || defaultEnd;
|
||||||
|
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
|
||||||
|
const rule = parseRecurrenceRule(source?.recurrence_rule);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: source?.title || '',
|
||||||
|
description: source?.description || '',
|
||||||
|
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
|
||||||
|
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
|
||||||
|
all_day: isAllDay,
|
||||||
|
location_id: source?.location_id?.toString() || '',
|
||||||
|
calendar_id: source?.calendar_id?.toString() || defaultCalendarId,
|
||||||
|
is_starred: source?.is_starred || false,
|
||||||
|
recurrence_type: rule?.type || '',
|
||||||
|
recurrence_interval: rule?.interval || 2,
|
||||||
|
recurrence_weekday: rule?.weekday ?? 1,
|
||||||
|
recurrence_week: rule?.week || 1,
|
||||||
|
recurrence_day: rule?.day || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecurrencePayload(state: EditState): RecurrenceRule | null {
|
||||||
|
if (!state.recurrence_type) return null;
|
||||||
|
switch (state.recurrence_type) {
|
||||||
|
case 'every_n_days':
|
||||||
|
return { type: 'every_n_days', interval: state.recurrence_interval };
|
||||||
|
case 'weekly':
|
||||||
|
return { type: 'weekly' };
|
||||||
|
case 'monthly_nth_weekday':
|
||||||
|
return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday };
|
||||||
|
case 'monthly_date':
|
||||||
|
return { type: 'monthly_date', day: state.recurrence_day };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
|
||||||
|
export default function EventDetailPanel({
|
||||||
|
event,
|
||||||
|
isCreating = false,
|
||||||
|
createDefaults,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
locationName,
|
||||||
|
}: EventDetailPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: calendars = [] } = useCalendars();
|
||||||
|
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
||||||
|
const defaultCalendar = calendars.find((c) => c.is_default);
|
||||||
|
|
||||||
|
const { data: locations = [] } = useQuery({
|
||||||
|
queryKey: ['locations'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<LocationType[]>('/locations');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editState, setEditState] = useState<EditState>(() =>
|
||||||
|
isCreating
|
||||||
|
? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')
|
||||||
|
: event
|
||||||
|
? buildEditStateFromEvent(event)
|
||||||
|
: buildCreateState(null, defaultCalendar?.id?.toString() || '')
|
||||||
|
);
|
||||||
|
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
|
||||||
|
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
||||||
|
const [locationSearch, setLocationSearch] = useState('');
|
||||||
|
|
||||||
|
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
|
||||||
|
|
||||||
|
// Reset state when event changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setScopeStep(null);
|
||||||
|
setEditScope(null);
|
||||||
|
setLocationSearch('');
|
||||||
|
if (event) setEditState(buildEditStateFromEvent(event));
|
||||||
|
}, [event?.id]);
|
||||||
|
|
||||||
|
// Enter edit mode when creating
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreating) {
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || ''));
|
||||||
|
setLocationSearch('');
|
||||||
|
}
|
||||||
|
}, [isCreating, createDefaults]);
|
||||||
|
|
||||||
|
// Initialize location search text from existing location
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && !isCreating && event?.location_id) {
|
||||||
|
const loc = locations.find((l) => l.id === event.location_id);
|
||||||
|
if (loc) setLocationSearch(loc.name);
|
||||||
|
}
|
||||||
|
}, [isEditing, isCreating, event?.location_id, locations]);
|
||||||
|
|
||||||
|
const invalidateAll = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
// --- Mutations ---
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (data: EditState) => {
|
||||||
|
const rule = buildRecurrencePayload(data);
|
||||||
|
let endDt = data.end_datetime;
|
||||||
|
if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt);
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description || null,
|
||||||
|
start_datetime: data.start_datetime,
|
||||||
|
end_datetime: endDt,
|
||||||
|
all_day: data.all_day,
|
||||||
|
location_id: data.location_id ? parseInt(data.location_id) : null,
|
||||||
|
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
|
||||||
|
is_starred: data.is_starred,
|
||||||
|
recurrence_rule: rule,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event && !isCreating) {
|
||||||
|
if (editScope) payload.edit_scope = editScope;
|
||||||
|
return api.put(`/events/${event.id}`, payload);
|
||||||
|
} else {
|
||||||
|
return api.post('/events', payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success(isCreating ? 'Event created' : 'Event updated');
|
||||||
|
if (isCreating) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditScope(null);
|
||||||
|
}
|
||||||
|
onSaved?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const scope = editScope ? `?scope=${editScope}` : '';
|
||||||
|
await api.delete(`/events/${event!.id}${scope}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Event deleted');
|
||||||
|
onClose();
|
||||||
|
onDeleted?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
||||||
|
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
const handleEditStart = () => {
|
||||||
|
if (isRecurring) {
|
||||||
|
setScopeStep('edit');
|
||||||
|
} else {
|
||||||
|
if (event) setEditState(buildEditStateFromEvent(event));
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScopeSelect = (scope: 'this' | 'this_and_future') => {
|
||||||
|
setEditScope(scope);
|
||||||
|
if (scopeStep === 'edit') {
|
||||||
|
if (event) setEditState(buildEditStateFromEvent(event));
|
||||||
|
setIsEditing(true);
|
||||||
|
setScopeStep(null);
|
||||||
|
} else if (scopeStep === 'delete') {
|
||||||
|
// Delete with scope — execute immediately
|
||||||
|
setScopeStep(null);
|
||||||
|
// The deleteMutation will read editScope, but we need to set it first
|
||||||
|
// Since setState is async, use the mutation directly with the scope
|
||||||
|
const scopeParam = `?scope=${scope}`;
|
||||||
|
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Event(s) deleted');
|
||||||
|
onClose();
|
||||||
|
onDeleted?.();
|
||||||
|
}).catch((error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditScope(null);
|
||||||
|
setLocationSearch('');
|
||||||
|
if (isCreating) {
|
||||||
|
onClose();
|
||||||
|
} else if (event) {
|
||||||
|
setEditState(buildEditStateFromEvent(event));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
saveMutation.mutate(editState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteStart = () => {
|
||||||
|
if (isRecurring) {
|
||||||
|
setScopeStep('delete');
|
||||||
|
} else {
|
||||||
|
handleDeleteClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Render helpers ---
|
||||||
|
|
||||||
|
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
|
||||||
|
setEditState((s) => ({ ...s, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (!event && !isCreating) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<Calendar className="h-8 w-8 mb-3 opacity-40" />
|
||||||
|
<p className="text-sm">Select an event to view details</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View mode data
|
||||||
|
const startDate = event ? parseISO(event.start_datetime) : null;
|
||||||
|
const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null;
|
||||||
|
const startStr = startDate
|
||||||
|
? event!.all_day
|
||||||
|
? format(startDate, 'EEEE, MMMM d, yyyy')
|
||||||
|
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a')
|
||||||
|
: '';
|
||||||
|
const endStr = endDate
|
||||||
|
? event!.all_day
|
||||||
|
? format(endDate, 'EEEE, MMMM d, yyyy')
|
||||||
|
: format(endDate, 'h:mm a')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const panelTitle = isCreating
|
||||||
|
? createDefaults?.templateName
|
||||||
|
? `New Event from ${createDefaults.templateName}`
|
||||||
|
: 'New Event'
|
||||||
|
: event?.title || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isCreating ? (
|
||||||
|
<h3 className="font-heading text-lg font-semibold">{panelTitle}</h3>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={editState.title}
|
||||||
|
onChange={(e) => updateField('title', e.target.value)}
|
||||||
|
className="h-8 text-base font-semibold"
|
||||||
|
placeholder="Event title"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : scopeStep ? (
|
||||||
|
<h3 className="font-heading text-sm font-semibold">
|
||||||
|
{scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
|
||||||
|
</h3>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-heading text-lg font-semibold truncate">{event?.title}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{event?.calendar_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{scopeStep ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setScopeStep(null)}
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-green-400 hover:text-green-300"
|
||||||
|
onClick={handleEditSave}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!event?.is_virtual && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleEditStart}
|
||||||
|
title="Edit event"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDeleteStart}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||||
|
title="Confirm delete"
|
||||||
|
>
|
||||||
|
Sure?
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={handleDeleteStart}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
title="Delete event"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close panel"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||||
|
{scopeStep ? (
|
||||||
|
/* Scope selection step */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This is a recurring event. How would you like to proceed?
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleScopeSelect('this')}
|
||||||
|
>
|
||||||
|
This event only
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => handleScopeSelect('this_and_future')}
|
||||||
|
>
|
||||||
|
This and all future events
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => setScopeStep(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isEditing ? (
|
||||||
|
/* Edit / Create mode */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title (only shown in body for create mode; edit mode has it in header) */}
|
||||||
|
{isCreating && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-title" required>Title</Label>
|
||||||
|
<Input
|
||||||
|
id="panel-title"
|
||||||
|
value={editState.title}
|
||||||
|
onChange={(e) => updateField('title', e.target.value)}
|
||||||
|
placeholder="Event title"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="panel-desc"
|
||||||
|
value={editState.description}
|
||||||
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
rows={3}
|
||||||
|
className="text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="panel-allday"
|
||||||
|
checked={editState.all_day}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
updateField('all_day', checked);
|
||||||
|
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
|
||||||
|
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="panel-allday">All day event</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-start" required>Start</Label>
|
||||||
|
<Input
|
||||||
|
id="panel-start"
|
||||||
|
type={editState.all_day ? 'date' : 'datetime-local'}
|
||||||
|
value={editState.start_datetime}
|
||||||
|
onChange={(e) => updateField('start_datetime', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-end">End</Label>
|
||||||
|
<Input
|
||||||
|
id="panel-end"
|
||||||
|
type={editState.all_day ? 'date' : 'datetime-local'}
|
||||||
|
value={editState.end_datetime}
|
||||||
|
onChange={(e) => updateField('end_datetime', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-calendar">Calendar</Label>
|
||||||
|
<Select
|
||||||
|
id="panel-calendar"
|
||||||
|
value={editState.calendar_id}
|
||||||
|
onChange={(e) => updateField('calendar_id', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{selectableCalendars.map((cal) => (
|
||||||
|
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-location">Location</Label>
|
||||||
|
<LocationPicker
|
||||||
|
id="panel-location"
|
||||||
|
value={locationSearch}
|
||||||
|
onChange={(val) => {
|
||||||
|
setLocationSearch(val);
|
||||||
|
if (!val) updateField('location_id', '');
|
||||||
|
}}
|
||||||
|
onSelect={async (result) => {
|
||||||
|
if (result.source === 'local' && result.location_id) {
|
||||||
|
updateField('location_id', result.location_id.toString());
|
||||||
|
} else if (result.source === 'nominatim') {
|
||||||
|
try {
|
||||||
|
const { data: newLoc } = await api.post('/locations', {
|
||||||
|
name: result.name,
|
||||||
|
address: result.address,
|
||||||
|
category: 'other',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||||
|
updateField('location_id', newLoc.id.toString());
|
||||||
|
toast.success(`Location "${result.name}" created`);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to create location');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search location..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||||
|
<Select
|
||||||
|
id="panel-recurrence"
|
||||||
|
value={editState.recurrence_type}
|
||||||
|
onChange={(e) => updateField('recurrence_type', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="every_n_days">Every X days</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
|
||||||
|
<option value="monthly_date">Monthly (date)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editState.recurrence_type === 'every_n_days' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-interval">Every how many days?</Label>
|
||||||
|
<Input
|
||||||
|
id="panel-interval"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={editState.recurrence_interval}
|
||||||
|
onChange={(e) => updateField('recurrence_interval', parseInt(e.target.value) || 1)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editState.recurrence_type === 'weekly' && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Repeats every week on the same day as the start date.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editState.recurrence_type === 'monthly_nth_weekday' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-week">Week of month</Label>
|
||||||
|
<Select
|
||||||
|
id="panel-week"
|
||||||
|
value={editState.recurrence_week.toString()}
|
||||||
|
onChange={(e) => updateField('recurrence_week', parseInt(e.target.value))}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<option value="1">1st</option>
|
||||||
|
<option value="2">2nd</option>
|
||||||
|
<option value="3">3rd</option>
|
||||||
|
<option value="4">4th</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-weekday">Day of week</Label>
|
||||||
|
<Select
|
||||||
|
id="panel-weekday"
|
||||||
|
value={editState.recurrence_weekday.toString()}
|
||||||
|
onChange={(e) => updateField('recurrence_weekday', parseInt(e.target.value))}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{WEEKDAYS.map((name, i) => (
|
||||||
|
<option key={i} value={i}>{name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editState.recurrence_type === 'monthly_date' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="panel-day">Day of month</Label>
|
||||||
|
<Input
|
||||||
|
id="panel-day"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
value={editState.recurrence_day}
|
||||||
|
onChange={(e) => updateField('recurrence_day', parseInt(e.target.value) || 1)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="panel-starred"
|
||||||
|
checked={editState.is_starred}
|
||||||
|
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="panel-starred">Star this event</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save / Cancel buttons at bottom of form */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleEditCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* View mode */
|
||||||
|
<>
|
||||||
|
{/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Calendar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
Calendar
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{event?.calendar_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Starred */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
Starred
|
||||||
|
</div>
|
||||||
|
{event?.is_starred ? (
|
||||||
|
<p className="text-sm text-amber-200/90">Starred</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Start
|
||||||
|
</div>
|
||||||
|
<CopyableField value={startStr} icon={Clock} label="Start time" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
End
|
||||||
|
</div>
|
||||||
|
{endStr ? (
|
||||||
|
<CopyableField value={endStr} icon={Clock} label="End time" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
Location
|
||||||
|
</div>
|
||||||
|
{locationName ? (
|
||||||
|
<CopyableField value={locationName} icon={MapPin} label="Location" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
Recurrence
|
||||||
|
</div>
|
||||||
|
{isRecurring && event?.recurrence_rule ? (
|
||||||
|
<p className="text-sm">{formatRecurrenceRule(event.recurrence_rule)}</p>
|
||||||
|
) : isRecurring ? (
|
||||||
|
<p className="text-sm">Recurring event</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description — full width */}
|
||||||
|
{event?.description && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<AlignLeft className="h-3 w-3" />
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Updated at */}
|
||||||
|
{event && !event.is_virtual && (
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatUpdatedAt(event.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Calendar } from 'lucide-react';
|
import { Calendar } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
@ -17,6 +18,9 @@ interface CalendarWidgetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const todayStr = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -37,7 +41,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
|||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
|
||||||
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { differenceInCalendarDays } from 'date-fns';
|
import { differenceInCalendarDays, format } from 'date-fns';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
|
|
||||||
interface CountdownWidgetProps {
|
interface CountdownWidgetProps {
|
||||||
@ -10,6 +11,7 @@ interface CountdownWidgetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CountdownWidget({ events }: CountdownWidgetProps) {
|
export default function CountdownWidget({ events }: CountdownWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
|
const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
|
||||||
if (visible.length === 0) return null;
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
@ -18,8 +20,13 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
|
|||||||
{visible.map((event) => {
|
{visible.map((event) => {
|
||||||
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
|
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
|
||||||
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
|
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
|
||||||
|
const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd');
|
||||||
return (
|
return (
|
||||||
<div key={event.id} className="flex items-center gap-2">
|
<div
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })}
|
||||||
|
className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150"
|
||||||
|
>
|
||||||
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
|
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||||
<span className="text-sm text-amber-200/90 truncate">
|
<span className="text-sm text-amber-200/90 truncate">
|
||||||
<span className="font-semibold tabular-nums">{label}</span>
|
<span className="font-semibold tabular-nums">{label}</span>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
|
import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
|
||||||
@ -33,6 +34,7 @@ function getGreeting(name?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
|
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
|
||||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||||
@ -234,7 +236,8 @@ export default function DashboardPage() {
|
|||||||
{futureReminders.map((reminder) => (
|
{futureReminders.map((reminder) => (
|
||||||
<div
|
<div
|
||||||
key={reminder.id}
|
key={reminder.id}
|
||||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
onClick={() => navigate('/reminders', { state: { reminderId: reminder.id } })}
|
||||||
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
|
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
|
||||||
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>
|
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
|
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
@ -11,6 +12,8 @@ interface StatsWidgetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
|
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
label: 'PROJECTS',
|
label: 'PROJECTS',
|
||||||
@ -18,6 +21,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
|
|||||||
icon: FolderKanban,
|
icon: FolderKanban,
|
||||||
color: 'text-blue-400',
|
color: 'text-blue-400',
|
||||||
glowBg: 'bg-blue-500/10',
|
glowBg: 'bg-blue-500/10',
|
||||||
|
onClick: () => navigate('/projects'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'IN PROGRESS',
|
label: 'IN PROGRESS',
|
||||||
@ -25,6 +29,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
|
|||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
color: 'text-purple-400',
|
color: 'text-purple-400',
|
||||||
glowBg: 'bg-purple-500/10',
|
glowBg: 'bg-purple-500/10',
|
||||||
|
onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'OPEN TODOS',
|
label: 'OPEN TODOS',
|
||||||
@ -32,13 +37,18 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
|
|||||||
icon: CheckSquare,
|
icon: CheckSquare,
|
||||||
color: 'text-teal-400',
|
color: 'text-teal-400',
|
||||||
glowBg: 'bg-teal-500/10',
|
glowBg: 'bg-teal-500/10',
|
||||||
|
onClick: () => navigate('/todos'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
|
||||||
{statCards.map((stat) => (
|
{statCards.map((stat) => (
|
||||||
<Card key={stat.label} className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
<Card
|
||||||
|
key={stat.label}
|
||||||
|
className="bg-gradient-to-br from-accent/[0.03] to-transparent cursor-pointer group transition-transform duration-150 hover:scale-[1.01]"
|
||||||
|
onClick={stat.onClick}
|
||||||
|
>
|
||||||
<CardContent className="px-3 py-2">
|
<CardContent className="px-3 py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { format, isPast, endOfDay } from 'date-fns';
|
import { format, isPast, endOfDay } from 'date-fns';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -29,6 +30,8 @@ const dotColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TodoWidget({ todos }: TodoWidgetProps) {
|
export default function TodoWidget({ todos }: TodoWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -51,7 +54,8 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
|
||||||
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
|
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
|
||||||
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>
|
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react';
|
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react';
|
||||||
import type { UpcomingItem } from '@/types';
|
import type { UpcomingItem } from '@/types';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -17,6 +18,26 @@ const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; labe
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
|
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleItemClick = (item: UpcomingItem) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'event': {
|
||||||
|
const dateStr = item.datetime
|
||||||
|
? format(new Date(item.datetime), 'yyyy-MM-dd')
|
||||||
|
: item.date;
|
||||||
|
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: item.id } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'todo':
|
||||||
|
navigate('/todos', { state: { todoId: item.id } });
|
||||||
|
break;
|
||||||
|
case 'reminder':
|
||||||
|
navigate('/reminders', { state: { reminderId: item.id } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -44,7 +65,8 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${item.type}-${item.id}-${index}`}
|
key={`${item.type}-${item.id}-${index}`}
|
||||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
onClick={() => handleItemClick(item)}
|
||||||
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
|
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
|
||||||
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
|
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns';
|
import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns';
|
||||||
import type { UpcomingItem } from '@/types';
|
import type { UpcomingItem } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -14,6 +15,7 @@ const typeColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function WeekTimeline({ items }: WeekTimelineProps) {
|
export default function WeekTimeline({ items }: WeekTimelineProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const today = useMemo(() => startOfDay(new Date()), []);
|
const today = useMemo(() => startOfDay(new Date()), []);
|
||||||
const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]);
|
const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]);
|
||||||
|
|
||||||
@ -41,12 +43,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
|
|||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<div
|
<div
|
||||||
key={day.key}
|
key={day.key}
|
||||||
|
onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border',
|
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border cursor-pointer',
|
||||||
day.isToday
|
day.isToday
|
||||||
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
|
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
|
||||||
: day.isPast
|
: day.isPast
|
||||||
? 'border-transparent opacity-50'
|
? 'border-transparent opacity-50 hover:opacity-75'
|
||||||
: 'border-transparent hover:border-border/50'
|
: 'border-transparent hover:border-border/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -10,7 +10,10 @@ import LockOverlay from './LockOverlay';
|
|||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
useTheme();
|
useTheme();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
|
||||||
|
catch { return false; }
|
||||||
|
});
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -19,7 +22,11 @@ export default function AppLayout() {
|
|||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggle={() => setCollapsed(!collapsed)}
|
onToggle={() => {
|
||||||
|
const next = !collapsed;
|
||||||
|
setCollapsed(next);
|
||||||
|
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
|
||||||
|
}}
|
||||||
mobileOpen={mobileOpen}
|
mobileOpen={mobileOpen}
|
||||||
onMobileClose={() => setMobileOpen(false)}
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useLock } from '@/hooks/useLock';
|
import { useLock } from '@/hooks/useLock';
|
||||||
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
@ -57,10 +58,12 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
select: (data) => data.map(({ id, name }) => ({ id, name })),
|
select: (data) => data.map(({ id, name }) => ({ id, name })),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const doLogout = useCallback(async () => {
|
||||||
await logout();
|
await logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
}, [logout, navigate]);
|
||||||
|
|
||||||
|
const { confirming: logoutConfirming, handleClick: handleLogout } = useConfirmAction(doLogout);
|
||||||
|
|
||||||
const isProjectsActive = location.pathname.startsWith('/projects');
|
const isProjectsActive = location.pathname.startsWith('/projects');
|
||||||
const showExpanded = !collapsed || mobileOpen;
|
const showExpanded = !collapsed || mobileOpen;
|
||||||
@ -200,10 +203,15 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
logoutConfirming
|
||||||
|
? 'bg-destructive/15 text-destructive'
|
||||||
|
: 'text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<LogOut className="h-5 w-5 shrink-0" />
|
<LogOut className="h-5 w-5 shrink-0" />
|
||||||
{showExpanded && <span>Logout</span>}
|
{showExpanded && <span>{logoutConfirming ? 'Sure?' : 'Logout'}</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { Plus, MapPin, Phone, Mail } from 'lucide-react';
|
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
@ -240,11 +240,11 @@ export default function LocationsPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const panelFields: PanelField[] = [
|
const panelFields: PanelField[] = [
|
||||||
{ label: 'Address', key: 'address', copyable: true, icon: MapPin },
|
|
||||||
{ label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone },
|
{ label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone },
|
||||||
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
||||||
{ label: 'Category', key: 'category' },
|
{ label: 'Category', key: 'category', icon: Tag },
|
||||||
{ label: 'Notes', key: 'notes', multiline: true },
|
{ label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true },
|
||||||
|
{ label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderPanel = () => (
|
const renderPanel = () => (
|
||||||
@ -283,7 +283,7 @@ export default function LocationsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { Plus, Users, Star, Cake, Phone, Mail, MapPin } from 'lucide-react';
|
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format, parseISO, differenceInYears } from 'date-fns';
|
import { format, parseISO, differenceInYears } from 'date-fns';
|
||||||
@ -173,12 +173,12 @@ const panelFields: PanelField[] = [
|
|||||||
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
|
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
|
||||||
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
|
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
|
||||||
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
||||||
{ label: 'Address', key: 'address', copyable: true, icon: MapPin },
|
{ label: 'Birthday', key: 'birthday_display', icon: Cake },
|
||||||
{ label: 'Birthday', key: 'birthday_display' },
|
{ label: 'Category', key: 'category', icon: Tag },
|
||||||
{ label: 'Category', key: 'category' },
|
{ label: 'Company', key: 'company', icon: Building2 },
|
||||||
{ label: 'Company', key: 'company' },
|
{ label: 'Job Title', key: 'job_title', icon: Briefcase },
|
||||||
{ label: 'Job Title', key: 'job_title' },
|
{ label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true },
|
||||||
{ label: 'Notes', key: 'notes', multiline: true },
|
{ label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -237,6 +237,9 @@ export default function PeoplePage() {
|
|||||||
list = list.filter(
|
list = list.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.name.toLowerCase().includes(q) ||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.first_name?.toLowerCase().includes(q) ||
|
||||||
|
p.last_name?.toLowerCase().includes(q) ||
|
||||||
|
p.nickname?.toLowerCase().includes(q) ||
|
||||||
p.email?.toLowerCase().includes(q) ||
|
p.email?.toLowerCase().includes(q) ||
|
||||||
p.mobile?.toLowerCase().includes(q) ||
|
p.mobile?.toLowerCase().includes(q) ||
|
||||||
p.phone?.toLowerCase().includes(q) ||
|
p.phone?.toLowerCase().includes(q) ||
|
||||||
@ -400,10 +403,11 @@ export default function PeoplePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<CategoryFilterBar
|
<CategoryFilterBar
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
pinnedLabel="Favourites"
|
pinnedLabel="Favourites"
|
||||||
@ -417,7 +421,7 @@ export default function PeoplePage() {
|
|||||||
searchValue={search}
|
searchValue={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1" />
|
</div>
|
||||||
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
|
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Person
|
Add Person
|
||||||
|
|||||||
@ -344,7 +344,7 @@ export default function ProjectDetail() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
@ -373,7 +373,7 @@ export default function ProjectDetail() {
|
|||||||
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
|
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2 } from 'lucide-react';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2, Search } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { GridSkeleton } from '@/components/ui/skeleton';
|
import { GridSkeleton } from '@/components/ui/skeleton';
|
||||||
import { EmptyState } from '@/components/ui/empty-state';
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
@ -18,9 +20,20 @@ const statusFilters = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
const location = useLocation();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Handle navigation state from dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { filter?: string } | null;
|
||||||
|
if (state?.filter) {
|
||||||
|
setStatusFilter(state.filter);
|
||||||
|
window.history.replaceState({}, '');
|
||||||
|
}
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
const { data: projects = [], isLoading } = useQuery({
|
const { data: projects = [], isLoading } = useQuery({
|
||||||
queryKey: ['projects'],
|
queryKey: ['projects'],
|
||||||
@ -30,9 +43,16 @@ export default function ProjectsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredProjects = statusFilter
|
const filteredProjects = useMemo(() => {
|
||||||
? projects.filter((p) => p.status === statusFilter)
|
let list = statusFilter ? projects.filter((p) => p.status === statusFilter) : projects;
|
||||||
: projects;
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(p) => p.name.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [projects, statusFilter, search]);
|
||||||
|
|
||||||
const inProgressCount = projects.filter((p) => p.status === 'in_progress').length;
|
const inProgressCount = projects.filter((p) => p.status === 'in_progress').length;
|
||||||
const completedCount = projects.filter((p) => p.status === 'completed').length;
|
const completedCount = projects.filter((p) => p.status === 'completed').length;
|
||||||
@ -48,7 +68,7 @@ export default function ProjectsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
|
||||||
@ -75,6 +95,16 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-52 h-8 pl-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => setShowForm(true)} size="sm">
|
<Button onClick={() => setShowForm(true)} size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New Project
|
New Project
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Calendar, User, Flag, Activity, Send, X, Save,
|
Calendar, User, Flag, Activity, Send, X, Save,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||||
import type { ProjectTask, TaskComment, Person } from '@/types';
|
import type { ProjectTask, TaskComment, Person } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -558,6 +559,15 @@ export default function TaskDetailPanel({
|
|||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Updated at footer */}
|
||||||
|
{task.updated_at && (
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatUpdatedAt(task.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
473
frontend/src/components/reminders/ReminderDetailPanel.tsx
Normal file
473
frontend/src/components/reminders/ReminderDetailPanel.tsx
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { format, parseISO, isPast, isToday } from 'date-fns';
|
||||||
|
import {
|
||||||
|
X, Pencil, Trash2, Save, Bell, BellOff, Clock, Repeat, AlertCircle, AlignLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Reminder } from '@/types';
|
||||||
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||||
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface ReminderDetailPanelProps {
|
||||||
|
reminder: Reminder | null;
|
||||||
|
isCreating?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditState {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
remind_at: string;
|
||||||
|
recurrence_rule: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurrenceLabels: Record<string, string> = {
|
||||||
|
daily: 'Daily',
|
||||||
|
weekly: 'Weekly',
|
||||||
|
monthly: 'Monthly',
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
|
||||||
|
|
||||||
|
function buildEditState(reminder: Reminder): EditState {
|
||||||
|
return {
|
||||||
|
title: reminder.title,
|
||||||
|
description: reminder.description || '',
|
||||||
|
remind_at: reminder.remind_at ? reminder.remind_at.slice(0, 16) : '',
|
||||||
|
recurrence_rule: reminder.recurrence_rule || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateState(): EditState {
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
remind_at: '',
|
||||||
|
recurrence_rule: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
|
||||||
|
export default function ReminderDetailPanel({
|
||||||
|
reminder,
|
||||||
|
isCreating = false,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: ReminderDetailPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editState, setEditState] = useState<EditState>(() =>
|
||||||
|
isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset state when reminder changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (reminder) setEditState(buildEditState(reminder));
|
||||||
|
}, [reminder?.id]);
|
||||||
|
|
||||||
|
// Enter edit mode when creating
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreating) {
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditState(buildCreateState());
|
||||||
|
}
|
||||||
|
}, [isCreating]);
|
||||||
|
|
||||||
|
const invalidateAll = useCallback(() => {
|
||||||
|
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
// --- Mutations ---
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (data: EditState) => {
|
||||||
|
const payload = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description || null,
|
||||||
|
remind_at: data.remind_at || null,
|
||||||
|
recurrence_rule: data.recurrence_rule || null,
|
||||||
|
};
|
||||||
|
if (reminder && !isCreating) {
|
||||||
|
return api.put(`/reminders/${reminder.id}`, payload);
|
||||||
|
} else {
|
||||||
|
return api.post('/reminders', payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success(isCreating ? 'Reminder created' : 'Reminder updated');
|
||||||
|
if (isCreating) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
onSaved?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, isCreating ? 'Failed to create reminder' : 'Failed to update reminder'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.patch(`/reminders/${reminder!.id}/dismiss`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Reminder dismissed');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to dismiss reminder');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.delete(`/reminders/${reminder!.id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Reminder deleted');
|
||||||
|
onClose();
|
||||||
|
onDeleted?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to delete reminder'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
||||||
|
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
const handleEditStart = () => {
|
||||||
|
if (reminder) setEditState(buildEditState(reminder));
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (isCreating) {
|
||||||
|
onClose();
|
||||||
|
} else if (reminder) {
|
||||||
|
setEditState(buildEditState(reminder));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
saveMutation.mutate(editState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
|
||||||
|
setEditState((s) => ({ ...s, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (!reminder && !isCreating) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<Bell className="h-8 w-8 mb-3 opacity-40" />
|
||||||
|
<p className="text-sm">Select a reminder to view details</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View data
|
||||||
|
const remindDate = reminder?.remind_at ? parseISO(reminder.remind_at) : null;
|
||||||
|
const isOverdue = !reminder?.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate);
|
||||||
|
const isDueToday = remindDate ? isToday(remindDate) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
{isEditing && !isCreating ? (
|
||||||
|
<Input
|
||||||
|
value={editState.title}
|
||||||
|
onChange={(e) => updateField('title', e.target.value)}
|
||||||
|
className="h-8 text-base font-semibold flex-1"
|
||||||
|
placeholder="Reminder title"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : isCreating ? (
|
||||||
|
<h3 className="font-heading text-lg font-semibold">New Reminder</h3>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<Bell
|
||||||
|
className={`h-4 w-4 shrink-0 ${
|
||||||
|
isOverdue ? 'text-red-400' : reminder!.is_dismissed ? 'text-muted-foreground' : 'text-orange-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<h3 className={`font-heading text-lg font-semibold truncate ${reminder!.is_dismissed ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
{reminder!.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-green-400 hover:text-green-300"
|
||||||
|
onClick={handleEditSave}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!reminder!.is_dismissed && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
|
||||||
|
onClick={() => dismissMutation.mutate()}
|
||||||
|
disabled={dismissMutation.isPending}
|
||||||
|
title="Dismiss reminder"
|
||||||
|
>
|
||||||
|
<BellOff className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleEditStart}
|
||||||
|
title="Edit reminder"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||||
|
title="Confirm delete"
|
||||||
|
>
|
||||||
|
Sure?
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
title="Delete reminder"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close panel"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||||
|
{isEditing ? (
|
||||||
|
/* Edit / Create mode */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isCreating && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-title" required>Title</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-title"
|
||||||
|
value={editState.title}
|
||||||
|
onChange={(e) => updateField('title', e.target.value)}
|
||||||
|
placeholder="Reminder title"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reminder-desc"
|
||||||
|
value={editState.description}
|
||||||
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
rows={3}
|
||||||
|
className="text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-at">Remind At</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-at"
|
||||||
|
type="datetime-local"
|
||||||
|
value={editState.remind_at}
|
||||||
|
onChange={(e) => updateField('remind_at', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-recurrence">Recurrence</Label>
|
||||||
|
<Select
|
||||||
|
id="reminder-recurrence"
|
||||||
|
value={editState.recurrence_rule}
|
||||||
|
onChange={(e) => updateField('recurrence_rule', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save / Cancel at bottom */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleEditCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* View mode */
|
||||||
|
<>
|
||||||
|
{/* 2-column grid: Status, Recurrence, Remind At, Snoozed Until */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Status */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
{reminder!.is_dismissed ? (
|
||||||
|
<BellOff className="h-3 w-3" />
|
||||||
|
) : isOverdue ? (
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Bell className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
{reminder!.is_dismissed ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Dismissed</p>
|
||||||
|
) : isOverdue ? (
|
||||||
|
<p className="text-sm text-red-400">Overdue</p>
|
||||||
|
) : isDueToday ? (
|
||||||
|
<p className="text-sm text-yellow-400">Due today</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-orange-400">Active</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
Recurrence
|
||||||
|
</div>
|
||||||
|
{reminder!.recurrence_rule ? (
|
||||||
|
<p className="text-sm">{recurrenceLabels[reminder!.recurrence_rule] || reminder!.recurrence_rule}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remind At */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Remind At
|
||||||
|
</div>
|
||||||
|
{remindDate ? (
|
||||||
|
<CopyableField
|
||||||
|
value={format(remindDate, 'EEEE, MMMM d, yyyy · h:mm a')}
|
||||||
|
icon={Clock}
|
||||||
|
label="Remind at"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snoozed Until */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Snoozed Until
|
||||||
|
</div>
|
||||||
|
{reminder!.snoozed_until ? (
|
||||||
|
<p className="text-sm">{format(parseISO(reminder!.snoozed_until), 'MMM d, h:mm a')}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description — full width */}
|
||||||
|
{reminder!.description && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<AlignLeft className="h-3 w-3" />
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
|
||||||
|
{reminder!.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Updated at */}
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatUpdatedAt(reminder!.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { isPast, isToday, parseISO } from 'date-fns';
|
import { isPast, isToday, parseISO } from 'date-fns';
|
||||||
@ -9,7 +10,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||||
import ReminderList from './ReminderList';
|
import ReminderList from './ReminderList';
|
||||||
import ReminderForm from './ReminderForm';
|
import ReminderDetailPanel from './ReminderDetailPanel';
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'active', label: 'Active' },
|
||||||
@ -20,11 +21,25 @@ const statusFilters = [
|
|||||||
type StatusFilter = (typeof statusFilters)[number]['value'];
|
type StatusFilter = (typeof statusFilters)[number]['value'];
|
||||||
|
|
||||||
export default function RemindersPage() {
|
export default function RemindersPage() {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const location = useLocation();
|
||||||
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
|
|
||||||
|
// Panel state
|
||||||
|
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
||||||
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||||
|
|
||||||
const [filter, setFilter] = useState<StatusFilter>('active');
|
const [filter, setFilter] = useState<StatusFilter>('active');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Handle navigation state from dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { reminderId?: number } | null;
|
||||||
|
if (state?.reminderId) {
|
||||||
|
setSelectedReminderId(state.reminderId);
|
||||||
|
setPanelMode('view');
|
||||||
|
window.history.replaceState({}, '');
|
||||||
|
}
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
const { data: reminders = [], isLoading } = useQuery({
|
const { data: reminders = [], isLoading } = useQuery({
|
||||||
queryKey: ['reminders'],
|
queryKey: ['reminders'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@ -50,18 +65,39 @@ export default function RemindersPage() {
|
|||||||
).length;
|
).length;
|
||||||
const dismissedCount = reminders.filter((r) => r.is_dismissed).length;
|
const dismissedCount = reminders.filter((r) => r.is_dismissed).length;
|
||||||
|
|
||||||
const handleEdit = (reminder: Reminder) => {
|
const panelOpen = panelMode !== 'closed';
|
||||||
setEditingReminder(reminder);
|
const selectedReminder = useMemo(
|
||||||
setShowForm(true);
|
() => reminders.find((r) => r.id === selectedReminderId) ?? null,
|
||||||
|
[selectedReminderId, reminders],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (reminder: Reminder) => {
|
||||||
|
setSelectedReminderId(reminder.id);
|
||||||
|
setPanelMode('view');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCreateNew = () => {
|
||||||
setShowForm(false);
|
setSelectedReminderId(null);
|
||||||
setEditingReminder(null);
|
setPanelMode('create');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePanelClose = () => {
|
||||||
|
setPanelMode('closed');
|
||||||
|
setSelectedReminderId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Escape key closes panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelOpen) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') handlePanelClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [panelOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
|
||||||
@ -87,25 +123,32 @@ export default function RemindersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative ml-2">
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-52 h-8 pl-8 text-sm"
|
className="w-52 h-8 pl-8 text-sm ring-inset"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<Button onClick={handleCreateNew} size="sm">
|
||||||
|
|
||||||
<Button onClick={() => setShowForm(true)} size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Reminder
|
Add Reminder
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
{/* Main content — list + detail panel */}
|
||||||
|
<div className="flex-1 overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-5">
|
||||||
{/* Summary stats */}
|
{/* Summary stats */}
|
||||||
{!isLoading && reminders.length > 0 && (
|
{!isLoading && reminders.length > 0 && (
|
||||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||||
@ -156,13 +199,47 @@ export default function RemindersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ReminderList
|
<ReminderList
|
||||||
reminders={filteredReminders}
|
reminders={filteredReminders}
|
||||||
onEdit={handleEdit}
|
onEdit={handleSelect}
|
||||||
onAdd={() => setShowForm(true)}
|
onAdd={handleCreateNew}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showForm && <ReminderForm reminder={editingReminder} onClose={handleCloseForm} />}
|
{/* Detail panel (desktop) */}
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ReminderDetailPanel
|
||||||
|
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||||
|
isCreating={panelMode === 'create'}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile detail panel overlay */}
|
||||||
|
{panelOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
|
onClick={handlePanelClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ReminderDetailPanel
|
||||||
|
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||||
|
isCreating={panelMode === 'create'}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -210,7 +210,7 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
{/* Page header — matches Stage 4-5 pages */}
|
{/* Page header — matches Stage 4-5 pages */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
|
||||||
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />
|
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||||
|
|||||||
@ -118,7 +118,6 @@ export default function CategoryFilterBar({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
}: CategoryFilterBarProps) {
|
}: CategoryFilterBarProps) {
|
||||||
const [otherOpen, setOtherOpen] = useState(false);
|
const [otherOpen, setOtherOpen] = useState(false);
|
||||||
const [searchCollapsed, setSearchCollapsed] = useState(false);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const isAllActive = activeFilters.length === 0;
|
const isAllActive = activeFilters.length === 0;
|
||||||
@ -129,16 +128,6 @@ export default function CategoryFilterBar({
|
|||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collapse search if there are many categories
|
|
||||||
useEffect(() => {
|
|
||||||
setSearchCollapsed(categories.length >= 4);
|
|
||||||
}, [categories.length]);
|
|
||||||
|
|
||||||
const handleExpandSearch = () => {
|
|
||||||
setSearchCollapsed(false);
|
|
||||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id || !onReorderCategories) return;
|
if (!over || active.id === over.id || !onReorderCategories) return;
|
||||||
@ -254,32 +243,18 @@ export default function CategoryFilterBar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
{searchCollapsed ? (
|
<div className="relative shrink-0">
|
||||||
<button
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||||
type="button"
|
|
||||||
onClick={handleExpandSearch}
|
|
||||||
aria-label="Expand search"
|
|
||||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-card-elevated transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="relative transition-all duration-200">
|
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
|
||||||
<Input
|
<Input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
onBlur={() => {
|
className="w-52 h-8 pl-8 text-sm ring-inset"
|
||||||
if (!searchValue && categories.length >= 4) setSearchCollapsed(true);
|
|
||||||
}}
|
|
||||||
className="w-44 h-8 pl-8 text-sm"
|
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface PanelField {
|
|||||||
copyable?: boolean;
|
copyable?: boolean;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityDetailPanelProps<T> {
|
interface EntityDetailPanelProps<T> {
|
||||||
@ -81,30 +82,55 @@ export function EntityDetailPanel<T>({
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||||
{fields.map((field) => {
|
{(() => {
|
||||||
const value = getValue(item, field.key);
|
const gridFields = fields.filter((f) => !f.fullWidth && getValue(item, f.key));
|
||||||
if (!value) return null;
|
const fullWidthFields = fields.filter((f) => f.fullWidth && getValue(item, f.key));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.key}>
|
<>
|
||||||
|
{gridFields.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{gridFields.map((field) => {
|
||||||
|
const value = getValue(item, field.key)!;
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
{field.icon && <field.icon className="h-3 w-3" />}
|
||||||
|
{field.label}
|
||||||
|
</div>
|
||||||
{field.copyable ? (
|
{field.copyable ? (
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
|
||||||
{field.label}
|
|
||||||
</p>
|
|
||||||
<CopyableField value={value} icon={field.icon} label={field.label} />
|
<CopyableField value={value} icon={field.icon} label={field.label} />
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<p className="text-sm">{value}</p>
|
||||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
|
||||||
{field.label}
|
|
||||||
</p>
|
|
||||||
<p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap' : ''}`}>{value}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fullWidthFields.map((field) => {
|
||||||
|
const value = getValue(item, field.key)!;
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
{field.icon && <field.icon className="h-3 w-3" />}
|
||||||
|
{field.label}
|
||||||
|
</div>
|
||||||
|
{field.copyable ? (
|
||||||
|
<CopyableField value={value} icon={field.icon} label={field.label} />
|
||||||
|
) : (
|
||||||
|
<p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap text-muted-foreground leading-relaxed' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
|
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
|
||||||
|
|||||||
548
frontend/src/components/todos/TodoDetailPanel.tsx
Normal file
548
frontend/src/components/todos/TodoDetailPanel.tsx
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { format, parseISO, isToday } from 'date-fns';
|
||||||
|
import {
|
||||||
|
X, Pencil, Trash2, Save, Clock, Calendar, Flag, Tag, Repeat, CheckSquare, AlertCircle, AlignLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Todo } from '@/types';
|
||||||
|
import { isTodoOverdue } from '@/lib/utils';
|
||||||
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||||
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface TodoCreateDefaults {
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TodoDetailPanelProps {
|
||||||
|
todo: Todo | null;
|
||||||
|
isCreating?: boolean;
|
||||||
|
createDefaults?: TodoCreateDefaults | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditState {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: string;
|
||||||
|
due_date: string;
|
||||||
|
due_time: string;
|
||||||
|
category: string;
|
||||||
|
recurrence_rule: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
none: 'bg-gray-500/20 text-gray-400',
|
||||||
|
low: 'bg-green-500/20 text-green-400',
|
||||||
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
high: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const recurrenceLabels: Record<string, string> = {
|
||||||
|
daily: 'Daily',
|
||||||
|
weekly: 'Weekly',
|
||||||
|
monthly: 'Monthly',
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
|
||||||
|
|
||||||
|
function buildEditState(todo: Todo): EditState {
|
||||||
|
return {
|
||||||
|
title: todo.title,
|
||||||
|
description: todo.description || '',
|
||||||
|
priority: todo.priority,
|
||||||
|
due_date: todo.due_date ? todo.due_date.slice(0, 10) : '',
|
||||||
|
due_time: todo.due_time ? todo.due_time.slice(0, 5) : '',
|
||||||
|
category: todo.category || '',
|
||||||
|
recurrence_rule: todo.recurrence_rule || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
priority: 'medium',
|
||||||
|
due_date: '',
|
||||||
|
due_time: '',
|
||||||
|
category: defaults?.category || '',
|
||||||
|
recurrence_rule: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
|
||||||
|
export default function TodoDetailPanel({
|
||||||
|
todo,
|
||||||
|
isCreating = false,
|
||||||
|
createDefaults,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: TodoDetailPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editState, setEditState] = useState<EditState>(() =>
|
||||||
|
isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset state when todo changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (todo) setEditState(buildEditState(todo));
|
||||||
|
}, [todo?.id]);
|
||||||
|
|
||||||
|
// Enter edit mode when creating
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreating) {
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditState(buildCreateState(createDefaults));
|
||||||
|
}
|
||||||
|
}, [isCreating, createDefaults]);
|
||||||
|
|
||||||
|
const invalidateAll = useCallback(() => {
|
||||||
|
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
// --- Mutations ---
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (data: EditState) => {
|
||||||
|
const payload = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description || null,
|
||||||
|
priority: data.priority,
|
||||||
|
due_date: data.due_date || null,
|
||||||
|
due_time: data.due_time || null,
|
||||||
|
category: data.category || null,
|
||||||
|
recurrence_rule: data.recurrence_rule || null,
|
||||||
|
};
|
||||||
|
if (todo && !isCreating) {
|
||||||
|
return api.put(`/todos/${todo.id}`, payload);
|
||||||
|
} else {
|
||||||
|
return api.post('/todos', payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success(isCreating ? 'Todo created' : 'Todo updated');
|
||||||
|
if (isCreating) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
onSaved?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, isCreating ? 'Failed to create todo' : 'Failed to update todo'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.patch(`/todos/${todo!.id}/toggle`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
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: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Todo deleted');
|
||||||
|
onClose();
|
||||||
|
onDeleted?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to delete todo'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
||||||
|
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
const handleEditStart = () => {
|
||||||
|
if (todo) setEditState(buildEditState(todo));
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (isCreating) {
|
||||||
|
onClose();
|
||||||
|
} else if (todo) {
|
||||||
|
setEditState(buildEditState(todo));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
saveMutation.mutate(editState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
|
||||||
|
setEditState((s) => ({ ...s, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (!todo && !isCreating) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<CheckSquare className="h-8 w-8 mb-3 opacity-40" />
|
||||||
|
<p className="text-sm">Select a todo to view details</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View data
|
||||||
|
const dueDate = todo?.due_date ? parseISO(todo.due_date) : null;
|
||||||
|
const isDueToday = dueDate ? isToday(dueDate) : false;
|
||||||
|
const isOverdue = todo ? isTodoOverdue(todo.due_date, todo.completed) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
{isEditing && !isCreating ? (
|
||||||
|
<Input
|
||||||
|
value={editState.title}
|
||||||
|
onChange={(e) => updateField('title', e.target.value)}
|
||||||
|
className="h-8 text-base font-semibold flex-1"
|
||||||
|
placeholder="Todo title"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : isCreating ? (
|
||||||
|
<h3 className="font-heading text-lg font-semibold">New Todo</h3>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<span onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={todo!.completed}
|
||||||
|
onChange={() => toggleMutation.mutate()}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<h3 className={`font-heading text-lg font-semibold truncate ${todo!.completed ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
{todo!.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-green-400 hover:text-green-300"
|
||||||
|
onClick={handleEditSave}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleEditStart}
|
||||||
|
title="Edit todo"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||||
|
title="Confirm delete"
|
||||||
|
>
|
||||||
|
Sure?
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
title="Delete todo"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close panel"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||||
|
{isEditing ? (
|
||||||
|
/* Edit / Create mode */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isCreating && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="todo-title" required>Title</Label>
|
||||||
|
<Input
|
||||||
|
id="todo-title"
|
||||||
|
value={editState.title}
|
||||||
|
onChange={(e) => updateField('title', e.target.value)}
|
||||||
|
placeholder="Todo title"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="todo-desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="todo-desc"
|
||||||
|
value={editState.description}
|
||||||
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
rows={3}
|
||||||
|
className="text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="todo-priority">Priority</Label>
|
||||||
|
<Select
|
||||||
|
id="todo-priority"
|
||||||
|
value={editState.priority}
|
||||||
|
onChange={(e) => updateField('priority', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="todo-category">Category</Label>
|
||||||
|
<Input
|
||||||
|
id="todo-category"
|
||||||
|
value={editState.category}
|
||||||
|
onChange={(e) => updateField('category', e.target.value)}
|
||||||
|
placeholder="e.g., Work"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="todo-due-date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="todo-due-date"
|
||||||
|
type="date"
|
||||||
|
value={editState.due_date}
|
||||||
|
onChange={(e) => updateField('due_date', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="todo-due-time">Due Time</Label>
|
||||||
|
<Input
|
||||||
|
id="todo-due-time"
|
||||||
|
type="time"
|
||||||
|
value={editState.due_time}
|
||||||
|
onChange={(e) => updateField('due_time', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="todo-recurrence">Recurrence</Label>
|
||||||
|
<Select
|
||||||
|
id="todo-recurrence"
|
||||||
|
value={editState.recurrence_rule}
|
||||||
|
onChange={(e) => updateField('recurrence_rule', e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save / Cancel at bottom */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleEditCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* View mode */
|
||||||
|
<>
|
||||||
|
{/* 2-column grid: Priority, Category, Due Date, Recurrence */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Flag className="h-3 w-3" />
|
||||||
|
Priority
|
||||||
|
</div>
|
||||||
|
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[todo!.priority] ?? ''}`}>
|
||||||
|
{todo!.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Tag className="h-3 w-3" />
|
||||||
|
Category
|
||||||
|
</div>
|
||||||
|
{todo!.category ? (
|
||||||
|
<Badge className="text-[9px] px-1.5 py-0.5 bg-blue-500/15 text-blue-400">
|
||||||
|
{todo!.category}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
{isOverdue ? <AlertCircle className="h-3 w-3" /> : <Calendar className="h-3 w-3" />}
|
||||||
|
Due Date
|
||||||
|
</div>
|
||||||
|
{dueDate ? (
|
||||||
|
<CopyableField
|
||||||
|
value={`${isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''}${format(dueDate, 'EEEE, MMMM d, yyyy')}${todo!.due_time ? ` at ${todo!.due_time.slice(0, 5)}` : ''}`}
|
||||||
|
icon={isOverdue ? AlertCircle : Calendar}
|
||||||
|
label="Due date"
|
||||||
|
/>
|
||||||
|
) : todo!.due_time ? (
|
||||||
|
<CopyableField value={todo!.due_time.slice(0, 5)} icon={Clock} label="Due time" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
Recurrence
|
||||||
|
</div>
|
||||||
|
{todo!.recurrence_rule ? (
|
||||||
|
<p className="text-sm">{recurrenceLabels[todo!.recurrence_rule] || todo!.recurrence_rule}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion status — full width */}
|
||||||
|
{todo!.completed && todo!.completed_at && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<CheckSquare className="h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-400">
|
||||||
|
{format(parseISO(todo!.completed_at), 'MMM d, yyyy · h:mm a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset info — full width */}
|
||||||
|
{todo!.completed && todo!.recurrence_rule && todo!.reset_at && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
Resets
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-purple-400">
|
||||||
|
{format(parseISO(todo!.reset_at), 'EEE, MMM d')}
|
||||||
|
{todo!.next_due_date && ` · Next due ${format(parseISO(todo!.next_due_date), 'MMM d')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description — full width */}
|
||||||
|
{todo!.description && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<AlignLeft className="h-3 w-3" />
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
|
||||||
|
{todo!.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Updated at */}
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatUpdatedAt(todo!.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,17 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search, ChevronDown } from 'lucide-react';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Todo } from '@/types';
|
import type { Todo } from '@/types';
|
||||||
import { isTodoOverdue } from '@/lib/utils';
|
import { isTodoOverdue } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||||
|
import { CategoryFilterBar } from '@/components/shared';
|
||||||
|
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||||
import TodoList from './TodoList';
|
import TodoList from './TodoList';
|
||||||
import TodoForm from './TodoForm';
|
import TodoDetailPanel from './TodoDetailPanel';
|
||||||
|
|
||||||
const priorityFilters = [
|
const priorityFilters = [
|
||||||
{ value: '', label: 'All' },
|
{ value: '', label: 'All' },
|
||||||
@ -22,14 +22,27 @@ const priorityFilters = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function TodosPage() {
|
export default function TodosPage() {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const location = useLocation();
|
||||||
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
|
|
||||||
const [filters, setFilters] = useState({
|
// Panel state
|
||||||
priority: '',
|
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
||||||
category: '',
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||||
showCompleted: true,
|
|
||||||
search: '',
|
// Filters
|
||||||
});
|
const [priorityFilter, setPriorityFilter] = useState('');
|
||||||
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||||
|
const [showCompleted, setShowCompleted] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Handle navigation state from dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { todoId?: number } | null;
|
||||||
|
if (state?.todoId) {
|
||||||
|
setSelectedTodoId(state.todoId);
|
||||||
|
setPanelMode('view');
|
||||||
|
window.history.replaceState({}, '');
|
||||||
|
}
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
const { data: todos = [], isLoading } = useQuery({
|
const { data: todos = [], isLoading } = useQuery({
|
||||||
queryKey: ['todos'],
|
queryKey: ['todos'],
|
||||||
@ -39,7 +52,7 @@ export default function TodosPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const allCategories = useMemo(() => {
|
||||||
const cats = new Set<string>();
|
const cats = new Set<string>();
|
||||||
todos.forEach((t) => {
|
todos.forEach((t) => {
|
||||||
if (t.category) cats.add(t.category);
|
if (t.category) cats.add(t.category);
|
||||||
@ -47,54 +60,92 @@ export default function TodosPage() {
|
|||||||
return Array.from(cats).sort();
|
return Array.from(cats).sort();
|
||||||
}, [todos]);
|
}, [todos]);
|
||||||
|
|
||||||
|
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('todos', allCategories);
|
||||||
|
|
||||||
const filteredTodos = useMemo(
|
const filteredTodos = useMemo(
|
||||||
() =>
|
() =>
|
||||||
todos.filter((todo) => {
|
todos.filter((todo) => {
|
||||||
if (filters.priority && todo.priority !== filters.priority) return false;
|
if (priorityFilter && todo.priority !== priorityFilter) return false;
|
||||||
if (filters.category && todo.category?.toLowerCase() !== filters.category.toLowerCase())
|
if (activeFilters.length > 0) {
|
||||||
return false;
|
if (!todo.category || !activeFilters.includes(todo.category)) return false;
|
||||||
if (!filters.showCompleted && todo.completed) return false;
|
}
|
||||||
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase()))
|
if (!showCompleted && todo.completed) return false;
|
||||||
|
if (search && !todo.title.toLowerCase().includes(search.toLowerCase()))
|
||||||
return false;
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[todos, filters]
|
[todos, priorityFilter, activeFilters, showCompleted, search]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalCount = filteredTodos.filter((t) => !t.completed).length;
|
const totalCount = filteredTodos.filter((t) => !t.completed).length;
|
||||||
const completedCount = filteredTodos.filter((t) => t.completed).length;
|
const completedCount = filteredTodos.filter((t) => t.completed).length;
|
||||||
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
|
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
|
||||||
|
|
||||||
const handleEdit = (todo: Todo) => {
|
const panelOpen = panelMode !== 'closed';
|
||||||
setEditingTodo(todo);
|
const selectedTodo = useMemo(
|
||||||
setShowForm(true);
|
() => todos.find((t) => t.id === selectedTodoId) ?? null,
|
||||||
|
[selectedTodoId, todos],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (todo: Todo) => {
|
||||||
|
setSelectedTodoId(todo.id);
|
||||||
|
setPanelMode('view');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCreateNew = () => {
|
||||||
setShowForm(false);
|
setSelectedTodoId(null);
|
||||||
setEditingTodo(null);
|
setPanelMode('create');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePanelClose = () => {
|
||||||
|
setPanelMode('closed');
|
||||||
|
setSelectedTodoId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// CategoryFilterBar handlers
|
||||||
|
const toggleAll = () => setActiveFilters([]);
|
||||||
|
const toggleCompleted = () => setShowCompleted((p) => !p);
|
||||||
|
const toggleCategory = (cat: string) => {
|
||||||
|
setActiveFilters((prev) =>
|
||||||
|
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const selectAllCategories = () => {
|
||||||
|
const allSelected = orderedCategories.every((c) => activeFilters.includes(c));
|
||||||
|
setActiveFilters(allSelected ? [] : [...orderedCategories]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Escape key closes panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelOpen) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') handlePanelClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [panelOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
|
||||||
|
|
||||||
|
{/* Priority filter */}
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
||||||
{priorityFilters.map((pf) => (
|
{priorityFilters.map((pf) => (
|
||||||
<button
|
<button
|
||||||
key={pf.value}
|
key={pf.value}
|
||||||
onClick={() => setFilters({ ...filters, priority: pf.value })}
|
onClick={() => setPriorityFilter(pf.value)}
|
||||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
||||||
filters.priority === pf.value
|
priorityFilter === pf.value
|
||||||
? 'bg-accent/15 text-accent'
|
? 'bg-accent/15 text-accent'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
filters.priority === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
priorityFilter === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||||
color: filters.priority === pf.value ? 'hsl(var(--accent-color))' : undefined,
|
color: priorityFilter === pf.value ? 'hsl(var(--accent-color))' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pf.label}
|
{pf.label}
|
||||||
@ -102,54 +153,37 @@ export default function TodosPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative ml-2">
|
{/* Category filter bar (All + Completed + Categories with drag) */}
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<div className="flex-1 min-w-0">
|
||||||
<Input
|
<CategoryFilterBar
|
||||||
placeholder="Search..."
|
activeFilters={activeFilters}
|
||||||
value={filters.search}
|
pinnedLabel="Completed"
|
||||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
showPinned={showCompleted}
|
||||||
className="w-52 h-8 pl-8 text-sm"
|
categories={orderedCategories}
|
||||||
|
onToggleAll={toggleAll}
|
||||||
|
onTogglePinned={toggleCompleted}
|
||||||
|
onToggleCategory={toggleCategory}
|
||||||
|
onSelectAllCategories={selectAllCategories}
|
||||||
|
onReorderCategories={reorderCategories}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<Button onClick={handleCreateNew} size="sm">
|
||||||
<select
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
|
|
||||||
className="h-8 rounded-md border border-input bg-background px-3 pr-8 text-sm text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 appearance-none cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<option key={cat} value={cat}>
|
|
||||||
{cat}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Checkbox
|
|
||||||
id="show-completed"
|
|
||||||
checked={filters.showCompleted}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="show-completed" className="text-xs text-muted-foreground cursor-pointer">
|
|
||||||
Completed
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
<Button onClick={() => setShowForm(true)} size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Todo
|
Add Todo
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
{/* Main content — list + detail panel */}
|
||||||
|
<div className="flex-1 overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-5">
|
||||||
{/* Summary stats */}
|
{/* Summary stats */}
|
||||||
{!isLoading && todos.length > 0 && (
|
{!isLoading && todos.length > 0 && (
|
||||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||||
@ -200,13 +234,47 @@ export default function TodosPage() {
|
|||||||
) : (
|
) : (
|
||||||
<TodoList
|
<TodoList
|
||||||
todos={filteredTodos}
|
todos={filteredTodos}
|
||||||
onEdit={handleEdit}
|
onEdit={handleSelect}
|
||||||
onAdd={() => setShowForm(true)}
|
onAdd={handleCreateNew}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />}
|
{/* Detail panel (desktop) */}
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TodoDetailPanel
|
||||||
|
todo={panelMode === 'view' ? selectedTodo : null}
|
||||||
|
isCreating={panelMode === 'create'}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile detail panel overlay */}
|
||||||
|
{panelOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
|
onClick={handlePanelClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<TodoDetailPanel
|
||||||
|
todo={panelMode === 'view' ? selectedTodo : null}
|
||||||
|
isCreating={panelMode === 'create'}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user