Skip to content

Commit cb360a0

Browse files
committed
feat(phase-19): global search api and frontend component
1 parent f3648b0 commit cb360a0

4 files changed

Lines changed: 262 additions & 29 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Http\JsonResponse;
7+
use App\Modules\Finance\Models\Invoice;
8+
use App\Modules\Finance\Models\Contact;
9+
use App\Modules\Inventory\Models\Product;
10+
use App\Modules\HR\Models\Employee;
11+
use App\Modules\CRM\Models\CrmLead;
12+
use App\Modules\PM\Models\Project;
13+
use App\Modules\Inventory\Models\PurchaseOrder;
14+
15+
class SearchController extends ApiController
16+
{
17+
public function search(Request $request): JsonResponse
18+
{
19+
$request->validate(['q' => 'required|string|min:2|max:100']);
20+
$q = $request->q;
21+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
22+
$results = [];
23+
24+
// Invoices
25+
Invoice::where('tenant_id', $tenantId)
26+
->where(fn($query) => $query->where('number', 'like', "%{$q}%")
27+
->orWhereHas('contact', fn($q2) => $q2->where('name', 'like', "%{$q}%")))
28+
->limit(5)->get()
29+
->each(fn($inv) => $results[] = [
30+
'module' => 'invoice',
31+
'id' => $inv->id,
32+
'title' => "Invoice #{$inv->number}",
33+
'subtitle' => $inv->status,
34+
'url' => "/finance/invoices/{$inv->id}",
35+
]);
36+
37+
// Contacts
38+
Contact::where('tenant_id', $tenantId)
39+
->where(fn($query) => $query->where('name', 'like', "%{$q}%")
40+
->orWhere('email', 'like', "%{$q}%"))
41+
->limit(5)->get()
42+
->each(fn($c) => $results[] = [
43+
'module' => 'contact',
44+
'id' => $c->id,
45+
'title' => $c->name,
46+
'subtitle' => $c->type,
47+
'url' => "/finance/contacts/{$c->id}",
48+
]);
49+
50+
// Products
51+
Product::where('tenant_id', $tenantId)
52+
->where(fn($query) => $query->where('name', 'like', "%{$q}%")
53+
->orWhere('sku', 'like', "%{$q}%"))
54+
->limit(5)->get()
55+
->each(fn($p) => $results[] = [
56+
'module' => 'product',
57+
'id' => $p->id,
58+
'title' => $p->name,
59+
'subtitle' => $p->sku,
60+
'url' => "/inventory/products/{$p->id}",
61+
]);
62+
63+
// Employees
64+
Employee::where('tenant_id', $tenantId)
65+
->where(fn($query) => $query->where('first_name', 'like', "%{$q}%")
66+
->orWhere('last_name', 'like', "%{$q}%")
67+
->orWhere('email', 'like', "%{$q}%"))
68+
->limit(5)->get()
69+
->each(fn($e) => $results[] = [
70+
'module' => 'employee',
71+
'id' => $e->id,
72+
'title' => "{$e->first_name} {$e->last_name}",
73+
'subtitle' => $e->email,
74+
'url' => "/hr/employees/{$e->id}",
75+
]);
76+
77+
// CRM Leads
78+
CrmLead::where('tenant_id', $tenantId)
79+
->where(fn($query) => $query->where('contact_name', 'like', "%{$q}%")
80+
->orWhere('company_name', 'like', "%{$q}%")
81+
->orWhere('email', 'like', "%{$q}%")
82+
->orWhere('reference', 'like', "%{$q}%"))
83+
->limit(5)->get()
84+
->each(fn($l) => $results[] = [
85+
'module' => 'lead',
86+
'id' => $l->id,
87+
'title' => $l->contact_name ?? $l->company_name ?? $l->reference,
88+
'subtitle' => $l->status ?? '',
89+
'url' => "/crm/leads/{$l->id}",
90+
]);
91+
92+
// Projects
93+
Project::where('tenant_id', $tenantId)
94+
->where('name', 'like', "%{$q}%")
95+
->limit(5)->get()
96+
->each(fn($p) => $results[] = [
97+
'module' => 'project',
98+
'id' => $p->id,
99+
'title' => $p->name,
100+
'subtitle' => $p->status,
101+
'url' => "/pm/projects/{$p->id}",
102+
]);
103+
104+
// Purchase Orders
105+
PurchaseOrder::where('tenant_id', $tenantId)
106+
->where(fn($query) => $query->where('po_number', 'like', "%{$q}%"))
107+
->limit(5)->get()
108+
->each(fn($po) => $results[] = [
109+
'module' => 'purchase_order',
110+
'id' => $po->id,
111+
'title' => "PO #{$po->po_number}",
112+
'subtitle' => $po->status,
113+
'url' => "/purchase/orders/{$po->id}",
114+
]);
115+
116+
return $this->success(['query' => $q, 'results' => $results, 'total' => count($results)]);
117+
}
118+
}

