feat: add invoice viewing (C-015) and notifications display (C-016)
- Invoice page with table, search, sort, CSV export, PDF download - Company-level access control (can_access_invoices + view_invoices permission) - Notifications page with unread alerts section and mark-as-read - Fix unread notification count in layout (was counting read markers) - Fix dashboard notification count to use per-user unread alerts - Configurable INVOICE_STORAGE_PATH for invoice PDF file serving Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1198aaa373
commit
b32553668d
17 changed files with 881 additions and 32 deletions
|
|
@ -15,3 +15,6 @@ TRAEFIK_DOMAIN=traefik.vorteq.wulf.cloud
|
|||
DEV_AUTH_SECRET=your-dev-secret-here
|
||||
TEST_AUTH_SECRET=your-test-secret-here
|
||||
PROD_AUTH_SECRET=your-prod-secret-here
|
||||
|
||||
# Invoice PDF Storage Path (where uploaded invoice PDFs are stored)
|
||||
INVOICE_STORAGE_PATH=./storage/invoices
|
||||
|
|
|
|||
28
TASKS.md
28
TASKS.md
|
|
@ -128,7 +128,7 @@
|
|||
|
||||
## Phase 2: Core Features (Est. 80-110 hrs)
|
||||
|
||||
**Progress:** 12/16 tasks complete
|
||||
**Progress:** 14/16 tasks complete
|
||||
|
||||
### C-001: Dashboard
|
||||
- [x] Dashboard page at `/(portal)/dashboard/page.tsx`
|
||||
|
|
@ -282,19 +282,23 @@
|
|||
- **Deps:** F-005, C-003 | **Est:** 8 hrs
|
||||
|
||||
### C-015: Invoice Viewing (Customer)
|
||||
- [ ] `/(portal)/invoices/page.tsx`
|
||||
- [ ] Only visible if company `CanAccessInvoices=true`
|
||||
- [ ] List invoices from `inv_upload_entry` filtered by company
|
||||
- [ ] PDF download endpoint `/api/invoices/:id/pdf`
|
||||
- [ ] File serving from storage directory
|
||||
- **Deps:** F-005, F-009 | **Est:** 4 hrs
|
||||
- [x] `/(portal)/invoices/page.tsx` — client-side fetch with loading skeleton
|
||||
- [x] Only visible if company `can_access_invoices=true` (403 response + user-friendly message)
|
||||
- [x] Service: `getInvoicesForCompany(companyId)` querying `inv_upload_entry` filtered by company
|
||||
- [x] Invoice table with search, sort, CSV export (invoice#, job#, page, date, sent date, PDF link)
|
||||
- [x] PDF download endpoint `/api/invoices/{id}/pdf` with company security check
|
||||
- [x] File serving from configurable `INVOICE_STORAGE_PATH` storage directory
|
||||
- [x] Permission check: `view_invoices` + `can_access_invoices`
|
||||
- **Deps:** F-005, F-009 | **Est:** 4 hrs | **Status:** ✅ Complete
|
||||
|
||||
### C-016: Notifications Display
|
||||
- [ ] Notification bell in header with unread count
|
||||
- [ ] Alert banner on dashboard for IsAlert=true notifications
|
||||
- [ ] Notification list view
|
||||
- [ ] Mark as read → create `quest_user_notification_alert_read` record
|
||||
- **Deps:** F-005, F-009 | **Est:** 3 hrs
|
||||
- [x] Fixed notification bell unread count (was counting read records, now counts unread alerts)
|
||||
- [x] Notification service: `getActiveNotifications()`, `getUnreadAlertCount()`, `markNotificationRead()`
|
||||
- [x] Alert banner on dashboard uses correct unread count for current user
|
||||
- [x] `/(portal)/notifications/page.tsx` — notification list with unread alerts section
|
||||
- [x] Mark as read → upsert `quest_user_notification_alert_read` record
|
||||
- [x] API routes: `/api/notifications` (GET list) + `/api/notifications/{id}/read` (POST mark read)
|
||||
- **Deps:** F-005, F-009 | **Est:** 3 hrs | **Status:** ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
88
src/app/(portal)/invoices/page.tsx
Normal file
88
src/app/(portal)/invoices/page.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { InvoiceTable } from '@/components/invoices/invoice-table';
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { InvoiceEntry } from '@/types/invoices';
|
||||
|
||||
function InvoiceSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-muted" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 w-full animate-pulse rounded bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [invoices, setInvoices] = useState<InvoiceEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/invoices')
|
||||
.then((res) => {
|
||||
if (res.status === 403) {
|
||||
setAccessDenied(true);
|
||||
return [];
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
if (!accessDenied) setInvoices(json);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (accessDenied) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-3xl font-bold">Invoices</h1>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="mb-4 h-12 w-12 text-muted-foreground/40" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
Invoice access is not available
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your company does not have invoice viewing enabled. Please contact
|
||||
Vorteq support if you need access to invoices.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-3xl font-bold">Invoices</h1>
|
||||
{error ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-destructive">
|
||||
Failed to load invoices: {error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : invoices === null ? (
|
||||
<InvoiceSkeleton />
|
||||
) : (
|
||||
<InvoiceTable data={invoices} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { PortalSidebar } from '@/components/layout/portal-sidebar';
|
|||
import { PortalHeader } from '@/components/layout/portal-header';
|
||||
import { Breadcrumb } from '@/components/layout/breadcrumb';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { db } from '@/lib/db';
|
||||
import { getUnreadAlertCount } from '@/services/notifications';
|
||||
|
||||
export default async function PortalLayout({
|
||||
children,
|
||||
|
|
@ -24,14 +24,8 @@ export default async function PortalLayout({
|
|||
// Get active company
|
||||
const activeCompany = await getActiveCompany();
|
||||
|
||||
// Get unread notifications count
|
||||
const unreadNotifications = await db.quest_user_notification_alert_read.count(
|
||||
{
|
||||
where: {
|
||||
auth_user_id: session.user.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
// Get unread alert notifications count (active alerts not yet read by this user)
|
||||
const unreadNotifications = await getUnreadAlertCount(session.user.id);
|
||||
|
||||
const isAdmin =
|
||||
session.userType === 'Admin' || session.userType === 'Super Admin';
|
||||
|
|
|
|||
70
src/app/(portal)/notifications/page.tsx
Normal file
70
src/app/(portal)/notifications/page.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { NotificationList } from '@/components/notifications/notification-list';
|
||||
import type { NotificationItem } from '@/types/notifications';
|
||||
|
||||
function NotificationsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-2">
|
||||
<div className="h-5 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[] | null>(
|
||||
null
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/notifications')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => setNotifications(json))
|
||||
.catch((err) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
const res = await fetch(`/api/notifications/${id}/read`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to mark notification as read');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-3xl font-bold">Notifications</h1>
|
||||
{error ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-destructive">
|
||||
Failed to load notifications: {error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : notifications === null ? (
|
||||
<NotificationsSkeleton />
|
||||
) : (
|
||||
<NotificationList
|
||||
notifications={notifications}
|
||||
onMarkRead={handleMarkRead}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
getInventorySummary,
|
||||
} from '@/services/dashboard';
|
||||
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
|
||||
import { db } from '@/lib/db';
|
||||
import { getUnreadAlertCount } from '@/services/notifications';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
@ -28,16 +28,7 @@ export async function GET() {
|
|||
unprocessed_count: 0,
|
||||
total_weight: 0,
|
||||
})),
|
||||
db.quest_notification
|
||||
.count({
|
||||
where: {
|
||||
is_alert: true,
|
||||
created_at: {
|
||||
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(() => 0),
|
||||
getUnreadAlertCount(session.user.id).catch(() => 0),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
|
|||
52
src/app/api/invoices/[id]/pdf/route.ts
Normal file
52
src/app/api/invoices/[id]/pdf/route.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions';
|
||||
import { getInvoiceFile } from '@/services/invoices';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getQuestSession();
|
||||
const activeCompany = await getActiveCompany();
|
||||
|
||||
if (!session || !activeCompany) {
|
||||
return NextResponse.json({ error: 'No active company' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!activeCompany.can_access_invoices) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invoice access is not enabled for this company' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission('view_invoices');
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const file = await getInvoiceFile(id, activeCompany.id);
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invoice file not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(file.buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="${file.filename}"`,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/invoices/route.ts
Normal file
36
src/app/api/invoices/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions';
|
||||
import { getInvoicesForCompany } from '@/services/invoices';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getQuestSession();
|
||||
const activeCompany = await getActiveCompany();
|
||||
|
||||
if (!session || !activeCompany) {
|
||||
return NextResponse.json({ error: 'No active company' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check company-level access
|
||||
if (!activeCompany.can_access_invoices) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invoice access is not enabled for this company' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission('view_invoices');
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const invoices = await getInvoicesForCompany(activeCompany.id);
|
||||
return NextResponse.json(invoices);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
24
src/app/api/notifications/[id]/read/route.ts
Normal file
24
src/app/api/notifications/[id]/read/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getQuestSession } from '@/lib/permissions';
|
||||
import { markNotificationRead } from '@/services/notifications';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getQuestSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
await markNotificationRead(id, session.user.id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
src/app/api/notifications/route.ts
Normal file
21
src/app/api/notifications/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getQuestSession } from '@/lib/permissions';
|
||||
import { getActiveNotifications } from '@/services/notifications';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getQuestSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const notifications = await getActiveNotifications(session.user.id);
|
||||
return NextResponse.json(notifications);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
208
src/components/invoices/invoice-table.tsx
Normal file
208
src/components/invoices/invoice-table.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { InvoiceEntry } from '@/types/invoices';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Download, FileText, Search } from 'lucide-react';
|
||||
import {
|
||||
SortableTableHead,
|
||||
useSortableTable,
|
||||
} from '@/components/ui/sortable-table-head';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
data: InvoiceEntry[];
|
||||
};
|
||||
|
||||
export function InvoiceTable({ data }: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredData = data.filter((row) => {
|
||||
const s = searchTerm.toLowerCase();
|
||||
return (
|
||||
row.invoice_number.toLowerCase().includes(s) ||
|
||||
(row.job_number || '').toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
const { sortKey, sortDirection, handleSort, sortedData } =
|
||||
useSortableTable(filteredData);
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const headers = [
|
||||
'Invoice #',
|
||||
'Job #',
|
||||
'Page',
|
||||
'Date',
|
||||
'Sent Date',
|
||||
];
|
||||
const rows = sortedData.map((row) => [
|
||||
row.invoice_number,
|
||||
row.job_number || '',
|
||||
row.page_number ?? '',
|
||||
row.created_at ? new Date(row.created_at).toLocaleDateString() : '',
|
||||
row.sent_at ? new Date(row.sent_at).toLocaleDateString() : '',
|
||||
]);
|
||||
const csvContent = [headers, ...rows]
|
||||
.map((row) => row.map((cell) => `"${cell}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `invoices-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invoices</CardTitle>
|
||||
<CardDescription>
|
||||
Showing {sortedData.length} invoice{sortedData.length !== 1 ? 's' : ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search invoice #, job #..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleExportCSV} variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<thead>
|
||||
<tr className="bg-teal-700 text-white">
|
||||
<SortableTableHead
|
||||
sortKey="invoice_number"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Invoice #
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
sortKey="job_number"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Job #
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
sortKey="page_number"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
className="text-right"
|
||||
>
|
||||
Page
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
sortKey="created_at"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Date
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
sortKey="sent_at"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Sent Date
|
||||
</SortableTableHead>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium">
|
||||
PDF
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{sortedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No invoices found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedData.map((row, i) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={i % 2 === 0 ? 'bg-muted/30' : ''}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{row.invoice_number}
|
||||
</TableCell>
|
||||
<TableCell>{row.job_number || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.page_number ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.created_at
|
||||
? formatDate(new Date(row.created_at))
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.sent_at
|
||||
? formatDate(new Date(row.sent_at))
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.has_file ? (
|
||||
<a
|
||||
href={`/api/invoices/${row.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-teal-700 hover:text-teal-900"
|
||||
title="Download PDF"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
Showing {sortedData.length} of {data.length} invoices
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
188
src/components/notifications/notification-list.tsx
Normal file
188
src/components/notifications/notification-list.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { NotificationItem } from '@/types/notifications';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertTriangle, Bell, CheckCircle } from 'lucide-react';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
notifications: NotificationItem[];
|
||||
onMarkRead: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function NotificationList({ notifications, onMarkRead }: Props) {
|
||||
const [markingRead, setMarkingRead] = useState<string | null>(null);
|
||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
setMarkingRead(id);
|
||||
try {
|
||||
await onMarkRead(id);
|
||||
setReadIds((prev) => new Set(prev).add(id));
|
||||
} finally {
|
||||
setMarkingRead(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unreadAlerts = notifications.filter(
|
||||
(n) => n.is_alert && !n.is_read && !readIds.has(n.id)
|
||||
);
|
||||
const otherNotifications = notifications.filter(
|
||||
(n) => !n.is_alert || n.is_read || readIds.has(n.id)
|
||||
);
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Bell className="mb-4 h-12 w-12 text-muted-foreground/40" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
No notifications
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You're all caught up!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Unread Alerts Section */}
|
||||
{unreadAlerts.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold text-amber-700">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Unread Alerts ({unreadAlerts.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{unreadAlerts.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className="border-amber-200 bg-amber-50"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">
|
||||
{notification.title}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Alert
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMarkRead(notification.id)}
|
||||
disabled={markingRead === notification.id}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 w-4" />
|
||||
{markingRead === notification.id
|
||||
? 'Marking...'
|
||||
: 'Mark as Read'}
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{formatDate(new Date(notification.display_date))}
|
||||
{notification.display_time
|
||||
? ` at ${notification.display_time}`
|
||||
: ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm">
|
||||
{notification.text}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Notifications */}
|
||||
{otherNotifications.length > 0 && (
|
||||
<div>
|
||||
{unreadAlerts.length > 0 && (
|
||||
<h2 className="mb-3 text-lg font-semibold text-muted-foreground">
|
||||
All Notifications
|
||||
</h2>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{otherNotifications.map((notification) => {
|
||||
const isRead = notification.is_read || readIds.has(notification.id);
|
||||
return (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={isRead ? 'opacity-75' : ''}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle
|
||||
className={`text-base ${isRead ? 'font-normal' : ''}`}
|
||||
>
|
||||
{notification.title}
|
||||
</CardTitle>
|
||||
{notification.is_alert && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Alert
|
||||
</Badge>
|
||||
)}
|
||||
{isRead && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Read
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{notification.is_alert && !isRead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMarkRead(notification.id)}
|
||||
disabled={markingRead === notification.id}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 w-4" />
|
||||
{markingRead === notification.id
|
||||
? 'Marking...'
|
||||
: 'Mark as Read'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{formatDate(new Date(notification.display_date))}
|
||||
{notification.display_time
|
||||
? ` at ${notification.display_time}`
|
||||
: ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{notification.text}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/lib/storage.ts
Normal file
10
src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Get the base storage path for invoice PDF files.
|
||||
* In production, this should point to the mounted storage volume.
|
||||
* In development, falls back to a local directory.
|
||||
*/
|
||||
export function getInvoiceStoragePath(): string {
|
||||
return process.env.INVOICE_STORAGE_PATH || path.join(process.cwd(), 'storage', 'invoices');
|
||||
}
|
||||
68
src/services/invoices.ts
Normal file
68
src/services/invoices.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { db } from '@/lib/db';
|
||||
import { getInvoiceStoragePath } from '@/lib/storage';
|
||||
import type { InvoiceEntry } from '@/types/invoices';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Get all invoices for a company.
|
||||
* Filters out ignored and replaced entries.
|
||||
* Returns newest first.
|
||||
*/
|
||||
export async function getInvoicesForCompany(
|
||||
companyId: string
|
||||
): Promise<InvoiceEntry[]> {
|
||||
const entries = await db.inv_upload_entry.findMany({
|
||||
where: {
|
||||
quest_company_id: companyId,
|
||||
is_ignored: false,
|
||||
is_replaced: false,
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
|
||||
return entries.map((e) => ({
|
||||
id: e.id,
|
||||
invoice_number: e.invoice_number,
|
||||
job_number: e.job_number,
|
||||
page_number: e.page_number,
|
||||
created_at: e.created_at.toISOString(),
|
||||
sent_at: e.sent_at ? e.sent_at.toISOString() : null,
|
||||
has_file: !!(e.file_path && e.storage_filename),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an invoice PDF file for download.
|
||||
* Verifies the entry belongs to the specified company for security.
|
||||
* Returns the file buffer and filename, or null if not found.
|
||||
*/
|
||||
export async function getInvoiceFile(
|
||||
entryId: string,
|
||||
companyId: string
|
||||
): Promise<{ buffer: Buffer; filename: string } | null> {
|
||||
const entry = await db.inv_upload_entry.findFirst({
|
||||
where: {
|
||||
id: entryId,
|
||||
quest_company_id: companyId,
|
||||
is_ignored: false,
|
||||
is_replaced: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry || !entry.file_path || !entry.storage_filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storagePath = getInvoiceStoragePath();
|
||||
const filePath = path.join(storagePath, entry.file_path);
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
const filename = `invoice-${entry.invoice_number}.pdf`;
|
||||
return { buffer, filename };
|
||||
} catch {
|
||||
// File doesn't exist on disk
|
||||
return null;
|
||||
}
|
||||
}
|
||||
74
src/services/notifications.ts
Normal file
74
src/services/notifications.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { db } from '@/lib/db';
|
||||
import type { NotificationItem } from '@/types/notifications';
|
||||
|
||||
/**
|
||||
* Get all active notifications with read status for a specific user.
|
||||
* Returns notifications where display_date <= now, sorted newest first.
|
||||
*/
|
||||
export async function getActiveNotifications(
|
||||
userId: string
|
||||
): Promise<NotificationItem[]> {
|
||||
const notifications = await db.quest_notification.findMany({
|
||||
where: {
|
||||
is_active: true,
|
||||
display_date: { lte: new Date() },
|
||||
},
|
||||
include: {
|
||||
alert_reads: {
|
||||
where: { auth_user_id: userId },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { display_date: 'desc' },
|
||||
});
|
||||
|
||||
return notifications.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
text: n.text,
|
||||
is_alert: n.is_alert,
|
||||
display_date: n.display_date.toISOString(),
|
||||
display_time: n.display_time,
|
||||
is_read: n.alert_reads.length > 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unread alert notifications for a user.
|
||||
* Counts active alerts where display_date <= now and user has NOT read them.
|
||||
*/
|
||||
export async function getUnreadAlertCount(userId: string): Promise<number> {
|
||||
return db.quest_notification.count({
|
||||
where: {
|
||||
is_active: true,
|
||||
is_alert: true,
|
||||
display_date: { lte: new Date() },
|
||||
alert_reads: {
|
||||
none: { auth_user_id: userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read for a specific user.
|
||||
* Uses upsert to avoid duplicate read records.
|
||||
*/
|
||||
export async function markNotificationRead(
|
||||
notificationId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await db.quest_user_notification_alert_read.upsert({
|
||||
where: {
|
||||
quest_notification_id_auth_user_id: {
|
||||
quest_notification_id: notificationId,
|
||||
auth_user_id: userId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
quest_notification_id: notificationId,
|
||||
auth_user_id: userId,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
9
src/types/invoices.ts
Normal file
9
src/types/invoices.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export type InvoiceEntry = {
|
||||
id: string;
|
||||
invoice_number: string;
|
||||
job_number: string | null;
|
||||
page_number: number | null;
|
||||
created_at: string;
|
||||
sent_at: string | null;
|
||||
has_file: boolean;
|
||||
};
|
||||
9
src/types/notifications.ts
Normal file
9
src/types/notifications.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export type NotificationItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
text: string;
|
||||
is_alert: boolean;
|
||||
display_date: string;
|
||||
display_time: string | null;
|
||||
is_read: boolean;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue