Integrate NocoDB backend for admin portal with real data
Some checks failed
Build and Push Docker Images / docker (push) Failing after 1m57s

Phase 2 Backend Integration Complete:

Backend Infrastructure:
- Created NocoDB client abstraction layer (src/api/nocodbClient.ts)
- Clean TypeScript API hiding NocoDB query syntax complexity
- Helper methods for orders, stats, search, timeline, and filtering
- Automatic date range handling and pagination support

API Routes:
- POST /api/admin/stats - Dashboard statistics with time range filtering
- GET /api/admin/orders - List orders with search, filter, sort, pagination
- GET /api/admin/orders/[id] - Single order details
- PATCH /api/admin/orders/[id] - Update order fields
- DELETE /api/admin/orders/[id] - Cancel order (soft delete)
- GET /api/admin/orders/export - CSV/Excel export with filters

Dashboard Updates:
- Real-time data fetching from NocoDB
- Time range selector (7d, 30d, 90d, all time)
- Recharts line chart for orders timeline
- Recharts pie chart for status distribution
- Loading states and error handling
- Dynamic stat cards with real numbers

Dependencies Added:
- papaparse - CSV export
- xlsx - Excel export with styling
- @types/papaparse - TypeScript support

Data Types:
- OrderRecord interface for NocoDB data structure
- DashboardStats, TimelineData, OrderFilters interfaces
- Full type safety across API and UI

Environment Configuration:
- NOCODB_BASE_URL, NOCODB_BASE_ID configured
- NOCODB_API_KEY, NOCODB_ORDERS_TABLE_ID configured
- All credentials stored securely in .env.local

Ready for testing with sample data in NocoDB!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-11-03 10:40:25 +01:00
parent 1e4461cf43
commit a6484de35e
11 changed files with 11863 additions and 106 deletions

View File

@ -8,7 +8,8 @@
"mcp__serena__initial_instructions",
"mcp__serena__get_current_config",
"mcp__playwright__browser_fill_form",
"WebSearch"
"WebSearch",
"mcp__serena__check_onboarding_performed"
],
"deny": [],
"ask": []

View File

