style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
+1
View File
@@ -1,2 +1,3 @@
<?php
return ['name' => 'CRM'];
@@ -1,9 +1,79 @@
<?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'); } };
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');
}
};
+52 -8
View File
@@ -1,11 +1,55 @@
<?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.'); }
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.');
}
}
+22 -2
View File
@@ -1,3 +1,23 @@
<?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(); } }
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();
}
}
+22 -2
View File
@@ -1,3 +1,23 @@
<?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); } }
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);
}
}
+27 -2
View File
@@ -1,3 +1,28 @@
<?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); } }
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);
}
}
+22 -2
View File
@@ -1,3 +1,23 @@
<?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); } }
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);
}
}
+27 -2
View File
@@ -1,3 +1,28 @@
<?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); } }
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);
}
}
@@ -1,6 +1,9 @@
<?php
namespace Modules\CRM\Providers;
use Illuminate\Support\ServiceProvider;
final class CRMServiceProvider extends ServiceProvider
{
public function register(): void
@@ -8,6 +11,7 @@ final class CRMServiceProvider extends ServiceProvider
$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'));
@@ -1,7 +1,10 @@
<?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
+12 -2
View File
@@ -1,3 +1,13 @@
<?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'); });
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');
});
@@ -1,6 +1,9 @@
<?php
namespace Modules\CRM\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void