erp/resources/js/Components/Layout/GlobalSearch.tsx

Lines changed: 82 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { useState, useEffect, useRef, useCallback } from 'react';
22

33
interface SearchResult {
4+
module: string;
45
id: number;
56
title: string;
67
subtitle: string;
78
url: string;
8-
type: string;
99
}
1010

11-
const TYPE_COLORS: Record<string, string> = {
12-
Product: 'bg-blue-100 text-blue-700',
13-
Invoice: 'bg-green-100 text-green-700',
14-
Contact: 'bg-purple-100 text-purple-700',
15-
Lead: 'bg-orange-100 text-orange-700',
16-
Ticket: 'bg-red-100 text-red-700',
17-
Employee: 'bg-indigo-100 text-indigo-700',
18-
Project: 'bg-teal-100 text-teal-700',
19-
Order: 'bg-pink-100 text-pink-700',
11+
const MODULE_COLORS: Record<string, string> = {
12+
invoice: 'bg-green-100 text-green-700',
13+
contact: 'bg-blue-100 text-blue-700',
14+
product: 'bg-purple-100 text-purple-700',
15+
employee: 'bg-orange-100 text-orange-700',
16+
lead: 'bg-pink-100 text-pink-700',
17+
project: 'bg-indigo-100 text-indigo-700',
18+
purchase_order: 'bg-yellow-100 text-yellow-700',
2019
};
2120

2221
export default function GlobalSearch() {
@@ -43,14 +42,26 @@ export default function GlobalSearch() {
4342
}, []);
4443

4544
const search = useCallback((q: string) => {
46-
if (q.length < 2) { setResults([]); setOpen(false); return; }
45+
if (q.length < 2) {
46+
setResults([]);
47+
setOpen(false);
48+
return;
49+
}
4750
setLoading(true);
48-
fetch(`/search?q=${encodeURIComponent(q)}`, {
49-
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
51+
fetch(`/api/v1/search?q=${encodeURIComponent(q)}`, {
52+
headers: {
53+
Accept: 'application/json',
54+
'X-Requested-With': 'XMLHttpRequest',
55+
},
5056
})
51-
.then(r => r.json())
52-
.then(data => { setResults(data.results ?? []); setOpen(true); setLoading(false); setActiveIndex(-1); })
53-
.catch(() => setLoading(false));
57+
.then(r => r.json())
58+
.then(data => {
59+
setResults(data.data?.results ?? []);
60+
setOpen(true);
61+
setLoading(false);
62+
setActiveIndex(-1);
63+
})
64+
.catch(() => setLoading(false));
5465
}, []);
5566

5667
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -62,38 +73,80 @@ export default function GlobalSearch() {
6273

6374
const handleKeyDown = (e: React.KeyboardEvent) => {
6475
if (!open) return;
65-
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, results.length - 1)); }
66-
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); }
67-
if (e.key === 'Enter' && activeIndex >= 0) { window.location.href = results[activeIndex].url; }
68-
if (e.key === 'Escape') { setOpen(false); }
76+
if (e.key === 'ArrowDown') {
77+
e.preventDefault();
78+
setActiveIndex(i => Math.min(i + 1, results.length - 1));
79+
}
80+
if (e.key === 'ArrowUp') {
81+
e.preventDefault();
82+
setActiveIndex(i => Math.max(i - 1, 0));
83+
}
84+
if (e.key === 'Enter' && activeIndex >= 0) {
85+
window.location.href = results[activeIndex].url;
86+
}
87+
if (e.key === 'Escape') {
88+
setOpen(false);
89+
}
6990
};
7091

