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
|
DEV_AUTH_SECRET=your-dev-secret-here
|
||||||
TEST_AUTH_SECRET=your-test-secret-here
|
TEST_AUTH_SECRET=your-test-secret-here
|
||||||
PROD_AUTH_SECRET=your-prod-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)
|
## Phase 2: Core Features (Est. 80-110 hrs)
|
||||||
|
|
||||||
**Progress:** 12/16 tasks complete
|
**Progress:** 14/16 tasks complete
|
||||||
|
|
||||||
### C-001: Dashboard
|
### C-001: Dashboard
|
||||||
- [x] Dashboard page at `/(portal)/dashboard/page.tsx`
|
- [x] Dashboard page at `/(portal)/dashboard/page.tsx`
|
||||||
|
|
@ -282,19 +282,23 @@
|
||||||
- **Deps:** F-005, C-003 | **Est:** 8 hrs
|
- **Deps:** F-005, C-003 | **Est:** 8 hrs
|
||||||
|
|
||||||
### C-015: Invoice Viewing (Customer)
|
### C-015: Invoice Viewing (Customer)
|
||||||
- [ ] `/(portal)/invoices/page.tsx`
|
- [x] `/(portal)/invoices/page.tsx` — client-side fetch with loading skeleton
|
||||||
- [ ] Only visible if company `CanAccessInvoices=true`
|
- [x] Only visible if company `can_access_invoices=true` (403 response + user-friendly message)
|
||||||
- [ ] List invoices from `inv_upload_entry` filtered by company
|
- [x] Service: `getInvoicesForCompany(companyId)` querying `inv_upload_entry` filtered by company
|
||||||
- [ ] PDF download endpoint `/api/invoices/:id/pdf`
|
- [x] Invoice table with search, sort, CSV export (invoice#, job#, page, date, sent date, PDF link)
|
||||||
- [ ] File serving from storage directory
|
- [x] PDF download endpoint `/api/invoices/{id}/pdf` with company security check
|
||||||
- **Deps:** F-005, F-009 | **Est:** 4 hrs
|
- [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
|
### C-016: Notifications Display
|
||||||
- [ ] Notification bell in header with unread count
|
- [x] Fixed notification bell unread count (was counting read records, now counts unread alerts)
|
||||||
- [ ] Alert banner on dashboard for IsAlert=true notifications
|
- [x] Notification service: `getActiveNotifications()`, `getUnreadAlertCount()`, `markNotificationRead()`
|
||||||
- [ ] Notification list view
|
- [x] Alert banner on dashboard uses correct unread count for current user
|
||||||
- [ ] Mark as read → create `quest_user_notification_alert_read` record
|
- [x] `/(portal)/notifications/page.tsx` — notification list with unread alerts section
|
||||||
- **Deps:** F-005, F-009 | **Est:** 3 hrs
|
- [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 { PortalHeader } from '@/components/layout/portal-header';
|
||||||
import { Breadcrumb } from '@/components/layout/breadcrumb';
|
import { Breadcrumb } from '@/components/layout/breadcrumb';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { db } from '@/lib/db';
|
import { getUnreadAlertCount } from '@/services/notifications';
|
||||||
|
|
||||||
export default async function PortalLayout({
|
export default async function PortalLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -24,14 +24,8 @@ export default async function PortalLayout({
|
||||||
// Get active company
|
// Get active company
|
||||||
const activeCompany = await getActiveCompany();
|
const activeCompany = await getActiveCompany();
|
||||||
|
|
||||||
// Get unread notifications count
|
// Get unread alert notifications count (active alerts not yet read by this user)
|
||||||
const unreadNotifications = await db.quest_user_notification_alert_read.count(
|
const unreadNotifications = await getUnreadAlertCount(session.user.id);
|
||||||
{
|
|
||||||
where: {
|
|
||||||
auth_user_id: session.user.id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session.userType === 'Admin' || session.userType === 'Super Admin';
|
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,
|
getInventorySummary,
|
||||||
} from '@/services/dashboard';
|
} from '@/services/dashboard';
|
||||||
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
|
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
|
||||||
import { db } from '@/lib/db';
|
import { getUnreadAlertCount } from '@/services/notifications';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -28,16 +28,7 @@ export async function GET() {
|
||||||
unprocessed_count: 0,
|
unprocessed_count: 0,
|
||||||
total_weight: 0,
|
total_weight: 0,
|
||||||
})),
|
})),
|
||||||
db.quest_notification
|
getUnreadAlertCount(session.user.id).catch(() => 0),
|
||||||
.count({
|
|
||||||
where: {
|
|
||||||
is_alert: true,
|
|
||||||
created_at: {
|
|
||||||
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(() => 0),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
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