Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions apps/web/app/(portal)/mypage/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,21 @@ export default async function MyInvoicesPage() {
>
Pay Now — Card / Apple Pay
</Link>
{/* PDF download */}
{/* View / download PDF */}
{inv.pdfUrl && !inv.pdfUrl.startsWith('data:') && (
<a
href={inv.pdfUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost btn-sm"
>
Download PDF
</a>
<>
<Link href={`/docs/invoice/${inv.id}`} className="btn btn-ghost btn-sm">
View Invoice
</Link>
<a
href={inv.pdfUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost btn-sm"
>
Download PDF
</a>
</>
)}
</div>

Expand Down Expand Up @@ -159,9 +164,14 @@ export default async function MyInvoicesPage() {
</td>
<td>
{inv.pdfUrl && !inv.pdfUrl.startsWith('data:') ? (
<a href={inv.pdfUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--copper-deep)', fontWeight: 600, fontSize: '0.85rem' }}>
Download
</a>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Link href={`/docs/invoice/${inv.id}`} style={{ color: 'var(--copper-deep)', fontWeight: 600, fontSize: '0.85rem' }}>
View
</Link>
<a href={inv.pdfUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--muted)', fontWeight: 600, fontSize: '0.85rem' }}>
PDF
</a>
</div>
) : (
<span style={{ color: 'var(--muted-soft)', fontSize: '0.85rem' }}>—</span>
)}
Expand Down
43 changes: 28 additions & 15 deletions apps/web/app/(portal)/mypage/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,26 @@ export default async function MyProjectsPage() {
</div>

{/* Contract PDFs */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{proj.contractPdfUrl && !proj.contractPdfUrl.startsWith('data:') && (
<a href={proj.contractPdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
📄 Download Contract
</a>
)}
{proj.signedContractPdfUrl && !proj.signedContractPdfUrl.startsWith('data:') && (
<a href={proj.signedContractPdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
✅ Signed Contract
</a>
)}
</div>
{(proj.contractPdfUrl || proj.signedContractPdfUrl) && (
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{(proj.contractPdfUrl && !proj.contractPdfUrl.startsWith('data:')) ||
(proj.signedContractPdfUrl && !proj.signedContractPdfUrl.startsWith('data:')) ? (
<Link href={`/docs/contract/${proj.id}`} className="btn btn-ghost btn-sm">
📄 View Contract
</Link>
) : null}
{proj.contractPdfUrl && !proj.contractPdfUrl.startsWith('data:') && (
<a href={proj.contractPdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm" style={{ fontSize: '0.8rem' }}>
Download PDF
</a>
)}
{proj.signedContractPdfUrl && !proj.signedContractPdfUrl.startsWith('data:') && (
<a href={proj.signedContractPdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm" style={{ fontSize: '0.8rem' }}>
✅ Signed PDF
</a>
)}
</div>
)}

{/* Invoices */}
{proj.invoices.length > 0 && (
Expand All @@ -179,9 +187,14 @@ export default async function MyProjectsPage() {
</Link>
)}
{inv.pdfUrl && !inv.pdfUrl.startsWith('data:') && (
<a href={inv.pdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm" style={{ padding: '0.3rem 0.75rem' }}>
PDF
</a>
<>
<Link href={`/docs/invoice/${inv.id}`} className="btn btn-ghost btn-sm" style={{ padding: '0.3rem 0.75rem' }}>
View
</Link>
<a href={inv.pdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm" style={{ padding: '0.3rem 0.75rem' }}>
PDF
</a>
</>
)}
</div>
</div>
Expand Down
11 changes: 8 additions & 3 deletions apps/web/app/(portal)/mypage/quotes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,14 @@ export default async function MyQuotesPage() {
)}
</div>
</div>
<a href={(q as { quotePdfUrl: string }).quotePdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-copper btn-sm">
Download Estimate PDF
</a>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Link href={`/docs/quote/${q.id}`} className="btn btn-copper btn-sm">
View Estimate
</Link>
<a href={(q as { quotePdfUrl: string }).quotePdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
Download PDF
</a>
</div>
</div>
)}

Expand Down
3 changes: 3 additions & 0 deletions apps/web/app/admin/projects/[id]/GenerateContractButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export default function GenerateContractButton({ projectId, contractPdfUrl: init

{pdfUrl && !pdfUrl.startsWith('data:') && (
<>
<a href={`/docs/contract/${projectId}`} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
🔗 Public Link
</a>
<a href={pdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
📄 View PDF
</a>
Expand Down
22 changes: 16 additions & 6 deletions apps/web/app/admin/projects/[id]/InvoiceIssueForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,14 @@ export default function InvoiceIssueForm({
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<span style={{ color: 'var(--muted)', fontSize: '0.82rem' }}>—</span>
{invoice.pdfUrl && (
<a href={invoice.pdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
PDF
</a>
<>
<a href={`/docs/invoice/${invoice.id}`} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
🔗
</a>
<a href={invoice.pdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
PDF
</a>
</>
)}
</div>
);
Expand All @@ -131,9 +136,14 @@ export default function InvoiceIssueForm({
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
{/* View PDF */}
{invoice.pdfUrl && (
<a href={invoice.pdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
PDF
</a>
<>
<a href={`/docs/invoice/${invoice.id}`} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
🔗 Public
</a>
<a href={invoice.pdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
PDF
</a>
</>
)}

{/* DRAFT: Issue */}
Expand Down
11 changes: 8 additions & 3 deletions apps/web/app/admin/quotes/[id]/EstimateBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,14 @@ export default function EstimateBuilder({
</h2>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{quotePdfUrl && !quotePdfUrl.startsWith('data:') && (
<a href={quotePdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
📄 View PDF
</a>
<>
<a href={`/docs/quote/${quoteId}`} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
🔗 Public Link
</a>
<a href={quotePdfUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
📄 View PDF
</a>
</>
)}
{!isEditing && existingAmount && (
<button onClick={() => setIsEditing(true)} className="btn btn-ghost btn-sm">
Expand Down
58 changes: 58 additions & 0 deletions apps/web/app/api/docs/contract/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const runtime = 'nodejs';

export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return new NextResponse('Unauthorized', { status: 401 });
}

const { id } = await params;

const project = await db.project.findUnique({
where: { id },
select: {
contractPdfUrl: true,
signedContractPdfUrl: true,
title: true,
userId: true,
},
});

if (!project) return new NextResponse('Not found', { status: 404 });

const isAdmin = session.user.role === 'ADMIN';
const isOwner = project.userId === session.user.id;
if (!isAdmin && !isOwner) {
return new NextResponse('Forbidden', { status: 403 });
}

// Prefer signed contract; fall back to unsigned
const pdfUrl = project.signedContractPdfUrl ?? project.contractPdfUrl;

if (!pdfUrl || pdfUrl.startsWith('data:') || !pdfUrl.startsWith('http')) {
return new NextResponse('Not found', { status: 404 });
}

const blobRes = await fetch(pdfUrl);
if (!blobRes.ok) return new NextResponse('PDF unavailable', { status: 404 });

const isDownload = new URL(req.url).searchParams.get('dl') === '1';
const filename = `contract-${project.title.replace(/[^a-zA-Z0-9]/g, '-')}.pdf`;

return new NextResponse(blobRes.body, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': isDownload
? `attachment; filename="${filename}"`
: 'inline',
'Cache-Control': 'private, max-age=3600',
},
});
}
53 changes: 53 additions & 0 deletions apps/web/app/api/docs/invoice/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const runtime = 'nodejs';

export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return new NextResponse('Unauthorized', { status: 401 });
}

const { id } = await params;

const invoice = await db.invoice.findUnique({
where: { id },
select: {
pdfUrl: true,
number: true,
project: { select: { userId: true } },
},
});

if (!invoice?.pdfUrl || invoice.pdfUrl.startsWith('data:') || !invoice.pdfUrl.startsWith('http')) {
return new NextResponse('Not found', { status: 404 });
}

// Admin can view all; customers can only view their own
const isAdmin = session.user.role === 'ADMIN';
const isOwner = invoice.project.userId === session.user.id;
if (!isAdmin && !isOwner) {
return new NextResponse('Forbidden', { status: 403 });
}

const blobRes = await fetch(invoice.pdfUrl);
if (!blobRes.ok) return new NextResponse('PDF unavailable', { status: 404 });

const isDownload = new URL(req.url).searchParams.get('dl') === '1';
const filename = `invoice-${invoice.number ?? id}.pdf`;

return new NextResponse(blobRes.body, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': isDownload
? `attachment; filename="${filename}"`
: 'inline',
'Cache-Control': 'private, max-age=3600',
},
});
}
51 changes: 51 additions & 0 deletions apps/web/app/api/docs/quote/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const runtime = 'nodejs';

export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return new NextResponse('Unauthorized', { status: 401 });
}

const { id } = await params;

const quote = await db.quoteRequest.findUnique({
where: { id },
select: { quotePdfUrl: true, userId: true, email: true },
});

const pdfUrl = quote?.quotePdfUrl as string | null | undefined;

if (!pdfUrl || pdfUrl.startsWith('data:') || !pdfUrl.startsWith('http')) {
return new NextResponse('Not found', { status: 404 });
}

const isAdmin = session.user.role === 'ADMIN';
const isOwner =
(quote != null && quote.userId != null && quote.userId === session.user.id) ||
(quote != null && quote.email === session.user.email);
if (!isAdmin && !isOwner) {
return new NextResponse('Forbidden', { status: 403 });
}

const blobRes = await fetch(pdfUrl);
if (!blobRes.ok) return new NextResponse('PDF unavailable', { status: 404 });

const isDownload = new URL(req.url).searchParams.get('dl') === '1';

return new NextResponse(blobRes.body, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': isDownload
? `attachment; filename="estimate-${id}.pdf"`
: 'inline',
'Cache-Control': 'private, max-age=3600',
},
});
}
Loading
Loading