7192
return (
7293
<div className="relative w-72">
7394
<div className="relative">
74-
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
95+
<svg
96+
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400"
97+
fill="none"
98+
viewBox="0 0 24 24"
99+
stroke="currentColor"
100+
>
101+
<path
102+
strokeLinecap="round"
103+
strokeLinejoin="round"
104+
strokeWidth={2}
105+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
106+
/>
107+
</svg>
75108
<input
76109
ref={inputRef}
77110
type="text"
78111
value={query}
79112
onChange={handleChange}
80113
onKeyDown={handleKeyDown}
81114
onFocus={() => query.length >= 2 && setOpen(true)}
82-
placeholder="Search... (⌘K)"
115+
placeholder="Search ERP... (⌘K)"
83116
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-lg border border-slate-200 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
84117
/>
85-
{loading && <div className="absolute right-3 top-1/2 -translate-y-1/2 h-3 w-3 border border-slate-400 border-t-transparent rounded-full animate-spin" />}
118+
{loading && (
119+
<div className="absolute right-3 top-1/2 -translate-y-1/2 h-3 w-3 border border-slate-400 border-t-transparent rounded-full animate-spin" />
120+
)}
86121
</div>
87122
{open && results.length > 0 && (
88123
<div className="absolute top-full mt-1 left-0 right-0 bg-white border border-slate-200 rounded-lg shadow-lg z-50 max-h-80 overflow-y-auto">
89124
{results.map((r, i) => (
90-
<a key={`${r.type}-${r.id}`} href={r.url}
91-
className={`flex items-start gap-3 px-3 py-2 hover:bg-slate-50 cursor-pointer ${i === activeIndex ? 'bg-slate-50' : ''}`}
92-
onClick={() => setOpen(false)}>
93-
<span className={`mt-0.5 shrink-0 inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${TYPE_COLORS[r.type] ?? 'bg-slate-100 text-slate-600'}`}>{r.type}</span>
125+
<a
126+
key={`${r.module}-${r.id}`}
127+
href={r.url}
128+
className={`flex items-start gap-3 px-3 py-2 hover:bg-slate-50 cursor-pointer ${
129+
i === activeIndex ? 'bg-slate-50' : ''
130+
}`}
131+
onClick={() => setOpen(false)}
132+
>
133+
<span
134+
className={`mt-0.5 shrink-0 inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium capitalize ${
135+
MODULE_COLORS[r.module] ??
136+
'bg-slate-100 text-slate-600'
137+
}`}
138+
>
139+
{r.module.replace('_', ' ')}
140+
</span>
94141
<div className="min-w-0">
95-
<p className="text-sm font-medium text-slate-900 truncate">{r.title}</p>
96-
{r.subtitle && <p className="text-xs text-slate-500 truncate">{r.subtitle}</p>}
142+
<p className="text-sm font-medium text-slate-900 truncate">
143+
{r.title}
144+
</p>
145+
{r.subtitle && (
146+
<p className="text-xs text-slate-500 truncate">
147+
{r.subtitle}
148+
</p>
149+
)}
97150
</div>
98151
</a>
99152
))}

erp/routes/api.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,5 +319,8 @@
319319
Route::get('/purchase-orders/{id}', [PdfController::class, 'purchaseOrder']);
320320
Route::get('/payslips/{id}', [PdfController::class, 'payslip']);
321321
});
322+
323+
// Global Search
324+
Route::get('/search', [\App\Http\Controllers\Api\V1\SearchController::class, 'search']);
322325
});
323326
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\Contact;
6+
use App\Modules\Inventory\Models\Product;
7+
use Database\Seeders\RolePermissionSeeder;
8+
9+
beforeEach(function () {
10+
$this->seed(RolePermissionSeeder::class);
11+
$this->tenant = Tenant::create(['name' => 'Search Co', 'slug' => 'search-co']);
12+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
13+
$this->user->assignRole('super-admin');
14+
$this->token = $this->user->createToken('test')->plainTextToken;
15+
app()->instance('tenant', $this->tenant);
16+
});
17+
18+
it('returns search results for products', function () {
19+
Product::create(['tenant_id' => $this->tenant->id, 'sku' => 'SRCH-001', 'name' => 'Searchable Widget']);
20+
21+
$response = $this->withToken($this->token)->getJson('/api/v1/search?q=Widget');
22+
23+
$response->assertStatus(200);
24+
$data = $response->json('data');
25+
expect($data['results'])->not->toBeEmpty();
26+
expect(collect($data['results'])->where('module', 'product')->count())->toBeGreaterThan(0);
27+
});
28+
29+
it('returns search results for contacts', function () {
30+
Contact::create(['tenant_id' => $this->tenant->id, 'name' => 'Searchable Customer', 'type' => 'customer']);
31+
32+
$response = $this->withToken($this->token)->getJson('/api/v1/search?q=Searchable');
33+
34+
$response->assertStatus(200);
35+
$data = $response->json('data');
36+
expect(collect($data['results'])->where('module', 'contact')->count())->toBeGreaterThan(0);
37+
});
38+
39+
it('requires at least 2 characters', function () {
40+
$response = $this->withToken($this->token)->getJson('/api/v1/search?q=a');
41+
$response->assertStatus(422);
42+
});
43+
44+
it('requires authentication', function () {
45+
$response = $this->getJson('/api/v1/search?q=test');
46+
$response->assertStatus(401);
47+
});
48+
49+
it('does not return other tenant results', function () {
50+
$otherTenant = Tenant::create(['name' => 'Other Corp', 'slug' => 'other-corp']);
51+
Product::create(['tenant_id' => $otherTenant->id, 'sku' => 'OTHER-001', 'name' => 'Other Widget']);
52+
53+
$response = $this->withToken($this->token)->getJson('/api/v1/search?q=Other');
54+
55+
$response->assertStatus(200);
56+
$data = $response->json('data');
57+
$productResults = collect($data['results'])->where('module', 'product');
58+
expect($productResults->count())->toBe(0);
59+
});

0 commit comments

Comments
 (0)