Skip to content

Commit 4f8e939

Browse files
committed
feat(phase-14/15/16): PDF controller, import/export, dashboard tests
Phase 14: PdfController — invoice, purchase-order, payslip PDF download endpoints; payslip.blade.php PDF view Phase 15: ImportExportController — export products/contacts/invoices as xlsx; import products/contacts from CSV; ProductsImport, ContactsImport Phase 16: DashboardAnalyticsTest (9 tests) — module_stats, activity_feed, cross-tenant scoping assertions; PDF and import/export routes wired Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d408e8e commit 4f8e939

7 files changed

Lines changed: 434 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Exports\ContactsExport;
6+
use App\Exports\InvoicesExport;
7+
use App\Exports\ProductsExport;
8+
use App\Imports\ContactsImport;
9+
use App\Imports\ProductsImport;
10+
use Illuminate\Http\JsonResponse;
11+
use Illuminate\Http\Request;
12+
use Maatwebsite\Excel\Facades\Excel;
13+
14+
class ImportExportController
15+
{
16+
private function tenantId(Request $request): int
17+
{
18+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
19+
}
20+
21+
public function exportProducts(Request $request)
22+
{
23+
return Excel::download(new ProductsExport($this->tenantId($request)), 'products.xlsx');
24+
}
25+
26+
public function exportContacts(Request $request)
27+
{
28+
return Excel::download(new ContactsExport($this->tenantId($request)), 'contacts.xlsx');
29+
}
30+
31+
public function exportInvoices(Request $request)
32+
{
33+
return Excel::download(new InvoicesExport($this->tenantId($request)), 'invoices.xlsx');
34+
}
35+
36+
public function importProducts(Request $request): JsonResponse
37+
{
38+
$request->validate([
39+
'file' => 'required|file|mimes:csv,xlsx',
40+
]);
41+
42+
Excel::import(new ProductsImport($this->tenantId($request)), $request->file('file'));
43+
44+
return response()->json(['success' => true, 'message' => 'Products imported successfully.']);
45+
}
46+
47+
public function importContacts(Request $request): JsonResponse
48+
{
49+
$request->validate([
50+
'file' => 'required|file|mimes:csv,xlsx',
51+
]);
52+
53+
Excel::import(new ContactsImport($this->tenantId($request)), $request->file('file'));
54+
55+
return response()->json(['success' => true, 'message' => 'Contacts imported successfully.']);
56+
}
57+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Finance\Models\Invoice;
6+
use App\Modules\HR\Models\PayrollRun;
7+
use App\Modules\Purchase\Models\Po;
8+
use Barryvdh\DomPDF\Facade\Pdf;
9+
use Illuminate\Http\Request;
10+
use Symfony\Component\HttpFoundation\StreamedResponse;
11+
12+
class PdfController extends ApiController
13+
{
14+
/**
15+
* GET /api/v1/pdf/invoices/{id}
16+
* Generate and download an invoice PDF.
17+
*/
18+
public function invoice(Request $request, int $id): mixed
19+
{
20+
$invoice = Invoice::with(['items', 'contact'])->findOrFail($id);
21+
22+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
23+
24+
if ((int) $invoice->tenant_id !== (int) $tenantId) {
25+
abort(403, 'Access denied.');
26+
}
27+
28+
$items = $invoice->items;
29+
30+
$pdf = Pdf::loadView('pdfs.invoice', compact('invoice', 'items'));
31+
32+
return $pdf->download("invoice-{$invoice->number}.pdf");
33+
}
34+
35+
/**
36+
* GET /api/v1/pdf/purchase-orders/{id}
37+
* Generate and download a purchase order PDF.
38+
*/
39+
public function purchaseOrder(Request $request, int $id): mixed
40+
{
41+
$po = Po::with(['lines', 'vendor'])->findOrFail($id);
42+
43+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
44+
45+
if ((int) $po->tenant_id !== (int) $tenantId) {
46+
abort(403, 'Access denied.');
47+
}
48+
49+
$pdf = Pdf::loadView('pdfs.purchase-order', compact('po'));
50+
51+
return $pdf->download("po-{$po->po_number}.pdf");
52+
}
53+
54+
/**
55+
* GET /api/v1/pdf/payslips/{id}
56+
* Generate and download a payslip PDF.
57+
*/
58+
public function payslip(Request $request, int $id): mixed
59+
{
60+
$payrollRun = PayrollRun::findOrFail($id);
61+
62+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
63+
64+
if ((int) $payrollRun->tenant_id !== (int) $tenantId) {
65+
abort(403, 'Access denied.');
66+
}
67+
68+
$pdf = Pdf::loadView('pdfs.payslip', compact('payrollRun'));
69+
70+
return $pdf->download("payslip-{$payrollRun->id}.pdf");
71+
}
72+
}

erp/app/Imports/ContactsImport.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace App\Imports;
4+
5+
use App\Modules\Finance\Models\Contact;
6+
use Maatwebsite\Excel\Concerns\ToModel;
7+
use Maatwebsite\Excel\Concerns\WithHeadingRow;
8+
use Maatwebsite\Excel\Concerns\SkipsOnError;
9+
use Maatwebsite\Excel\Concerns\SkipsErrors;
10+
11+
class ContactsImport implements ToModel, WithHeadingRow, SkipsOnError
12+
{
13+
use SkipsErrors;
14+
15+
public function __construct(private readonly int $tenantId) {}
16+
17+
public function model(array $row): ?Contact
18+
{
19+
return new Contact([
20+
'tenant_id' => $this->tenantId,
21+
'name' => $row['name'] ?? null,
22+
'email' => $row['email'] ?? null,
23+
'type' => $row['type'] ?? 'customer',
24+
]);
25+
}
26+
}

erp/app/Imports/ProductsImport.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace App\Imports;
4+
5+
use App\Modules\Inventory\Models\Product;
6+
use Maatwebsite\Excel\Concerns\ToModel;
7+
use Maatwebsite\Excel\Concerns\WithHeadingRow;
8+
use Maatwebsite\Excel\Concerns\SkipsOnError;
9+
use Maatwebsite\Excel\Concerns\SkipsErrors;
10+
11+
class ProductsImport implements ToModel, WithHeadingRow, SkipsOnError
12+
{
13+
use SkipsErrors;
14+
15+
public function __construct(private readonly int $tenantId) {}
16+
17+
public function model(array $row): ?Product
18+
{
19+
return new Product([
20+
'tenant_id' => $this->tenantId,
21+
'sku' => $row['sku'] ?? null,
22+
'name' => $row['name'] ?? null,
23+
]);
24+
}
25+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Payslip - {{ $payrollRun->period_label ?? 'Payroll Run' }}</title>
7+
<style>
8+
* { margin: 0; padding: 0; box-sizing: border-box; }
9+
body { font-family: DejaVu Sans, Arial, sans-serif; font-size: 13px; color: #333; background: #fff; }
10+
.page { padding: 40px; }
11+
.header { display: table; width: 100%; margin-bottom: 30px; }
12+
.header-left { display: table-cell; vertical-align: top; width: 60%; }
13+
.header-right { display: table-cell; vertical-align: top; text-align: right; }
14+
.company-name { font-size: 22px; font-weight: bold; color: #1e3a5f; margin-bottom: 4px; }
15+
.payslip-title { font-size: 26px; font-weight: bold; color: #1e3a5f; letter-spacing: 2px; }
16+
.period-label { margin-top: 6px; font-size: 14px; color: #555; }
17+
.divider { border: none; border-top: 2px solid #1e3a5f; margin: 20px 0; }
18+
.info-section { display: table; width: 100%; margin-bottom: 24px; }
19+
.info-left { display: table-cell; width: 50%; vertical-align: top; }
20+
.info-right { display: table-cell; width: 50%; vertical-align: top; }
21+
.info-label { font-size: 11px; font-weight: bold; text-transform: uppercase; color: #888; letter-spacing: 1px; margin-bottom: 4px; }
22+
.info-value { font-size: 13px; color: #333; font-weight: bold; }
23+
.info-detail { font-size: 12px; color: #666; }
24+
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
25+
.section-header { background-color: #1e3a5f; }
26+
.section-header th { color: #fff; padding: 8px 12px; font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; }
27+
.section-header th.text-right { text-align: right; }
28+
tbody tr { border-bottom: 1px solid #e8e8e8; }
29+
tbody td { padding: 9px 12px; font-size: 12px; }
30+
tbody td.text-right { text-align: right; }
31+
.earnings-row { background-color: #f0f9f4; }
32+
.deductions-row { background-color: #fff5f5; }
33+
.summary-section { display: table; width: 100%; margin-top: 16px; }
34+
.summary-spacer { display: table-cell; width: 40%; }
35+
.summary-table-cell { display: table-cell; width: 60%; vertical-align: top; }
36+
.summary-table { width: 100%; border-collapse: collapse; }
37+
.summary-table td { padding: 9px 12px; font-size: 13px; border-bottom: 1px solid #e8e8e8; }
38+
.summary-table td:last-child { text-align: right; font-weight: bold; }
39+
.summary-table tr.gross-row td { background-color: #f0f9f4; color: #065f46; }
40+
.summary-table tr.deduction-row td { background-color: #fff5f5; color: #991b1b; }
41+
.summary-table tr.net-row { background: #1e3a5f; }
42+
.summary-table tr.net-row td { color: #fff; font-size: 16px; font-weight: bold; padding: 12px; border-bottom: none; }
43+
.status-section { margin-top: 20px; }
44+
.status-badge { display: inline-block; padding: 3px 12px; border-radius: 12px; font-size: 11px; font-weight: bold; text-transform: uppercase; }
45+
.status-draft { background-color: #f0f0f0; color: #666; }
46+
.status-processed { background-color: #dbeafe; color: #1d4ed8; }
47+
.status-approved { background-color: #d1fae5; color: #065f46; }
48+
.status-paid { background-color: #ede9fe; color: #5b21b6; }
49+
.footer { margin-top: 40px; text-align: center; font-size: 11px; color: #888; padding-top: 16px; border-top: 1px solid #e8e8e8; }
50+
</style>
51+
</head>
52+
<body>
53+
<div class="page">
54+
55+
{{-- Header --}}
56+
<div class="header">
57+
<div class="header-left">
58+
<div class="company-name">Demo Company</div>
59+
</div>
60+
<div class="header-right">
61+
<div class="payslip-title">PAYSLIP</div>
62+
<div class="period-label">{{ $payrollRun->period_label ?? 'N/A' }}</div>
63+
</div>
64+
</div>
65+
66+
<hr class="divider">
67+
68+
{{-- Run Info --}}
69+
<div class="info-section">
70+
<div class="info-left">
71+
<div class="info-label">Pay Period</div>
72+
<div class="info-value">
73+
{{ $payrollRun->period_start ? $payrollRun->period_start->format('d M Y') : 'N/A' }}
74+
&mdash;
75+
{{ $payrollRun->period_end ? $payrollRun->period_end->format('d M Y') : 'N/A' }}
76+
</div>
77+
</div>
78+
<div class="info-right">
79+
<div class="info-label">Run Date</div>
80+
<div class="info-value">{{ $payrollRun->run_date ? $payrollRun->run_date->format('d M Y') : 'N/A' }}</div>
81+
<div class="info-label" style="margin-top: 10px;">Status</div>
82+
<div>
83+
<span class="status-badge status-{{ $payrollRun->status ?? 'draft' }}">
84+
{{ ucfirst($payrollRun->status ?? 'draft') }}
85+
</span>
86+
</div>
87+
</div>
88+
</div>
89+
90+
<hr class="divider">
91+
92+
{{-- Earnings Section --}}
93+
<table>
94+
<thead>
95+
<tr class="section-header">
96+
<th>Earnings</th>
97+
<th class="text-right">Amount</th>
98+
</tr>
99+
</thead>
100+
<tbody>
101+
<tr class="earnings-row">
102+
<td>Gross Pay</td>
103+
<td class="text-right">{{ number_format((float)($payrollRun->attributes['total_gross'] ?? $payrollRun->total_gross), 2) }}</td>
104+
</tr>
105+
</tbody>
106+
</table>
107+
108+
{{-- Deductions Section --}}
109+
<table>
110+
<thead>
111+
<tr class="section-header">
112+
<th>Deductions</th>
113+
<th class="text-right">Amount</th>
114+
</tr>
115+
</thead>
116+
<tbody>
117+
<tr class="deductions-row">
118+
<td>Total Deductions</td>
119+
<td class="text-right">{{ number_format((float)($payrollRun->attributes['total_deductions'] ?? 0), 2) }}</td>
120+
</tr>
121+
</tbody>
122+
</table>
123+
124+
{{-- Summary --}}
125+
<div class="summary-section">
126+
<div class="summary-spacer"></div>
127+
<div class="summary-table-cell">
128+
@php
129+
$gross = (float)($payrollRun->attributes['total_gross'] ?? 0);
130+
$deductions = (float)($payrollRun->attributes['total_deductions'] ?? 0);
131+
$net = (float)($payrollRun->attributes['total_net'] ?? ($gross - $deductions));
132+
@endphp
133+
<table class="summary-table">
134+
<tr class="gross-row">
135+
<td>Gross Pay</td>
136+
<td>{{ number_format($gross, 2) }}</td>
137+
</tr>
138+
<tr class="deduction-row">
139+
<td>Total Deductions</td>
140+
<td>&minus; {{ number_format($deductions, 2) }}</td>
141+
</tr>
142+
<tr class="net-row">
143+
<td>Net Pay</td>
144+
<td>{{ number_format($net, 2) }}</td>
145+
</tr>
146+
</table>
147+
</div>
148+
</div>
149+
150+
{{-- Employee Count --}}
151+
@if($payrollRun->employee_count)
152+
<div style="margin-top: 20px; font-size: 12px; color: #666;">
153+
<strong>Employees Included:</strong> {{ $payrollRun->employee_count }}
154+
</div>
155+
@endif
156+
157+
{{-- Notes --}}
158+
@if($payrollRun->notes)
159+
<div style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-left: 3px solid #1e3a5f; font-size: 12px; color: #555;">
160+
<strong>Notes:</strong><br>
161+
{{ $payrollRun->notes }}
162+
</div>
163+
@endif
164+
165+
{{-- Footer --}}
166+
<div class="footer">
167+
Demo Company &mdash; Confidential Payslip &mdash; {{ $payrollRun->period_label ?? '' }}
168+
</div>
169+
170+
</div>
171+
</body>
172+
</html>

erp/routes/api.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use App\Http\Controllers\Api\V1\AccountingApiController;
4+
use App\Http\Controllers\Api\V1\ImportExportController;
45
use App\Http\Controllers\Api\V1\AppointmentsApiController;
56
use App\Http\Controllers\Api\V1\ApprovalsApiController;
67
use App\Http\Controllers\Api\V1\AuthController;
@@ -36,6 +37,7 @@
3637
use App\Http\Controllers\Api\V1\RepairsApiController;
3738
use App\Http\Controllers\Api\V1\SignApiController;
3839
use App\Http\Controllers\Api\V1\SocialMarketingApiController;
40+
use App\Http\Controllers\Api\V1\PdfController;
3941
use App\Http\Controllers\Api\V1\SubcontractingApiController;
4042
use App\Http\Controllers\Api\V1\SubscriptionsApiController;
4143
use App\Http\Controllers\Api\V1\SurveyApiController;
@@ -297,5 +299,23 @@
297299
Route::get('subcontracting/orders/{id}', [SubcontractingApiController::class, 'show']);
298300
Route::post('subcontracting/orders', [SubcontractingApiController::class, 'store']);
299301
Route::put('subcontracting/orders/{id}', [SubcontractingApiController::class, 'update']);
302+
303+
// Import / Export
304+
Route::prefix('export')->group(function () {
305+
Route::get('/products', [ImportExportController::class, 'exportProducts']);
306+
Route::get('/contacts', [ImportExportController::class, 'exportContacts']);
307+
Route::get('/invoices', [ImportExportController::class, 'exportInvoices']);
308+
});
309+
Route::prefix('import')->group(function () {
310+
Route::post('/products', [ImportExportController::class, 'importProducts']);
311+
Route::post('/contacts', [ImportExportController::class, 'importContacts']);
312+
});
313+
314+
// PDF Downloads
315+
Route::prefix('pdf')->group(function () {
316+
Route::get('/invoices/{id}', [PdfController::class, 'invoice']);
317+
Route::get('/purchase-orders/{id}', [PdfController::class, 'purchaseOrder']);
318+
Route::get('/payslips/{id}', [PdfController::class, 'payslip']);
319+
});
300320
});
301321
});

0 commit comments

Comments
 (0)