Skip to content

Commit 00ed26a

Browse files
committed
feat: Phase 6 — Global Search, CSV Export, Printable Invoice
- Add SearchController (GET /search?q=) returning JSON results for invoices, contacts, products, purchase orders, and employees; permission-gated per module, debounced in the client - Add CommandPalette component (Cmd/Ctrl+K) with keyboard navigation, type badges, and backdrop dismiss; wired into AppLayout - Add ExportController with streaming CSV downloads for products, invoices, and employees; gated by viewAny policy - Add export routes under /export/{products,invoices,employees} - Add Export CSV buttons to Products, Invoices, and Employees index pages - Add InvoiceController::print() rendering standalone Print page with company name and currency from TenantSetting - Add printable invoice page (Finance/Invoices/Print) with print:hidden controls bar and window.print() button - Add Print / PDF button to Invoice Show page - Fix ExportController: selling_price → sale_price (Product field name) - Add SearchTest (6 tests) and ExportTest (5 tests); all 172 tests pass https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent aa2b2c2 commit 00ed26a

14 files changed

Lines changed: 736 additions & 27 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Modules\Finance\Models\Invoice;
6+
use App\Modules\HR\Models\Employee;
7+
use App\Modules\Inventory\Models\Product;
8+
use Illuminate\Support\Facades\Gate;
9+
use Symfony\Component\HttpFoundation\StreamedResponse;
10+
11+
class ExportController extends Controller
12+
{
13+
public function products(): StreamedResponse
14+
{
15+
Gate::authorize('viewAny', Product::class);
16+
17+
return response()->streamDownload(function () {
18+
$out = fopen('php://output', 'w');
19+
fputcsv($out, ['SKU', 'Name', 'Category', 'Cost Price', 'Selling Price', 'Reorder Point', 'Active']);
20+
21+
Product::with('category')->chunk(200, function ($products) use ($out) {
22+
foreach ($products as $p) {
23+
fputcsv($out, [
24+
$p->sku ?? '',
25+
$p->name,
26+
$p->category?->name ?? '',
27+
$p->cost_price,
28+
$p->sale_price,
29+
$p->reorder_point,
30+
$p->is_active ? 'Yes' : 'No',
31+
]);
32+
}
33+
});
34+
35+
fclose($out);
36+
}, 'products-' . now()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']);
37+
}
38+
39+
public function invoices(): StreamedResponse
40+
{
41+
Gate::authorize('viewAny', Invoice::class);
42+
43+
return response()->streamDownload(function () {
44+
$out = fopen('php://output', 'w');
45+
fputcsv($out, ['Number', 'Contact', 'Status', 'Issue Date', 'Due Date', 'Total', 'Amount Due']);
46+
47+
Invoice::with(['contact', 'items', 'payments'])->chunk(200, function ($invoices) use ($out) {
48+
foreach ($invoices as $inv) {
49+
fputcsv($out, [
50+
$inv->number ?? '',
51+
$inv->contact?->name ?? '',
52+
$inv->status,
53+
$inv->issue_date?->toDateString() ?? '',
54+
$inv->due_date?->toDateString() ?? '',
55+
$inv->total,
56+
$inv->amount_due,
57+
]);
58+
}
59+
});
60+
61+
fclose($out);
62+
}, 'invoices-' . now()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']);
63+
}
64+
65+
public function employees(): StreamedResponse
66+
{
67+
Gate::authorize('viewAny', Employee::class);
68+
69+
return response()->streamDownload(function () {
70+
$out = fopen('php://output', 'w');
71+
fputcsv($out, ['Employee #', 'First Name', 'Last Name', 'Email', 'Position', 'Department', 'Type', 'Status', 'Start Date', 'Salary']);
72+
73+
Employee::with('department')->chunk(200, function ($employees) use ($out) {
74+
foreach ($employees as $emp) {
75+
fputcsv($out, [
76+
$emp->employee_number ?? '',
77+
$emp->first_name,
78+
$emp->last_name,
79+
$emp->email ?? '',
80+
$emp->position ?? '',
81+
$emp->department?->name ?? '',
82+
$emp->employment_type,
83+
$emp->status,
84+
$emp->start_date?->toDateString() ?? '',
85+
$emp->salary_amount,
86+
]);
87+
}
88+
});
89+
90+
fclose($out);
91+
}, 'employees-' . now()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']);
92+
}
93+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Modules\Finance\Models\Contact;
6+
use App\Modules\Finance\Models\Invoice;
7+
use App\Modules\HR\Models\Employee;
8+
use App\Modules\Inventory\Models\Product;
9+
use App\Modules\Inventory\Models\PurchaseOrder;
10+
use Illuminate\Http\JsonResponse;
11+
use Illuminate\Http\Request;
12+
13+
class SearchController extends Controller
14+
{
15+
private const LIMIT = 5;
16+
17+
public function __invoke(Request $request): JsonResponse
18+
{
19+
$query = trim($request->get('q', ''));
20+
21+
if (strlen($query) < 2) {
22+
return response()->json(['results' => []]);
23+
}
24+
25+
$user = auth()->user();
26+
$results = [];
27+
28+
if ($user->can('finance.view')) {
29+
foreach (Invoice::where('number', 'like', "%{$query}%")->with('contact')->limit(self::LIMIT)->get() as $inv) {
30+
$results[] = ['type' => 'Invoice', 'label' => $inv->number ?? "Invoice #{$inv->id}", 'sub' => $inv->contact?->name ?? '', 'href' => "/finance/invoices/{$inv->id}"];
31+
}
32+
33+
foreach (Contact::search($query)->limit(self::LIMIT)->get() as $c) {
34+
$results[] = ['type' => 'Contact', 'label' => $c->name, 'sub' => $c->email ?? '', 'href' => "/finance/contacts"];
35+
}
36+
}
37+
38+
if ($user->can('inventory.view')) {
39+
foreach (Product::search($query)->limit(self::LIMIT)->get() as $p) {
40+
$results[] = ['type' => 'Product', 'label' => $p->name, 'sub' => $p->sku ?? '', 'href' => "/inventory/products/{$p->id}"];
41+
}
42+
43+
foreach (PurchaseOrder::with('supplier')->where('id', 'like', "%{$query}%")->orWhereHas('supplier', fn ($q) => $q->where('name', 'like', "%{$query}%"))->limit(self::LIMIT)->get() as $po) {
44+
$results[] = ['type' => 'Purchase Order', 'label' => "PO #{$po->id}", 'sub' => $po->supplier?->name ?? '', 'href' => "/inventory/purchase-orders/{$po->id}"];
45+
}
46+
}
47+
48+
if ($user->can('hr.view')) {
49+
foreach (Employee::search($query)->limit(self::LIMIT)->get() as $emp) {
50+
$results[] = ['type' => 'Employee', 'label' => $emp->full_name, 'sub' => $emp->position ?? '', 'href' => "/hr/employees/{$emp->id}"];
51+
}
52+
}
53+
54+
return response()->json(['results' => $results]);
55+
}
56+
}

erp/app/Modules/Core/routes/core.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
use App\Http\Controllers\Admin\UserController;
55
use App\Http\Controllers\AnalyticsController;
66
use App\Http\Controllers\DashboardController;
7+
use App\Http\Controllers\ExportController;
78
use App\Http\Controllers\NotificationController;
9+
use App\Http\Controllers\SearchController;
810
use App\Http\Controllers\SettingController;
911
use Illuminate\Support\Facades\Route;
1012

@@ -18,6 +20,14 @@
1820
Route::patch('/notifications/read-all', [NotificationController::class, 'markAllRead'])->name('notifications.read-all');
1921
Route::delete('/notifications/{id}', [NotificationController::class, 'destroy'])->name('notifications.destroy');
2022

23+
Route::get('/search', SearchController::class)->name('search');
24+
25+
Route::prefix('export')->name('export.')->group(function () {
26+
Route::get('products', [ExportController::class, 'products'])->name('products');
27+
Route::get('invoices', [ExportController::class, 'invoices'])->name('invoices');
28+
Route::get('employees', [ExportController::class, 'employees'])->name('employees');
29+
});
30+
2131
Route::get('/settings', [SettingController::class, 'edit'])->name('settings.edit');
2232
Route::put('/settings', [SettingController::class, 'update'])->name('settings.update');
2333

erp/app/Modules/Finance/Http/Controllers/InvoiceController.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Modules\Finance\Http\Controllers;
44

55
use App\Http\Controllers\Controller;
6+
use App\Modules\Core\Models\TenantSetting;
67
use App\Modules\Finance\Http\Requests\StoreInvoiceRequest;
78
use App\Modules\Finance\Http\Requests\StorePaymentRequest;
89
use App\Modules\Finance\Http\Resources\InvoiceResource;
@@ -161,6 +162,21 @@ public function recordPayment(StorePaymentRequest $request, Invoice $invoice): R
161162
return back()->with('success', 'Payment recorded.');
162163
}
163164

165+
public function print(Invoice $invoice): Response
166+
{
167+
$this->authorize('view', $invoice);
168+
169+
$invoice->load(['contact', 'items', 'payments', 'creator']);
170+
171+
$tenantId = auth()->user()->tenant_id;
172+
173+
return Inertia::render('Finance/Invoices/Print', [
174+
'invoice' => new InvoiceResource($invoice),
175+
'company' => TenantSetting::getValue($tenantId, 'company_name', 'My Company'),
176+
'currency' => TenantSetting::getValue($tenantId, 'currency', 'USD'),
177+
]);
178+
}
179+
164180
public function destroy(Invoice $invoice): RedirectResponse
165181
{
166182
$this->authorize('delete', $invoice);

erp/app/Modules/Finance/routes/finance.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
Route::patch('invoices/{invoice}/cancel', [InvoiceController::class, 'cancel'])->name('invoices.cancel');
2727
Route::post('invoices/{invoice}/payments', [InvoiceController::class, 'recordPayment'])
2828
->name('invoices.payments.store');
29+
Route::get('invoices/{invoice}/print', [InvoiceController::class, 'print'])
30+
->name('invoices.print');
2931

3032
// Reports
3133
Route::get('reports/trial-balance', [ReportController::class, 'trialBalance'])
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { router } from '@inertiajs/react';
2+
import { useEffect, useRef, useState } from 'react';
3+
4+
interface SearchResult {
5+
type: string;
6+
label: string;
7+
sub?: string;
8+
href: string;
9+
}
10+
11+
interface Props {
12+
open: boolean;
13+
onClose: () => void;
14+
}
15+
16+
const TYPE_COLORS: Record<string, string> = {
17+
'Invoice': 'bg-blue-100 text-blue-700',
18+
'Contact': 'bg-purple-100 text-purple-700',
19+
'Product': 'bg-emerald-100 text-emerald-700',
20+
'Purchase Order': 'bg-amber-100 text-amber-700',
21+
'Employee': 'bg-teal-100 text-teal-700',
22+
};
23+
24+
export function CommandPalette({ open, onClose }: Props) {
25+
const [query, setQuery] = useState('');
26+
const [results, setResults] = useState<SearchResult[]>([]);
27+
const [loading, setLoading] = useState(false);
28+
const [selected, setSelected] = useState(0);
29+
const inputRef = useRef<HTMLInputElement>(null);
30+
31+
useEffect(() => {
32+
if (open) {
33+
setQuery('');
34+
setResults([]);
35+
setSelected(0);
36+
setTimeout(() => inputRef.current?.focus(), 50);
37+
}
38+
}, [open]);
39+
40+
useEffect(() => {
41+
if (query.length < 2) { setResults([]); return; }
42+
43+
const timer = setTimeout(async () => {
44+
setLoading(true);
45+
try {
46+
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
47+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
48+
});
49+
const data = await res.json();
50+
setResults(data.results ?? []);
51+
setSelected(0);
52+
} catch {
53+
setResults([]);
54+
} finally {
55+
setLoading(false);
56+
}
57+
}, 280);
58+
59+
return () => clearTimeout(timer);
60+
}, [query]);
61+
62+
function navigate(href: string) {
63+
router.visit(href);
64+
onClose();
65+
}
66+
67+
function handleKeyDown(e: React.KeyboardEvent) {
68+
if (e.key === 'Escape') { onClose(); return; }
69+
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected((s) => Math.min(s + 1, results.length - 1)); }
70+
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected((s) => Math.max(s - 1, 0)); }
71+
if (e.key === 'Enter' && results[selected]) { navigate(results[selected].href); }
72+
}
73+
74+
if (!open) return null;
75+
76+
return (
77+
<div
78+
className="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4"
79+
onClick={onClose}
80+
>
81+
{/* Backdrop */}
82+
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" />
83+
84+
{/* Palette */}
85+
<div
86+
className="relative w-full max-w-xl rounded-xl border border-slate-200 bg-white shadow-2xl overflow-hidden"
87+
onClick={(e) => e.stopPropagation()}
88+
>
89+
{/* Input */}
90+
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-200">
91+
<svg className="h-5 w-5 text-slate-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
92+
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
93+
</svg>
94+
<input
95+
ref={inputRef}
96+
type="text"
97+
value={query}
98+
onChange={(e) => setQuery(e.target.value)}
99+
onKeyDown={handleKeyDown}
100+
placeholder="Search invoices, employees, products…"
101+
className="flex-1 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none"
102+
/>
103+
<kbd className="hidden sm:flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-400">
104+
ESC
105+
</kbd>
106+
</div>
107+
108+
{/* Results */}
109+
{loading && (
110+
<div className="px-4 py-3 text-sm text-slate-400">Searching…</div>
111+
)}
112+
113+
{!loading && query.length >= 2 && results.length === 0 && (
114+
<div className="px-4 py-6 text-center text-sm text-slate-400">No results for "{query}"</div>
115+
)}
116+
117+
{!loading && results.length > 0 && (
118+
<ul className="max-h-80 overflow-y-auto py-1">
119+
{results.map((r, i) => (
120+
<li key={`${r.type}-${r.href}-${i}`}>
121+
<button
122+
className={[
123+
'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
124+
i === selected ? 'bg-indigo-50' : 'hover:bg-slate-50',
125+
].join(' ')}
126+
onClick={() => navigate(r.href)}
127+
onMouseEnter={() => setSelected(i)}
128+
>
129+
<span className={`shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${TYPE_COLORS[r.type] ?? 'bg-slate-100 text-slate-600'}`}>
130+
{r.type}
131+
</span>
132+
<span className="flex-1 min-w-0">
133+
<span className="block text-sm font-medium text-slate-900 truncate">{r.label}</span>
134+
{r.sub && <span className="block text-xs text-slate-500 truncate">{r.sub}</span>}
135+
</span>
136+
<svg className="h-4 w-4 text-slate-300 shrink-0" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
137+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
138+
</svg>
139+
</button>
140+
</li>
141+
))}
142+
</ul>
143+
)}
144+
145+
{!query && (
146+
<div className="px-4 py-4 text-xs text-slate-400">
147+
Type at least 2 characters to search across invoices, employees, products, and more.
148+
</div>
149+
)}
150+
</div>
151+
</div>
152+
);
153+
}

0 commit comments

Comments
 (0)