Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone) - User settings page with profile editor + notification preferences - Audit log API with filtering (entity, action, user, date range) - Audit log page with search, entity type, and action filters - Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id] - Client duplicates endpoint: GET /api/v1/clients/duplicates?name= - Replace settings and audit stub pages with real implementations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,5 @@
|
|||||||
|
import { AuditLogList } from '@/components/admin/audit/audit-log-list';
|
||||||
|
|
||||||
export default function AuditLogPage() {
|
export default function AuditLogPage() {
|
||||||
return (
|
return <AuditLogList />;
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
|
|
||||||
<p className="text-muted-foreground">Review system activity and changes</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This feature will be implemented in the next phase.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
|
import { UserSettings } from '@/components/settings/user-settings';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return <UserSettings />;
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
|
||||||
<p className="text-muted-foreground">Manage your account and port preferences</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This feature will be implemented in the next phase.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/app/api/v1/admin/audit/route.ts
Normal file
31
src/app/api/v1/admin/audit/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { listAuditLogs } from '@/lib/services/audit.service';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
|
const auditQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
entityType: z.string().optional(),
|
||||||
|
action: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
entityId: z.string().optional(),
|
||||||
|
dateFrom: z.string().optional(),
|
||||||
|
dateTo: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('admin', 'view_audit_log', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req, auditQuerySchema);
|
||||||
|
const result = await listAuditLogs(ctx.portId, query);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { updateBerthSchema } from '@/lib/validators/berths';
|
import { updateBerthSchema } from '@/lib/validators/berths';
|
||||||
import { getBerthById, updateBerth } from '@/lib/services/berths.service';
|
import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
// GET /api/v1/berths/[id]
|
// GET /api/v1/berths/[id]
|
||||||
@@ -18,7 +18,7 @@ export const GET = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// PATCH /api/v1/berths/[id] — update berth fields (no DELETE — import-only)
|
// PATCH /api/v1/berths/[id]
|
||||||
export const PATCH = withAuth(
|
export const PATCH = withAuth(
|
||||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
@@ -35,3 +35,20 @@ export const PATCH = withAuth(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DELETE /api/v1/berths/[id]
|
||||||
|
export const DELETE = withAuth(
|
||||||
|
withPermission('berths', 'edit', async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await deleteBerth(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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseQuery } from '@/lib/api/route-helpers';
|
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||||
import { listBerthsSchema } from '@/lib/validators/berths';
|
import { listBerthsSchema, createBerthSchema } from '@/lib/validators/berths';
|
||||||
import { listBerths } from '@/lib/services/berths.service';
|
import { listBerths, createBerth } from '@/lib/services/berths.service';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
// GET /api/v1/berths — list berths for the current port (no POST — import-only)
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('berths', 'view', async (req, ctx) => {
|
withPermission('berths', 'view', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
@@ -34,3 +33,20 @@ export const GET = withAuth(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('berths', 'edit', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, createBerthSchema);
|
||||||
|
const data = await createBerth(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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
23
src/app/api/v1/clients/duplicates/route.ts
Normal file
23
src/app/api/v1/clients/duplicates/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { findDuplicates } from '@/lib/services/clients.service';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
|
const duplicateQuerySchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('clients', 'view', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const { name } = parseQuery(req, duplicateQuerySchema);
|
||||||
|
const data = await findDuplicates(ctx.portId, name);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { withAuth, type AuthContext } from '@/lib/api/helpers';
|
import { withAuth, type AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { userProfiles } from '@/lib/db/schema';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const updateProfileSchema = z.object({
|
||||||
|
displayName: z.string().min(1).max(200).optional(),
|
||||||
|
phone: z.string().nullable().optional(),
|
||||||
|
avatarUrl: z.string().url().nullable().optional(),
|
||||||
|
preferences: z
|
||||||
|
.object({
|
||||||
|
dark_mode: z.boolean().optional(),
|
||||||
|
locale: z.string().optional(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -13,3 +34,45 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateProfileSchema);
|
||||||
|
|
||||||
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
|
where: eq(userProfiles.userId, ctx.userId),
|
||||||
|
});
|
||||||
|
if (!profile) {
|
||||||
|
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||||
|
if (body.phone !== undefined) updates.phone = body.phone;
|
||||||
|
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
||||||
|
if (body.preferences !== undefined) {
|
||||||
|
updates.preferences = {
|
||||||
|
...((profile.preferences as Record<string, unknown>) ?? {}),
|
||||||
|
...body.preferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(userProfiles)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(userProfiles.userId, ctx.userId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: {
|
||||||
|
userId: updated!.userId,
|
||||||
|
displayName: updated!.displayName,
|
||||||
|
phone: updated!.phone,
|
||||||
|
avatarUrl: updated!.avatarUrl,
|
||||||
|
preferences: updated!.preferences,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
257
src/components/admin/audit/audit-log-list.tsx
Normal file
257
src/components/admin/audit/audit-log-list.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/shared/data-table';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
fieldChanged: string | null;
|
||||||
|
oldValue: Record<string, unknown> | null;
|
||||||
|
newValue: Record<string, unknown> | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
create: 'bg-green-600',
|
||||||
|
update: 'bg-blue-500',
|
||||||
|
delete: 'bg-red-600',
|
||||||
|
archive: 'bg-orange-500',
|
||||||
|
restore: 'bg-teal-500',
|
||||||
|
login: 'bg-gray-500',
|
||||||
|
permission_denied: 'bg-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_TYPES = [
|
||||||
|
'client',
|
||||||
|
'interest',
|
||||||
|
'berth',
|
||||||
|
'document',
|
||||||
|
'expense',
|
||||||
|
'invoice',
|
||||||
|
'reminder',
|
||||||
|
'user',
|
||||||
|
'role',
|
||||||
|
'port',
|
||||||
|
'setting',
|
||||||
|
'tag',
|
||||||
|
'webhook',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AuditLogList() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
||||||
|
const [actionFilter, setActionFilter] = useState<string>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
limit: '50',
|
||||||
|
});
|
||||||
|
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
|
||||||
|
if (actionFilter !== 'all') params.set('action', actionFilter);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
|
||||||
|
const res = await apiFetch<{
|
||||||
|
data: AuditEntry[];
|
||||||
|
pagination: { total: number };
|
||||||
|
}>(`/api/v1/admin/audit?${params}`);
|
||||||
|
setEntries(res.data);
|
||||||
|
setTotal(res.pagination.total);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, entityTypeFilter, actionFilter, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
const columns: ColumnDef<AuditEntry, unknown>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: 'Time',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{new Date(row.original.createdAt).toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'action',
|
||||||
|
header: 'Action',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
||||||
|
>
|
||||||
|
{row.original.action}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'entityType',
|
||||||
|
header: 'Entity',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium capitalize">{row.original.entityType}</span>
|
||||||
|
<code className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{row.original.entityId.slice(0, 8)}...
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'changes',
|
||||||
|
header: 'Changes',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { newValue, fieldChanged } = row.original;
|
||||||
|
if (fieldChanged) return <span className="text-sm">{fieldChanged}</span>;
|
||||||
|
if (newValue) {
|
||||||
|
const keys = Object.keys(newValue);
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{keys.slice(0, 3).join(', ')}
|
||||||
|
{keys.length > 3 ? ` +${keys.length - 3} more` : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-muted-foreground">—</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'userId',
|
||||||
|
header: 'User',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<code className="text-xs">
|
||||||
|
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Audit Log" description={`${total} entries`} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={entityTypeFilter}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setEntityTypeFilter(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Entities</SelectItem>
|
||||||
|
{ENTITY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={actionFilter}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setActionFilter(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Actions</SelectItem>
|
||||||
|
<SelectItem value="create">Create</SelectItem>
|
||||||
|
<SelectItem value="update">Update</SelectItem>
|
||||||
|
<SelectItem value="delete">Delete</SelectItem>
|
||||||
|
<SelectItem value="archive">Archive</SelectItem>
|
||||||
|
<SelectItem value="restore">Restore</SelectItem>
|
||||||
|
<SelectItem value="permission_denied">Permission Denied</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={entries}
|
||||||
|
isLoading={loading}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
emptyState={
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">No audit log entries found.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{total > 50 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {Math.ceil(total / 50)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
disabled={page >= Math.ceil(total / 50)}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/components/settings/user-settings.tsx
Normal file
174
src/components/settings/user-settings.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface NotificationPrefs {
|
||||||
|
reminder_due: boolean;
|
||||||
|
reminder_overdue: boolean;
|
||||||
|
eoi_signed: boolean;
|
||||||
|
eoi_completed: boolean;
|
||||||
|
invoice_overdue: boolean;
|
||||||
|
duplicate_alert: boolean;
|
||||||
|
[key: string]: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSettings() {
|
||||||
|
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs | null>(null);
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [timezone, setTimezone] = useState('');
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProfile();
|
||||||
|
void loadNotificationPrefs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
const res = await apiFetch<{ data: { user?: { name: string } } }>('/api/v1/me', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
setDisplayName(res.data.user?.name ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotificationPrefs() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ data: NotificationPrefs }>('/api/v1/notifications/preferences');
|
||||||
|
setNotifPrefs(res.data);
|
||||||
|
} catch {
|
||||||
|
// Preferences may not exist yet
|
||||||
|
setNotifPrefs({
|
||||||
|
reminder_due: true,
|
||||||
|
reminder_overdue: true,
|
||||||
|
eoi_signed: true,
|
||||||
|
eoi_completed: true,
|
||||||
|
invoice_overdue: true,
|
||||||
|
duplicate_alert: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
setSaving('profile');
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
phone: phone || null,
|
||||||
|
preferences: { timezone: timezone || undefined },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setMessage('Profile saved');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setMessage(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleNotifPref(key: string, value: boolean) {
|
||||||
|
setSaving(key);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/notifications/preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { [key]: value },
|
||||||
|
});
|
||||||
|
setNotifPrefs((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIF_LABELS: Record<string, string> = {
|
||||||
|
reminder_due: 'Reminder due',
|
||||||
|
reminder_overdue: 'Reminder overdue',
|
||||||
|
eoi_signed: 'EOI signed by a party',
|
||||||
|
eoi_completed: 'EOI fully completed',
|
||||||
|
invoice_overdue: 'Invoice overdue',
|
||||||
|
duplicate_alert: 'Duplicate client detected',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Settings" description="Manage your profile and notification preferences" />
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6 max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
<CardDescription>Update your display name and contact info</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="settings-name">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="settings-name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="settings-phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="settings-phone"
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+1 555-0123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="settings-tz">Timezone</Label>
|
||||||
|
<Input
|
||||||
|
id="settings-tz"
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
placeholder="America/Anguilla"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={saveProfile} disabled={saving === 'profile'}>
|
||||||
|
<Save className="mr-1.5 h-4 w-4" />
|
||||||
|
{saving === 'profile' ? 'Saving...' : 'Save Profile'}
|
||||||
|
</Button>
|
||||||
|
{message && <span className="text-sm text-muted-foreground">{message}</span>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notifications</CardTitle>
|
||||||
|
<CardDescription>Choose which notifications you receive</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{notifPrefs &&
|
||||||
|
Object.entries(NOTIF_LABELS).map(([key, label]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<Switch
|
||||||
|
checked={notifPrefs[key] ?? true}
|
||||||
|
disabled={saving === key}
|
||||||
|
onCheckedChange={(checked) => toggleNotifPref(key, checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/lib/services/audit.service.ts
Normal file
57
src/lib/services/audit.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { and, eq, desc, sql, gte, lte } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { auditLogs } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface AuditListQuery {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
entityType?: string;
|
||||||
|
action?: string;
|
||||||
|
userId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuditLogs(portId: string, query: AuditListQuery) {
|
||||||
|
const conditions = [eq(auditLogs.portId, portId)];
|
||||||
|
|
||||||
|
if (query.entityType) conditions.push(eq(auditLogs.entityType, query.entityType));
|
||||||
|
if (query.action) conditions.push(eq(auditLogs.action, query.action));
|
||||||
|
if (query.userId) conditions.push(eq(auditLogs.userId, query.userId));
|
||||||
|
if (query.entityId) conditions.push(eq(auditLogs.entityId, query.entityId));
|
||||||
|
if (query.dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(query.dateFrom)));
|
||||||
|
if (query.dateTo) conditions.push(lte(auditLogs.createdAt, new Date(query.dateTo)));
|
||||||
|
if (query.search) {
|
||||||
|
conditions.push(
|
||||||
|
sql`(${auditLogs.entityType} ILIKE ${'%' + query.search + '%'} OR ${auditLogs.action} ILIKE ${'%' + query.search + '%'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (query.page - 1) * query.limit;
|
||||||
|
|
||||||
|
const [data, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(auditLogs)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(auditLogs.createdAt))
|
||||||
|
.limit(query.limit)
|
||||||
|
.offset(offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(auditLogs)
|
||||||
|
.where(and(...conditions)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page: query.page,
|
||||||
|
limit: query.limit,
|
||||||
|
total: Number(countResult[0]?.count ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
|
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||||
berths,
|
|
||||||
berthTags,
|
|
||||||
berthWaitingList,
|
|
||||||
berthMaintenanceLog,
|
|
||||||
} from '@/lib/db/schema/berths';
|
|
||||||
import { tags } from '@/lib/db/schema/system';
|
import { tags } from '@/lib/db/schema/system';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
import { NotFoundError } from '@/lib/errors';
|
import { NotFoundError } from '@/lib/errors';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
import { ConflictError } from '@/lib/errors';
|
||||||
import type {
|
import type {
|
||||||
|
CreateBerthInput,
|
||||||
UpdateBerthInput,
|
UpdateBerthInput,
|
||||||
UpdateBerthStatusInput,
|
UpdateBerthStatusInput,
|
||||||
ListBerthsQuery,
|
ListBerthsQuery,
|
||||||
@@ -71,12 +68,18 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
|
|
||||||
const sortColumn = (() => {
|
const sortColumn = (() => {
|
||||||
switch (query.sort) {
|
switch (query.sort) {
|
||||||
case 'mooringNumber': return berths.mooringNumber;
|
case 'mooringNumber':
|
||||||
case 'area': return berths.area;
|
return berths.mooringNumber;
|
||||||
case 'price': return berths.price;
|
case 'area':
|
||||||
case 'status': return berths.status;
|
return berths.area;
|
||||||
case 'lengthM': return berths.lengthM;
|
case 'price':
|
||||||
default: return berths.updatedAt;
|
return berths.price;
|
||||||
|
case 'status':
|
||||||
|
return berths.status;
|
||||||
|
case 'lengthM':
|
||||||
|
return berths.lengthM;
|
||||||
|
default:
|
||||||
|
return berths.updatedAt;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -161,7 +164,10 @@ export async function updateBerth(
|
|||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundError('Berth');
|
if (!existing) throw new NotFoundError('Berth');
|
||||||
|
|
||||||
const { changed, diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
|
const { changed, diff } = diffEntity(
|
||||||
|
existing as Record<string, unknown>,
|
||||||
|
data as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
if (!changed) return existing;
|
if (!changed) return existing;
|
||||||
|
|
||||||
@@ -288,12 +294,7 @@ export async function updateBerthStatus(
|
|||||||
|
|
||||||
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function setBerthTags(
|
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
tagIds: string[],
|
|
||||||
meta: AuditMeta,
|
|
||||||
) {
|
|
||||||
const existing = await db.query.berths.findFirst({
|
const existing = await db.query.berths.findFirst({
|
||||||
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
||||||
});
|
});
|
||||||
@@ -454,6 +455,90 @@ export async function updateWaitingList(
|
|||||||
return data.entries;
|
return data.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createBerth(portId: string, data: CreateBerthInput, meta: AuditMeta) {
|
||||||
|
// Check mooring number uniqueness within port
|
||||||
|
const existing = await db.query.berths.findFirst({
|
||||||
|
where: and(eq(berths.portId, portId), eq(berths.mooringNumber, data.mooringNumber)),
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [berth] = await db
|
||||||
|
.insert(berths)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
mooringNumber: data.mooringNumber,
|
||||||
|
area: data.area,
|
||||||
|
status: data.status ?? 'available',
|
||||||
|
lengthFt: data.lengthFt?.toString(),
|
||||||
|
lengthM: data.lengthM?.toString(),
|
||||||
|
widthFt: data.widthFt?.toString(),
|
||||||
|
widthM: data.widthM?.toString(),
|
||||||
|
draftFt: data.draftFt?.toString(),
|
||||||
|
draftM: data.draftM?.toString(),
|
||||||
|
price: data.price?.toString(),
|
||||||
|
priceCurrency: data.priceCurrency ?? 'USD',
|
||||||
|
tenureType: data.tenureType ?? 'permanent',
|
||||||
|
mooringType: data.mooringType,
|
||||||
|
powerCapacity: data.powerCapacity,
|
||||||
|
voltage: data.voltage,
|
||||||
|
access: data.access,
|
||||||
|
bowFacing: data.bowFacing,
|
||||||
|
sidePontoon: data.sidePontoon,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'berth',
|
||||||
|
entityId: berth!.id,
|
||||||
|
newValue: { mooringNumber: berth!.mooringNumber, area: berth!.area },
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||||
|
alertType: 'berth:created',
|
||||||
|
message: `Berth "${berth!.mooringNumber}" created`,
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
return berth!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delete ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
||||||
|
const berth = await db.query.berths.findFirst({
|
||||||
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!berth) throw new NotFoundError('Berth');
|
||||||
|
|
||||||
|
await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId)));
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'delete',
|
||||||
|
entityType: 'berth',
|
||||||
|
entityId: id,
|
||||||
|
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area },
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||||
|
alertType: 'berth:deleted',
|
||||||
|
message: `Berth "${berth.mooringNumber}" deleted`,
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Options ──────────────────────────────────────────────────────────────────
|
// ─── Options ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getBerthOptions(portId: string) {
|
export async function getBerthOptions(portId: string) {
|
||||||
|
|||||||
@@ -2,6 +2,31 @@ import { z } from 'zod';
|
|||||||
import { BERTH_STATUSES } from '@/lib/constants';
|
import { BERTH_STATUSES } from '@/lib/constants';
|
||||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
||||||
|
|
||||||
|
// ─── Create Berth ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const createBerthSchema = z.object({
|
||||||
|
mooringNumber: z.string().min(1),
|
||||||
|
area: z.string().min(1),
|
||||||
|
lengthFt: z.coerce.number().optional(),
|
||||||
|
lengthM: z.coerce.number().optional(),
|
||||||
|
widthFt: z.coerce.number().optional(),
|
||||||
|
widthM: z.coerce.number().optional(),
|
||||||
|
draftFt: z.coerce.number().optional(),
|
||||||
|
draftM: z.coerce.number().optional(),
|
||||||
|
price: z.coerce.number().optional(),
|
||||||
|
priceCurrency: z.string().optional(),
|
||||||
|
status: z.enum(BERTH_STATUSES).default('available'),
|
||||||
|
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
|
||||||
|
mooringType: z.string().optional(),
|
||||||
|
powerCapacity: z.string().optional(),
|
||||||
|
voltage: z.string().optional(),
|
||||||
|
access: z.string().optional(),
|
||||||
|
bowFacing: z.string().optional(),
|
||||||
|
sidePontoon: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateBerthInput = z.infer<typeof createBerthSchema>;
|
||||||
|
|
||||||
// ─── Update Berth ─────────────────────────────────────────────────────────────
|
// ─── Update Berth ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const updateBerthSchema = z.object({
|
export const updateBerthSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user