diff --git a/src/app/(dashboard)/[portSlug]/reminders/page.tsx b/src/app/(dashboard)/[portSlug]/reminders/page.tsx index b501217..ad93699 100644 --- a/src/app/(dashboard)/[portSlug]/reminders/page.tsx +++ b/src/app/(dashboard)/[portSlug]/reminders/page.tsx @@ -1,16 +1,5 @@ +import { ReminderList } from '@/components/reminders/reminder-list'; + export default function RemindersPage() { - return ( -
-
-

Reminders

-

Manage tasks and follow-up reminders

-
-
-

Coming in Layer 2

-

- This feature will be implemented in the next phase. -

-
-
- ); + return ; } diff --git a/src/app/api/v1/reminders/[id]/complete/route.ts b/src/app/api/v1/reminders/[id]/complete/route.ts new file mode 100644 index 0000000..077eaa3 --- /dev/null +++ b/src/app/api/v1/reminders/[id]/complete/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { completeReminder } from '@/lib/services/reminders.service'; +import { errorResponse } from '@/lib/errors'; + +export const POST = withAuth( + withPermission('reminders', 'edit_own', async (_req, ctx, params) => { + try { + const data = await completeReminder(params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reminders/[id]/dismiss/route.ts b/src/app/api/v1/reminders/[id]/dismiss/route.ts new file mode 100644 index 0000000..48ef647 --- /dev/null +++ b/src/app/api/v1/reminders/[id]/dismiss/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { dismissReminder } from '@/lib/services/reminders.service'; +import { errorResponse } from '@/lib/errors'; + +export const POST = withAuth( + withPermission('reminders', 'edit_own', async (_req, ctx, params) => { + try { + const data = await dismissReminder(params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reminders/[id]/route.ts b/src/app/api/v1/reminders/[id]/route.ts new file mode 100644 index 0000000..81cd346 --- /dev/null +++ b/src/app/api/v1/reminders/[id]/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { getReminder, updateReminder, deleteReminder } from '@/lib/services/reminders.service'; +import { updateReminderSchema } from '@/lib/validators/reminders'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('reminders', 'view_own', async (_req, ctx, params) => { + try { + const data = await getReminder(params.id!, ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const PATCH = withAuth( + withPermission('reminders', 'edit_own', async (req, ctx, params) => { + try { + const body = await parseBody(req, updateReminderSchema); + const data = await updateReminder(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('reminders', 'edit_own', async (_req, ctx, params) => { + try { + await deleteReminder(params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ success: true }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reminders/[id]/snooze/route.ts b/src/app/api/v1/reminders/[id]/snooze/route.ts new file mode 100644 index 0000000..2dc7cd6 --- /dev/null +++ b/src/app/api/v1/reminders/[id]/snooze/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { snoozeReminder } from '@/lib/services/reminders.service'; +import { snoozeReminderSchema } from '@/lib/validators/reminders'; +import { errorResponse } from '@/lib/errors'; + +export const POST = withAuth( + withPermission('reminders', 'edit_own', async (req, ctx, params) => { + try { + const body = await parseBody(req, snoozeReminderSchema); + const data = await snoozeReminder(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reminders/my/route.ts b/src/app/api/v1/reminders/my/route.ts new file mode 100644 index 0000000..8f01074 --- /dev/null +++ b/src/app/api/v1/reminders/my/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { getMyReminders } from '@/lib/services/reminders.service'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('reminders', 'view_own', async (_req, ctx) => { + try { + const data = await getMyReminders(ctx.userId, ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reminders/overdue/route.ts b/src/app/api/v1/reminders/overdue/route.ts new file mode 100644 index 0000000..ad85728 --- /dev/null +++ b/src/app/api/v1/reminders/overdue/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { getOverdueReminders } from '@/lib/services/reminders.service'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('reminders', 'view_all', async (_req, ctx) => { + try { + const data = await getOverdueReminders(ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reminders/route.ts b/src/app/api/v1/reminders/route.ts new file mode 100644 index 0000000..ae2abf2 --- /dev/null +++ b/src/app/api/v1/reminders/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody, parseQuery } from '@/lib/api/route-helpers'; +import { listReminders, createReminder } from '@/lib/services/reminders.service'; +import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('reminders', 'view_own', async (req, ctx) => { + try { + const query = parseQuery(req, reminderListQuerySchema); + const result = await listReminders(ctx.portId, query); + return NextResponse.json(result); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('reminders', 'create', async (req, ctx) => { + try { + const body = await parseBody(req, createReminderSchema); + + // Check assign_others permission if assigning to someone else + if (body.assignedTo && body.assignedTo !== ctx.userId) { + if (!ctx.isSuperAdmin) { + const perms = ctx.permissions?.reminders; + if (!perms?.assign_others) { + return NextResponse.json( + { error: 'Cannot assign reminders to other users' }, + { status: 403 }, + ); + } + } + } + + const data = await createReminder(ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reminders/upcoming/route.ts b/src/app/api/v1/reminders/upcoming/route.ts new file mode 100644 index 0000000..f7dffbe --- /dev/null +++ b/src/app/api/v1/reminders/upcoming/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { getUpcomingReminders } from '@/lib/services/reminders.service'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('reminders', 'view_own', async (_req, ctx) => { + try { + const data = await getUpcomingReminders(ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/reminders/reminder-form.tsx b/src/components/reminders/reminder-form.tsx new file mode 100644 index 0000000..1546faa --- /dev/null +++ b/src/components/reminders/reminder-form.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; +import { apiFetch } from '@/lib/api/client'; +import { usePermissions } from '@/hooks/use-permissions'; + +interface UserOption { + id: string; + displayName: string; +} + +interface ReminderFormProps { + open: boolean; + onOpenChange: (open: boolean) => void; + reminder?: { + id: string; + title: string; + note: string | null; + dueAt: string; + priority: string; + assignedTo: string | null; + clientId: string | null; + interestId: string | null; + berthId: string | null; + } | null; + // Pre-fill entity link when creating from entity detail pages + defaultClientId?: string; + defaultInterestId?: string; + defaultBerthId?: string; + onSuccess: () => void; +} + +export function ReminderForm({ + open, + onOpenChange, + reminder, + defaultClientId, + defaultInterestId, + defaultBerthId, + onSuccess, +}: ReminderFormProps) { + const [title, setTitle] = useState(''); + const [note, setNote] = useState(''); + const [dueAt, setDueAt] = useState(''); + const [priority, setPriority] = useState('medium'); + const [assignedTo, setAssignedTo] = useState(''); + const [clientId, setClientId] = useState(''); + const [interestId, setInterestId] = useState(''); + const [berthId, setBerthId] = useState(''); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { can } = usePermissions(); + const canAssignOthers = can('reminders', 'assign_others'); + + const isEdit = !!reminder; + + useEffect(() => { + if (open && canAssignOthers) { + void apiFetch<{ data: UserOption[] }>('/api/v1/admin/users/options').then((res) => + setUsers(res.data), + ); + } + }, [open, canAssignOthers]); + + useEffect(() => { + if (open) { + if (reminder) { + setTitle(reminder.title); + setNote(reminder.note ?? ''); + setDueAt(reminder.dueAt.slice(0, 16)); // datetime-local format + setPriority(reminder.priority); + setAssignedTo(reminder.assignedTo ?? ''); + setClientId(reminder.clientId ?? ''); + setInterestId(reminder.interestId ?? ''); + setBerthId(reminder.berthId ?? ''); + } else { + setTitle(''); + setNote(''); + // Default to tomorrow 9 AM + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); + setDueAt(tomorrow.toISOString().slice(0, 16)); + setPriority('medium'); + setAssignedTo(''); + setClientId(defaultClientId ?? ''); + setInterestId(defaultInterestId ?? ''); + setBerthId(defaultBerthId ?? ''); + } + setError(null); + } + }, [open, reminder, defaultClientId, defaultInterestId, defaultBerthId]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const body = { + title, + note: note || undefined, + dueAt: new Date(dueAt).toISOString(), + priority, + assignedTo: assignedTo || undefined, + clientId: clientId || undefined, + interestId: interestId || undefined, + berthId: berthId || undefined, + }; + + if (isEdit) { + await apiFetch(`/api/v1/reminders/${reminder.id}`, { + method: 'PATCH', + body, + }); + } else { + await apiFetch('/api/v1/reminders', { + method: 'POST', + body, + }); + } + onSuccess(); + onOpenChange(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Something went wrong'; + setError(message); + } finally { + setLoading(false); + } + } + + return ( + + + + {isEdit ? 'Edit Reminder' : 'New Reminder'} + + +
+
+ + setTitle(e.target.value)} + placeholder="Follow up with client..." + required + /> +
+ +
+ +