@ -1,119 +1,281 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { DollarSign, Package, Leaf, TrendingUp } from 'lucide-react';
import { DollarSign, Package, Leaf, TrendingUp, Calendar, Loader2 } from 'lucide-react';
import {
LineChart,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface DashboardStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
ordersByStatus: {
pending: number;
paid: number;
fulfilled: number;
cancelled: number;
};
}
interface TimelineData {
date: string;
count: number;
revenue: number;
}
type TimeRange = '7d' | '30d' | '90d' | 'all';
export default function AdminDashboard() {
// Placeholder data - will be replaced with real API data
const stats = [
{
title: 'Total Orders',
value: '0',
icon: <Package size={24} />,
trend: { value: 0, isPositive: true },
gradient: 'bg-gradient-to-br from-royal-purple to-purple-600',
bgColor: 'bg-royal-purple',
},
{
title: 'Total CO₂ Offset',
value: '0 tons',
icon: <Leaf size={24} />,
trend: { value: 0, isPositive: true },
gradient: 'bg-gradient-to-br from-sea-green to-green-600',
bgColor: 'bg-sea-green',
},
{
title: 'Total Revenue',
value: '$0',
icon: <DollarSign size={24} />,
trend: { value: 0, isPositive: true },
gradient: 'bg-gradient-to-br from-muted-gold to-orange-600',
bgColor: 'bg-muted-gold',
},
{
title: 'Fulfillment Rate',
value: '0%',
icon: <TrendingUp size={24} />,
trend: { value: 0, isPositive: true },
gradient: 'bg-gradient-to-br from-maritime-teal to-teal-600',
bgColor: 'bg-maritime-teal',
},
];
const [stats, setStats] = useState<DashboardStats | null>(null);
const [timeline, setTimeline] = useState<TimelineData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [error, setError] = useState<string | null>(null);
// Fetch dashboard data
useEffect(() => {
fetchDashboardData();
}, [timeRange]);
const fetchDashboardData = async () => {
setIsLoading(true);
setError(null);
try {
// Calculate date range
const dateTo = new Date().toISOString().split('T')[0];
let dateFrom: string | undefined;
if (timeRange !== 'all') {
const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
dateFrom = fromDate.toISOString().split('T')[0];
}
// Build query params
const params = new URLSearchParams();
if (dateFrom) params.append('dateFrom', dateFrom);
params.append('dateTo', dateTo);
params.append('period', timeRange === '7d' ? 'day' : 'week');
const response = await fetch(`/api/admin/stats?${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch dashboard data');
}
setStats(data.data.stats);
setTimeline(data.data.timeline);
} catch (err) {
console.error('Error fetching dashboard data:', err);
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
} finally {
setIsLoading(false);
}
};
// Prepare pie chart data
const pieChartData = stats
? [
{ name: 'Pending', value: stats.ordersByStatus.pending, color: '#D68910' },
{ name: 'Paid', value: stats.ordersByStatus.paid, color: '#008B8B' },
{ name: 'Fulfilled', value: stats.ordersByStatus.fulfilled, color: '#1E8449' },
{ name: 'Cancelled', value: stats.ordersByStatus.cancelled, color: '#DC2626' },
]
: [];
const statCards = stats
? [
{
title: 'Total Orders',
value: stats.totalOrders.toLocaleString(),
icon: <Package size={24} />,
gradient: 'bg-gradient-to-br from-royal-purple to-purple-600',
},
{
title: 'Total CO₂ Offset',
value: `${stats.totalCO2Offset.toFixed(2)} tons`,
icon: <Leaf size={24} />,
gradient: 'bg-gradient-to-br from-sea-green to-green-600',
},
{
title: 'Total Revenue',
value: `$${stats.totalRevenue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
icon: <DollarSign size={24} />,
gradient: 'bg-gradient-to-br from-muted-gold to-orange-600',
},
{
title: 'Fulfillment Rate',
value: `${stats.fulfillmentRate.toFixed(1)}%`,
icon: <TrendingUp size={24} />,
gradient: 'bg-gradient-to-br from-maritime-teal to-teal-600',
},
]
: [];
if (error) {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Dashboard</h1>
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<p className="text-red-800 font-medium">{error}</p>
<button
onClick={fetchDashboardData}
className="mt-4 px-4 py-2 bg-deep-sea-blue text-white rounded-lg hover:bg-deep-sea-blue/90"
>
Retry
</button>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Dashboard</h1>
<p className="text-deep-sea-blue/70 font-medium">Welcome to the Puffin Offset Admin Portal</p>
{/* Header with Time Range Selector */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Dashboard</h1>
<p className="text-deep-sea-blue/70 font-medium">Overview of your carbon offset operations</p>
</div>
<div className="flex items-center gap-2 bg-white border border-light-gray-border rounded-lg p-1">
{(['7d', '30d', '90d', 'all'] as TimeRange[]).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 rounded-md font-medium transition-all ${
timeRange === range
? 'bg-deep-sea-blue text-white shadow-sm'
: 'text-deep-sea-blue/60 hover:text-deep-sea-blue hover:bg-sail-white'
}`}
>
{range === 'all' ? 'All Time' : range.toUpperCase()}
</button>
))}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, index) => (
<motion.div
key={stat.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-white border border-light-gray-border rounded-xl p-6 hover:shadow-lg transition-shadow"
>
<div className="flex items-center justify-between mb-4">
<div className={stat.gradient + " w-12 h-12 rounded-lg flex items-center justify-center text-white shadow-md"}>
{stat.icon}
</div>
{stat.trend && (
<span className={`text-sm font-semibold ${stat.trend.isPositive ? 'text-sea-green' : 'text-red-600'}`}>
{stat.trend.isPositive ? '+' : '-'}{stat.trend.value}%
</span>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-deep-sea-blue animate-spin" />
</div>
) : (
<>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statCards.map((stat, index) => (
<motion.div
key={stat.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-white border border-light-gray-border rounded-xl p-6 hover:shadow-lg transition-shadow"
>
<div className="flex items-center justify-between mb-4">
<div
className={
stat.gradient + ' w-12 h-12 rounded-lg flex items-center justify-center text-white shadow-md'
}
>
{stat.icon}
</div>
</div>
<h3 className="text-sm font-medium text-deep-sea-blue/60 mb-1">{stat.title}</h3>
<p className="text-2xl font-bold text-deep-sea-blue">{stat.value}</p>
</motion.div>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Orders Timeline */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-white border border-light-gray-border rounded-xl p-6"
>
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Orders Over Time</h2>
{timeline.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={timeline}>
<CartesianGrid strokeDasharray="3 3" stroke="#EAECF0" />
<XAxis dataKey="date" stroke="#1D2939" fontSize={12} />
<YAxis stroke="#1D2939" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#FFF',
border: '1px solid #EAECF0',
borderRadius: '8px',
}}
/>
<Legend />
<Line type="monotone" dataKey="count" stroke="#884EA0" name="Orders" strokeWidth={2} />
<Line type="monotone" dataKey="revenue" stroke="#1E8449" name="Revenue ($)" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
No orders data available for this time range
</div>
)}
</div>
<h3 className="text-sm font-medium text-deep-sea-blue/60 mb-1">{stat.title}</h3>
<p className="text-2xl font-bold text-deep-sea-blue">{stat.value}</p>
</motion.div>
))}
</div>
</motion.div>
{/* Placeholder for Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-white border border-light-gray-border rounded-xl p-6"
>
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Orders Timeline</h2>
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
Chart will be implemented in Phase 3
{/* Status Distribution */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="bg-white border border-light-gray-border rounded-xl p-6"
>
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Status Distribution</h2>
{stats && stats.totalOrders > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{pieChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
No orders to display
</div>
)}
</motion.div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="bg-white border border-light-gray-border rounded-xl p-6"
>
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Status Distribution</h2>
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
Chart will be implemented in Phase 3
</div>
</motion.div>
</div>
{/* Info Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="bg-maritime-teal/10 border border-maritime-teal/30 rounded-xl p-6"
>
<h3 className="text-lg font-bold text-deep-sea-blue mb-2">🎉 Admin Center Active!</h3>
<p className="text-deep-sea-blue/80">
Phase 1 (Foundation) is complete. Dashboard, authentication, and navigation are now functional.
Phase 2 will add backend APIs and real data integration.
</p>
</motion.div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
/**
* GET /api/admin/orders/[id]
* Get single order by record ID
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const order = await nocodbClient.getOrderById(params.id);
return NextResponse.json({
success: true,
data: order,
});
} catch (error) {
console.error('Error fetching order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch order',
},
{ status: 500 }
);
}
}
/**
* PATCH /api/admin/orders/[id]
* Update order fields (commonly used for status updates)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const updatedOrder = await nocodbClient.updateOrder(params.id, body);
return NextResponse.json({
success: true,
data: updatedOrder,
});
} catch (error) {
console.error('Error updating order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update order',
},
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/orders/[id]
* Delete/archive an order
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Instead of actually deleting, update status to "cancelled"
await nocodbClient.updateOrderStatus(params.id, 'cancelled');
return NextResponse.json({
success: true,
message: 'Order cancelled successfully',
});
} catch (error) {
console.error('Error cancelling order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to cancel order',
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
import type { OrderFilters } from '@/api/nocodbClient';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
/**
* GET /api/admin/orders/export
* Export orders to CSV or Excel
* Query params:
* - format: 'csv' | 'xlsx' (default: csv)
* - status: Filter by status
* - dateFrom, dateTo: Date range filter
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const format = searchParams.get('format') || 'csv';
// Build filters (same as orders list)
const filters: OrderFilters = {};
if (searchParams.get('status')) filters.status = searchParams.get('status')!;
if (searchParams.get('dateFrom')) filters.dateFrom = searchParams.get('dateFrom')!;
if (searchParams.get('dateTo')) filters.dateTo = searchParams.get('dateTo')!;
// Get all matching orders (no pagination for export)
const response = await nocodbClient.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Transform data for export
const exportData = orders.map((order) => ({
'Order ID': order.orderId,
'Status': order.status,
'Created Date': order.CreatedAt ? new Date(order.CreatedAt).toLocaleDateString() : '',
'Vessel Name': order.vesselName,
'IMO Number': order.imoNumber || '',
'Distance (NM)': order.distance || '',
'Avg Speed (kn)': order.avgSpeed || '',
'CO2 Tons': order.co2Tons || '',
'Total Amount': order.totalAmount || '',
'Currency': order.currency || '',
'Customer Name': order.customerName || '',
'Customer Email': order.customerEmail || '',
'Customer Company': order.customerCompany || '',
'Departure Port': order.departurePort || '',
'Arrival Port': order.arrivalPort || '',
'Payment Method': order.paymentMethod || '',
'Payment Reference': order.paymentReference || '',
'Wren Order ID': order.wrenOrderId || '',
'Certificate URL': order.certificateUrl || '',
'Fulfilled At': order.fulfilledAt ? new Date(order.fulfilledAt).toLocaleDateString() : '',
'Notes': order.notes || '',
}));
if (format === 'xlsx') {
// Generate Excel file
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Orders');
// Style headers (make them bold)
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
for (let col = range.s.c; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col });
if (!worksheet[cellAddress]) continue;
worksheet[cellAddress].s = {
font: { bold: true },
fill: { fgColor: { rgb: 'D3D3D3' } },
};
}
// Set column widths
worksheet['!cols'] = [
{ wch: 20 }, // Order ID
{ wch: 12 }, // Status
{ wch: 15 }, // Created Date
{ wch: 25 }, // Vessel Name
{ wch: 12 }, // IMO Number
{ wch: 12 }, // Distance
{ wch: 12 }, // Avg Speed
{ wch: 10 }, // CO2 Tons
{ wch: 12 }, // Total Amount
{ wch: 10 }, // Currency
{ wch: 20 }, // Customer Name
{ wch: 25 }, // Customer Email
{ wch: 25 }, // Customer Company
{ wch: 20 }, // Departure Port
{ wch: 20 }, // Arrival Port
{ wch: 15 }, // Payment Method
{ wch: 20 }, // Payment Reference
{ wch: 20 }, // Wren Order ID
{ wch: 30 }, // Certificate URL
{ wch: 15 }, // Fulfilled At
{ wch: 40 }, // Notes
];
// Generate buffer
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
// Return Excel file
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="puffin-orders-${Date.now()}.xlsx"`,
},
});
} else {
// Generate CSV
const csv = Papa.unparse(exportData);
// Return CSV file
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="puffin-orders-${Date.now()}.csv"`,
},
});
}
} catch (error) {
console.error('Error exporting orders:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to export orders',
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
import type { OrderFilters, PaginationParams } from '@/api/nocodbClient';
/**
* GET /api/admin/orders
* Get list of orders with filtering, sorting, and pagination
* Query params:
* - search: Text search across vessel name, IMO, order ID
* - status: Filter by order status
* - dateFrom, dateTo: Date range filter
* - limit: Page size (default: 50)
* - offset: Pagination offset
* - sortBy: Field to sort by
* - sortOrder: 'asc' | 'desc'
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const searchTerm = searchParams.get('search');
// Build filters
const filters: OrderFilters = {};
if (searchParams.get('status')) filters.status = searchParams.get('status')!;
if (searchParams.get('dateFrom')) filters.dateFrom = searchParams.get('dateFrom')!;
if (searchParams.get('dateTo')) filters.dateTo = searchParams.get('dateTo')!;
if (searchParams.get('vesselName')) filters.vesselName = searchParams.get('vesselName')!;
if (searchParams.get('imoNumber')) filters.imoNumber = searchParams.get('imoNumber')!;
// Build pagination
const pagination: PaginationParams = {
limit: parseInt(searchParams.get('limit') || '50'),
offset: parseInt(searchParams.get('offset') || '0'),
sortBy: searchParams.get('sortBy') || 'CreatedAt',
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc',
};
// Use search if provided, otherwise use filters
const response = searchTerm
? await nocodbClient.searchOrders(searchTerm, pagination)
: await nocodbClient.getOrders(filters, pagination);
return NextResponse.json({
success: true,
data: response.list,
pagination: response.pageInfo,
});
} catch (error) {
console.error('Error fetching orders:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch orders',
},
{ status: 500 }
);
}
}
/**
* POST /api/admin/orders
* Create a new order (if needed for manual entry)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// In a real implementation, you'd call nocodbClient to create the order
// For now, return a placeholder
return NextResponse.json(
{
success: false,
error: 'Order creation not yet implemented',
},
{ status: 501 }
);
} catch (error) {
console.error('Error creating order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create order',
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
/**
* GET /api/admin/stats
* Get dashboard statistics with optional time range filtering
* Query params:
* - dateFrom: ISO date string (e.g., "2024-01-01")
* - dateTo: ISO date string
* - period: 'day' | 'week' | 'month' (for timeline data)
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const dateFrom = searchParams.get('dateFrom') || undefined;
const dateTo = searchParams.get('dateTo') || undefined;
const period = (searchParams.get('period') || 'day') as 'day' | 'week' | 'month';
// Get overall stats
const stats = await nocodbClient.getStats(dateFrom, dateTo);
// Get timeline data for charts
const timeline = await nocodbClient.getOrdersTimeline(period, dateFrom, dateTo);
return NextResponse.json({
success: true,
data: {
stats,
timeline,
},
});
} catch (error) {
console.error('Error fetching admin stats:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch statistics',
},
{ status: 500 }
);
}
}

10739
docs/nocodb_api_docs.json Normal file

File diff suppressed because it is too large Load Diff

124
package-lock.json generated
View File

@ -15,15 +15,18 @@
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.344.0",
"next": "^16.0.1",
"papaparse": "^5.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^3.3.0"
"recharts": "^3.3.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/node": "24.9.2",
"@types/papaparse": "^5.3.16",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.18",
@ -2045,6 +2048,16 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/papaparse": {
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz",
"integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
@ -2487,6 +2500,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
@ -2839,6 +2861,19 @@
}
]
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
@ -2934,6 +2969,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -2981,6 +3025,18 @@
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -3972,6 +4028,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -5477,6 +5542,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -6408,6 +6479,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -7268,6 +7351,24 @@
"node": ">=8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -7419,6 +7520,27 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@ -18,15 +18,18 @@
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.344.0",
"next": "^16.0.1",
"papaparse": "^5.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^3.3.0"
"recharts": "^3.3.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/node": "24.9.2",
"@types/papaparse": "^5.3.16",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.18",

334
src/api/nocodbClient.ts Normal file
View File

@ -0,0 +1,334 @@
/**
* NocoDB Client - Clean abstraction layer for NocoDB REST API
* Hides query syntax complexity behind simple TypeScript functions
*/
interface NocoDBConfig {
baseUrl: string;
baseId: string;
apiKey: string;
ordersTableId: string;
}
interface OrderFilters {
status?: string;
vesselName?: string;
imoNumber?: string;
dateFrom?: string;
dateTo?: string;
minAmount?: number;
maxAmount?: number;
}
interface PaginationParams {
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
interface NocoDBResponse<T> {
list: T[];
pageInfo: {
totalRows: number;
page: number;
pageSize: number;
isFirstPage: boolean;
isLastPage: boolean;
};
}
interface OrderStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
ordersByStatus: {
pending: number;
paid: number;
fulfilled: number;
cancelled: number;
};
}
export class NocoDBClient {
private config: NocoDBConfig;
private baseUrl: string;
constructor() {
this.config = {
baseUrl: process.env.NOCODB_BASE_URL || '',
baseId: process.env.NOCODB_BASE_ID || '',
apiKey: process.env.NOCODB_API_KEY || '',
ordersTableId: process.env.NOCODB_ORDERS_TABLE_ID || '',
};
if (!this.config.baseUrl || !this.config.baseId || !this.config.apiKey || !this.config.ordersTableId) {
console.warn('NocoDB configuration incomplete. Some features may not work.');
}
this.baseUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records`;
}
/**
* Build NocoDB where clause from filters
*/
private buildWhereClause(filters: OrderFilters): string {
const conditions: string[] = [];
if (filters.status) {
conditions.push(`(status,eq,${filters.status})`);
}
if (filters.vesselName) {
conditions.push(`(vesselName,like,%${filters.vesselName}%)`);
}
if (filters.imoNumber) {
conditions.push(`(imoNumber,eq,${filters.imoNumber})`);
}
if (filters.dateFrom) {
conditions.push(`(CreatedAt,gte,${filters.dateFrom})`);
}
if (filters.dateTo) {
conditions.push(`(CreatedAt,lte,${filters.dateTo})`);
}
if (filters.minAmount !== undefined) {
conditions.push(`(totalAmount,gte,${filters.minAmount})`);
}
if (filters.maxAmount !== undefined) {
conditions.push(`(totalAmount,lte,${filters.maxAmount})`);
}
return conditions.length > 0 ? conditions.join('~and') : '';
}
/**
* Build sort parameter
*/
private buildSortParam(sortBy?: string, sortOrder?: 'asc' | 'desc'): string {
if (!sortBy) return '-CreatedAt'; // Default: newest first
const prefix = sortOrder === 'asc' ? '' : '-';
return `${prefix}${sortBy}`;
}
/**
* Make authenticated request to NocoDB
*/
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'xc-token': this.config.apiKey,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`NocoDB request failed: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Get list of orders with filtering, sorting, and pagination
*/
async getOrders(
filters: OrderFilters = {},
pagination: PaginationParams = {}
): Promise<NocoDBResponse<any>> {
const params = new URLSearchParams();
// Add where clause if filters exist
const whereClause = this.buildWhereClause(filters);
if (whereClause) {
params.append('where', whereClause);
}
// Add sorting
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
params.append('sort', sort);
// Add pagination
if (pagination.limit) {
params.append('limit', pagination.limit.toString());
}
if (pagination.offset) {
params.append('offset', pagination.offset.toString());
}
const queryString = params.toString();
const endpoint = queryString ? `?${queryString}` : '';
return this.request<NocoDBResponse<any>>(endpoint);
}
/**
* Get single order by ID
*/
async getOrderById(recordId: string): Promise<any> {
return this.request<any>(`/${recordId}`);
}
/**
* Search orders by text (searches vessel name, IMO, order ID)
*/
async searchOrders(
searchTerm: string,
pagination: PaginationParams = {}
): Promise<NocoDBResponse<any>> {
// Search in multiple fields using OR conditions
const searchConditions = [
`(vesselName,like,%${searchTerm}%)`,
`(imoNumber,like,%${searchTerm}%)`,
`(orderId,like,%${searchTerm}%)`,
].join('~or');
const params = new URLSearchParams();
params.append('where', searchConditions);
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
params.append('sort', sort);
if (pagination.limit) {
params.append('limit', pagination.limit.toString());
}
if (pagination.offset) {
params.append('offset', pagination.offset.toString());
}
return this.request<NocoDBResponse<any>>(`?${params.toString()}`);
}
/**
* Get order statistics for dashboard
*/
async getStats(dateFrom?: string, dateTo?: string): Promise<OrderStats> {
const filters: OrderFilters = {};
if (dateFrom) filters.dateFrom = dateFrom;
if (dateTo) filters.dateTo = dateTo;
// Get all orders with filters (no pagination for stats calculation)
const response = await this.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Calculate stats
const totalOrders = orders.length;
const totalRevenue = orders.reduce((sum, order) => sum + (order.totalAmount || 0), 0);
const totalCO2Offset = orders.reduce((sum, order) => sum + (order.co2Tons || 0), 0);
const ordersByStatus = {
pending: orders.filter((o) => o.status === 'pending').length,
paid: orders.filter((o) => o.status === 'paid').length,
fulfilled: orders.filter((o) => o.status === 'fulfilled').length,
cancelled: orders.filter((o) => o.status === 'cancelled').length,
};
const fulfillmentRate =
totalOrders > 0 ? (ordersByStatus.fulfilled / totalOrders) * 100 : 0;
return {
totalOrders,
totalRevenue,
totalCO2Offset,
fulfillmentRate,
ordersByStatus,
};
}
/**
* Update order status
*/
async updateOrderStatus(recordId: string, status: string): Promise<any> {
return this.request<any>(`/${recordId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
/**
* Update order fields
*/
async updateOrder(recordId: string, data: Record<string, any>): Promise<any> {
return this.request<any>(`/${recordId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
/**
* Get orders grouped by time period (for charts)
*/
async getOrdersTimeline(
period: 'day' | 'week' | 'month',
dateFrom?: string,
dateTo?: string
): Promise<Array<{ date: string; count: number; revenue: number }>> {
const filters: OrderFilters = {};
if (dateFrom) filters.dateFrom = dateFrom;
if (dateTo) filters.dateTo = dateTo;
const response = await this.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Group orders by time period
const grouped = new Map<string, { count: number; revenue: number }>();
orders.forEach((order) => {
const date = new Date(order.CreatedAt);
let key: string;
switch (period) {
case 'day':
key = date.toISOString().split('T')[0];
break;
case 'week':
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
key = weekStart.toISOString().split('T')[0];
break;
case 'month':
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
break;
}
const existing = grouped.get(key) || { count: 0, revenue: 0 };
grouped.set(key, {
count: existing.count + 1,
revenue: existing.revenue + (order.totalAmount || 0),
});
});
return Array.from(grouped.entries())
.map(([date, data]) => ({ date, ...data }))
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Get count of records matching filters
*/
async getCount(filters: OrderFilters = {}): Promise<number> {
const whereClause = this.buildWhereClause(filters);
const params = whereClause ? `?where=${whereClause}` : '';
const countUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records/count${params}`;
const response = await this.request<{ count: number }>(countUrl);
return response.count;
}
}
// Export singleton instance
export const nocodbClient = new NocoDBClient();
// Export types for use in other files
export type { OrderFilters, PaginationParams, NocoDBResponse, OrderStats };

View File

@ -142,3 +142,54 @@ export interface OrderDetailsResponse {
customerEmail?: string;
};
}
// NocoDB Order Record (Admin Portal)
export interface OrderRecord {
// NocoDB metadata
Id: number;
CreatedAt: string;
UpdatedAt: string;
// Order identification
orderId: string;
status: 'pending' | 'paid' | 'fulfilled' | 'cancelled';
source?: string; // 'web', 'mobile-app', 'manual'
// Vessel information
vesselName: string;
imoNumber?: string;
vesselType?: string;
vesselLength?: string;
// Trip details
departurePort?: string;
arrivalPort?: string;
distance: string;
avgSpeed: string;
duration?: string;
enginePower?: string;
// Carbon offset details
co2Tons: string;
portfolioId?: string;
portfolioName?: string;
totalAmount: string;
currency: string;
amountUSD: string;
// Customer information
customerName: string;
customerEmail: string;
customerCompany?: string;
customerPhone?: string;
// Payment & fulfillment
paymentMethod?: string;
paymentReference?: string;
wrenOrderId?: string;
certificateUrl?: string;
fulfilledAt?: string;
// Admin notes
notes?: string;
}