phase 5
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
return ['name' => 'CRM'];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration { public function up(): void {
|
||||
Schema::create('crm_companies', function(Blueprint $t): void { $t->id(); $t->string('name'); $t->string('website')->nullable(); $t->string('phone')->nullable(); $t->string('email')->nullable(); $t->string('industry')->nullable(); $t->text('address')->nullable(); $t->text('notes')->nullable(); $t->boolean('is_active')->default(true); $t->timestamps(); });
|
||||
Schema::create('crm_contacts', function(Blueprint $t): void { $t->id(); $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete(); $t->string('first_name'); $t->string('last_name'); $t->string('email')->nullable()->index(); $t->string('phone')->nullable(); $t->string('job_title')->nullable(); $t->text('notes')->nullable(); $t->boolean('is_active')->default(true); $t->timestamps(); });
|
||||
Schema::create('crm_leads', function(Blueprint $t): void { $t->id(); $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete(); $t->string('title'); $t->string('source')->nullable(); $t->string('status')->default('new')->index(); $t->decimal('estimated_value',12,2)->nullable(); $t->text('notes')->nullable(); $t->timestamp('next_action_at')->nullable(); $t->timestamps(); });
|
||||
Schema::create('crm_opportunities', function(Blueprint $t): void { $t->id(); $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete(); $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete(); $t->string('name'); $t->string('stage')->default('qualification')->index(); $t->decimal('value',12,2)->default(0); $t->unsignedTinyInteger('probability')->default(0); $t->date('expected_close_at')->nullable(); $t->text('notes')->nullable(); $t->timestamps(); });
|
||||
Schema::create('crm_activities', function(Blueprint $t): void { $t->id(); $t->nullableMorphs('subject'); $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $t->string('type'); $t->string('title'); $t->text('description')->nullable(); $t->timestamp('due_at')->nullable(); $t->timestamp('completed_at')->nullable(); $t->timestamps(); });
|
||||
} public function down(): void { Schema::dropIfExists('crm_activities'); Schema::dropIfExists('crm_opportunities'); Schema::dropIfExists('crm_leads'); Schema::dropIfExists('crm_contacts'); Schema::dropIfExists('crm_companies'); } };
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Http\Controllers;
|
||||
use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\CRM\Models\Company; use Modules\CRM\Models\Contact; use Modules\CRM\Models\Lead; use Modules\CRM\Models\Opportunity;
|
||||
final class CRMController extends Controller {
|
||||
public function index(): View { return view('crm::admin.index',['companies'=>Company::withCount('contacts')->latest()->paginate(20),'contacts'=>Contact::with('company')->latest()->limit(10)->get(),'leads'=>Lead::with('contact')->latest()->limit(10)->get(),'opportunities'=>Opportunity::with('company')->latest()->limit(10)->get(),'pipelineValue'=>Opportunity::whereNotIn('stage',['won','lost'])->sum('value')]); }
|
||||
public function storeCompany(Request $r): RedirectResponse { Company::create($r->validate(['name'=>['required','string','max:255'],'website'=>['nullable','url','max:255'],'phone'=>['nullable','string','max:80'],'email'=>['nullable','email','max:255'],'industry'=>['nullable','string','max:120'],'address'=>['nullable','string'],'notes'=>['nullable','string']])+['is_active'=>$r->boolean('is_active',true)]); return back()->with('status','Company created.'); }
|
||||
public function storeContact(Request $r): RedirectResponse { Contact::create($r->validate(['company_id'=>['nullable','exists:crm_companies,id'],'first_name'=>['required','string','max:120'],'last_name'=>['required','string','max:120'],'email'=>['nullable','email','max:255'],'phone'=>['nullable','string','max:80'],'job_title'=>['nullable','string','max:120'],'notes'=>['nullable','string']])+['is_active'=>$r->boolean('is_active',true)]); return back()->with('status','Contact created.'); }
|
||||
public function storeLead(Request $r): RedirectResponse { Lead::create($r->validate(['contact_id'=>['nullable','exists:crm_contacts,id'],'title'=>['required','string','max:255'],'source'=>['nullable','string','max:120'],'status'=>['required','in:new,qualified,proposal,won,lost'],'estimated_value'=>['nullable','numeric','min:0'],'notes'=>['nullable','string'],'next_action_at'=>['nullable','date']])); return back()->with('status','Lead created.'); }
|
||||
public function storeOpportunity(Request $r): RedirectResponse { Opportunity::create($r->validate(['company_id'=>['nullable','exists:crm_companies,id'],'contact_id'=>['nullable','exists:crm_contacts,id'],'name'=>['required','string','max:255'],'stage'=>['required','in:qualification,proposal,negotiation,won,lost'],'value'=>['required','numeric','min:0'],'probability'=>['required','integer','between:0,100'],'expected_close_at'=>['nullable','date'],'notes'=>['nullable','string']])); return back()->with('status','Opportunity created.'); }
|
||||
public function destroyCompany(Company $company): RedirectResponse { $company->delete(); return back()->with('status','Company deleted.'); }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
final class Activity extends Model { protected $table='crm_activities'; protected $fillable=['subject_type','subject_id','user_id','type','title','description','due_at','completed_at']; protected function casts(): array { return ['due_at'=>'datetime','completed_at'=>'datetime']; } public function subject(): MorphTo { return $this->morphTo(); } }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
final class Company extends Model { protected $table='crm_companies'; protected $fillable=['name','website','phone','email','industry','address','notes','is_active']; protected function casts(): array { return ['is_active'=>'boolean']; } public function contacts(): HasMany { return $this->hasMany(Contact::class); } }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
final class Contact extends Model { protected $table='crm_contacts'; protected $fillable=['company_id','first_name','last_name','email','phone','job_title','notes','is_active']; protected function casts(): array { return ['is_active'=>'boolean']; } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function getNameAttribute(): string { return trim($this->first_name.' '.$this->last_name); } }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
final class Lead extends Model { protected $table='crm_leads'; protected $fillable=['contact_id','title','source','status','estimated_value','notes','next_action_at']; protected function casts(): array { return ['estimated_value'=>'decimal:2','next_action_at'=>'datetime']; } public function contact(): BelongsTo { return $this->belongsTo(Contact::class); } }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
final class Opportunity extends Model { protected $table='crm_opportunities'; protected $fillable=['company_id','contact_id','name','stage','value','probability','expected_close_at','notes']; protected function casts(): array { return ['value'=>'decimal:2','expected_close_at'=>'date']; } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function contact(): BelongsTo { return $this->belongsTo(Contact::class); } }
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Providers;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
final class CRMServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
$this->mergeConfigFrom(module_path('CRM', 'Config/config.php'), 'crm');
|
||||
}
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(module_path('CRM', 'Database/Migrations'));
|
||||
$this->loadViewsFrom(module_path('CRM', 'Resources/views'), 'crm');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Providers;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
Route::middleware('web')->group(module_path('CRM', 'Routes/web.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<x-layouts.app :title="__('CRM')"><div class="space-y-8"><div><h1 class="text-2xl font-semibold">CRM</h1><p class="text-sm text-zinc-500">Companies, contacts, leads and sales opportunities.</p></div>@if(session('status'))<div class="rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="grid gap-4 md:grid-cols-4"><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Companies</div><div class="text-2xl font-semibold">{{ $companies->total() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Contacts</div><div class="text-2xl font-semibold">{{ \Modules\CRM\Models\Contact::count() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Open leads</div><div class="text-2xl font-semibold">{{ \Modules\CRM\Models\Lead::whereNotIn('status',['won','lost'])->count() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Pipeline</div><div class="text-2xl font-semibold">£{{ number_format($pipelineValue,2) }}</div></div></div><div class="grid gap-6 lg:grid-cols-2"><form method="POST" action="{{ route('administration.crm.companies.store') }}" class="space-y-4 rounded-xl border p-5">@csrf<h2 class="font-semibold">Add company</h2><flux:input name="name" label="Company name" required/><div class="grid gap-4 md:grid-cols-2"><flux:input name="email" type="email" label="Email"/><flux:input name="phone" label="Phone"/><flux:input name="website" label="Website"/><flux:input name="industry" label="Industry"/></div><flux:textarea name="address" label="Address"/><flux:button type="submit" variant="primary">Create company</flux:button></form><form method="POST" action="{{ route('administration.crm.contacts.store') }}" class="space-y-4 rounded-xl border p-5">@csrf<h2 class="font-semibold">Add contact</h2><flux:select name="company_id" label="Company"><option value="">None</option>@foreach($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</flux:select><div class="grid gap-4 md:grid-cols-2"><flux:input name="first_name" label="First name" required/><flux:input name="last_name" label="Last name" required/><flux:input name="email" type="email" label="Email"/><flux:input name="phone" label="Phone"/></div><flux:button type="submit" variant="primary">Create contact</flux:button></form></div><div class="overflow-hidden rounded-xl border"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Company</th><th class="p-3 text-left">Industry</th><th class="p-3 text-left">Contacts</th><th class="p-3"></th></tr></thead><tbody>@forelse($companies as $company)<tr class="border-t"><td class="p-3 font-medium">{{ $company->name }}</td><td class="p-3">{{ $company->industry ?: '—' }}</td><td class="p-3">{{ $company->contacts_count }}</td><td class="p-3 text-right"><form method="POST" action="{{ route('administration.crm.companies.destroy',$company) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="4" class="p-6 text-center text-zinc-500">No companies yet.</td></tr>@endforelse</tbody></table></div>{{ $companies->links() }}</div></x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route; use Modules\CRM\Http\Controllers\CRMController;
|
||||
Route::middleware(['auth','verified'])->prefix('administration/crm')->name('administration.crm.')->group(function(): void { Route::get('/',[CRMController::class,'index'])->name('index'); Route::post('/companies',[CRMController::class,'storeCompany'])->name('companies.store'); Route::delete('/companies/{company}',[CRMController::class,'destroyCompany'])->name('companies.destroy'); Route::post('/contacts',[CRMController::class,'storeContact'])->name('contacts.store'); Route::post('/leads',[CRMController::class,'storeLead'])->name('leads.store'); Route::post('/opportunities',[CRMController::class,'storeOpportunity'])->name('opportunities.store'); });
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\CRM\Tests\Feature;
|
||||
use Tests\TestCase;
|
||||
final class ModuleBootTest extends TestCase
|
||||
{
|
||||
public function test_module_configuration_is_available(): void
|
||||
{
|
||||
$this->assertSame('CRM', config('crm.name'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "CRM",
|
||||
"alias": "crm",
|
||||
"version": "1.0.0",
|
||||
"description": "Customer relationship management for companies, contacts, leads, opportunities and activities.",
|
||||
"keywords": ["business", "crm"],
|
||||
"priority": 40,
|
||||
"providers": ["Modules\\CRM\\Providers\\CRMServiceProvider"],
|
||||
"aliases": {},
|
||||
"files": [],
|
||||
"requires": ["Core", "Administration"]
|
||||
}
|
||||
Reference in New Issue
Block a user