From 3f87bea5f97fe7d88dcb7738276512da74ff0f41 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 10:47:01 +0100 Subject: [PATCH] phase 5 --- Modules/CRM/Config/config.php | 2 ++ .../2026_07_24_04000_create_crm_tables.php | 9 +++++ .../CRM/Http/Controllers/CRMController.php | 11 ++++++ Modules/CRM/Models/Activity.php | 3 ++ Modules/CRM/Models/Company.php | 3 ++ Modules/CRM/Models/Contact.php | 3 ++ Modules/CRM/Models/Lead.php | 3 ++ Modules/CRM/Models/Opportunity.php | 3 ++ Modules/CRM/Providers/CRMServiceProvider.php | 16 +++++++++ .../CRM/Providers/RouteServiceProvider.php | 12 +++++++ .../CRM/Resources/views/admin/index.blade.php | 1 + Modules/CRM/Routes/web.php | 3 ++ .../Tests/Feature/ModuleBootTest.php | 4 +-- Modules/CRM/module.json | 12 +++++++ Modules/Careers/Config/config.php | 2 ++ ...2026_07_24_04300_create_careers_tables.php | 3 ++ .../Http/Controllers/CareerController.php | 3 ++ Modules/Careers/Models/Application.php | 3 ++ Modules/Careers/Models/Job.php | 3 ++ .../Providers/CareersServiceProvider.php | 16 +++++++++ .../Providers/RouteServiceProvider.php | 12 +++++++ .../Resources/views/admin/index.blade.php | 1 + Modules/Careers/Routes/web.php | 3 ++ .../Careers/Tests/Feature/ModuleBootTest.php | 10 ++++++ Modules/Careers/module.json | 12 +++++++ Modules/Events/Config/config.php | 2 ++ .../2026_07_24_04200_create_events_tables.php | 3 ++ .../Http/Controllers/EventController.php | 3 ++ Modules/Events/Models/Event.php | 3 ++ Modules/Events/Models/Registration.php | 3 ++ Modules/Events/Models/Venue.php | 3 ++ .../Providers/EventsServiceProvider.php | 16 +++++++++ .../Events/Providers/RouteServiceProvider.php | 12 +++++++ .../Resources/views/admin/index.blade.php | 1 + Modules/Events/Routes/web.php | 3 ++ .../Events/Tests/Feature/ModuleBootTest.php | 10 ++++++ Modules/Events/module.json | 12 +++++++ Modules/Locations/Config/config.php | 2 ++ ...026_07_24_04400_create_locations_table.php | 3 ++ .../Http/Controllers/LocationController.php | 3 ++ Modules/Locations/Models/Location.php | 3 ++ .../Providers/LocationsServiceProvider.php | 16 +++++++++ .../Providers/RouteServiceProvider.php | 12 +++++++ .../Resources/views/admin/index.blade.php | 1 + Modules/Locations/Routes/web.php | 3 ++ .../Tests/Feature/ModuleBootTest.php | 10 ++++++ Modules/Locations/module.json | 12 +++++++ Modules/Newsletter/Config/config.php | 2 ++ ...6_07_24_04100_create_newsletter_tables.php | 3 ++ .../Http/Controllers/NewsletterController.php | 3 ++ Modules/Newsletter/Models/Campaign.php | 3 ++ Modules/Newsletter/Models/MailingList.php | 3 ++ Modules/Newsletter/Models/Subscriber.php | 3 ++ .../Providers/NewsletterServiceProvider.php | 16 +++++++++ .../Providers/RouteServiceProvider.php | 12 +++++++ .../Resources/views/admin/index.blade.php | 1 + Modules/Newsletter/Routes/web.php | 3 ++ .../Tests/Feature/ModuleBootTest.php | 10 ++++++ Modules/Newsletter/module.json | 12 +++++++ .../Database/Seeders/RolePermissionSeeder.php | 5 +++ modules_statuses.json | 35 +++++++++++++++++-- .../components/layouts/app/sidebar.blade.php | 7 ++++ 62 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 Modules/CRM/Config/config.php create mode 100644 Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php create mode 100644 Modules/CRM/Http/Controllers/CRMController.php create mode 100644 Modules/CRM/Models/Activity.php create mode 100644 Modules/CRM/Models/Company.php create mode 100644 Modules/CRM/Models/Contact.php create mode 100644 Modules/CRM/Models/Lead.php create mode 100644 Modules/CRM/Models/Opportunity.php create mode 100644 Modules/CRM/Providers/CRMServiceProvider.php create mode 100644 Modules/CRM/Providers/RouteServiceProvider.php create mode 100644 Modules/CRM/Resources/views/admin/index.blade.php create mode 100644 Modules/CRM/Routes/web.php rename Modules/{Blog => CRM}/Tests/Feature/ModuleBootTest.php (61%) create mode 100644 Modules/CRM/module.json create mode 100644 Modules/Careers/Config/config.php create mode 100644 Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php create mode 100644 Modules/Careers/Http/Controllers/CareerController.php create mode 100644 Modules/Careers/Models/Application.php create mode 100644 Modules/Careers/Models/Job.php create mode 100644 Modules/Careers/Providers/CareersServiceProvider.php create mode 100644 Modules/Careers/Providers/RouteServiceProvider.php create mode 100644 Modules/Careers/Resources/views/admin/index.blade.php create mode 100644 Modules/Careers/Routes/web.php create mode 100644 Modules/Careers/Tests/Feature/ModuleBootTest.php create mode 100644 Modules/Careers/module.json create mode 100644 Modules/Events/Config/config.php create mode 100644 Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php create mode 100644 Modules/Events/Http/Controllers/EventController.php create mode 100644 Modules/Events/Models/Event.php create mode 100644 Modules/Events/Models/Registration.php create mode 100644 Modules/Events/Models/Venue.php create mode 100644 Modules/Events/Providers/EventsServiceProvider.php create mode 100644 Modules/Events/Providers/RouteServiceProvider.php create mode 100644 Modules/Events/Resources/views/admin/index.blade.php create mode 100644 Modules/Events/Routes/web.php create mode 100644 Modules/Events/Tests/Feature/ModuleBootTest.php create mode 100644 Modules/Events/module.json create mode 100644 Modules/Locations/Config/config.php create mode 100644 Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php create mode 100644 Modules/Locations/Http/Controllers/LocationController.php create mode 100644 Modules/Locations/Models/Location.php create mode 100644 Modules/Locations/Providers/LocationsServiceProvider.php create mode 100644 Modules/Locations/Providers/RouteServiceProvider.php create mode 100644 Modules/Locations/Resources/views/admin/index.blade.php create mode 100644 Modules/Locations/Routes/web.php create mode 100644 Modules/Locations/Tests/Feature/ModuleBootTest.php create mode 100644 Modules/Locations/module.json create mode 100644 Modules/Newsletter/Config/config.php create mode 100644 Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php create mode 100644 Modules/Newsletter/Http/Controllers/NewsletterController.php create mode 100644 Modules/Newsletter/Models/Campaign.php create mode 100644 Modules/Newsletter/Models/MailingList.php create mode 100644 Modules/Newsletter/Models/Subscriber.php create mode 100644 Modules/Newsletter/Providers/NewsletterServiceProvider.php create mode 100644 Modules/Newsletter/Providers/RouteServiceProvider.php create mode 100644 Modules/Newsletter/Resources/views/admin/index.blade.php create mode 100644 Modules/Newsletter/Routes/web.php create mode 100644 Modules/Newsletter/Tests/Feature/ModuleBootTest.php create mode 100644 Modules/Newsletter/module.json diff --git a/Modules/CRM/Config/config.php b/Modules/CRM/Config/config.php new file mode 100644 index 0000000..f4ee058 --- /dev/null +++ b/Modules/CRM/Config/config.php @@ -0,0 +1,2 @@ + 'CRM']; diff --git a/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php b/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php new file mode 100644 index 0000000..25e7049 --- /dev/null +++ b/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php @@ -0,0 +1,9 @@ +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'); } }; diff --git a/Modules/CRM/Http/Controllers/CRMController.php b/Modules/CRM/Http/Controllers/CRMController.php new file mode 100644 index 0000000..c3ab23b --- /dev/null +++ b/Modules/CRM/Http/Controllers/CRMController.php @@ -0,0 +1,11 @@ +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.'); } +} diff --git a/Modules/CRM/Models/Activity.php b/Modules/CRM/Models/Activity.php new file mode 100644 index 0000000..6574a76 --- /dev/null +++ b/Modules/CRM/Models/Activity.php @@ -0,0 +1,3 @@ +'datetime','completed_at'=>'datetime']; } public function subject(): MorphTo { return $this->morphTo(); } } diff --git a/Modules/CRM/Models/Company.php b/Modules/CRM/Models/Company.php new file mode 100644 index 0000000..2eab9f6 --- /dev/null +++ b/Modules/CRM/Models/Company.php @@ -0,0 +1,3 @@ +'boolean']; } public function contacts(): HasMany { return $this->hasMany(Contact::class); } } diff --git a/Modules/CRM/Models/Contact.php b/Modules/CRM/Models/Contact.php new file mode 100644 index 0000000..0a8f29a --- /dev/null +++ b/Modules/CRM/Models/Contact.php @@ -0,0 +1,3 @@ +'boolean']; } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function getNameAttribute(): string { return trim($this->first_name.' '.$this->last_name); } } diff --git a/Modules/CRM/Models/Lead.php b/Modules/CRM/Models/Lead.php new file mode 100644 index 0000000..93c50c1 --- /dev/null +++ b/Modules/CRM/Models/Lead.php @@ -0,0 +1,3 @@ +'decimal:2','next_action_at'=>'datetime']; } public function contact(): BelongsTo { return $this->belongsTo(Contact::class); } } diff --git a/Modules/CRM/Models/Opportunity.php b/Modules/CRM/Models/Opportunity.php new file mode 100644 index 0000000..fbe11ed --- /dev/null +++ b/Modules/CRM/Models/Opportunity.php @@ -0,0 +1,3 @@ +'decimal:2','expected_close_at'=>'date']; } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function contact(): BelongsTo { return $this->belongsTo(Contact::class); } } diff --git a/Modules/CRM/Providers/CRMServiceProvider.php b/Modules/CRM/Providers/CRMServiceProvider.php new file mode 100644 index 0000000..c987cf0 --- /dev/null +++ b/Modules/CRM/Providers/CRMServiceProvider.php @@ -0,0 +1,16 @@ +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'); + } +} diff --git a/Modules/CRM/Providers/RouteServiceProvider.php b/Modules/CRM/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..58e1855 --- /dev/null +++ b/Modules/CRM/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('CRM', 'Routes/web.php')); + } +} diff --git a/Modules/CRM/Resources/views/admin/index.blade.php b/Modules/CRM/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..4a4868f --- /dev/null +++ b/Modules/CRM/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

CRM

Companies, contacts, leads and sales opportunities.

@if(session('status'))
{{ session('status') }}
@endif
Companies
{{ $companies->total() }}
Contacts
{{ \Modules\CRM\Models\Contact::count() }}
Open leads
{{ \Modules\CRM\Models\Lead::whereNotIn('status',['won','lost'])->count() }}
Pipeline
£{{ number_format($pipelineValue,2) }}
@csrf

Add company

Create company
@csrf

Add contact

@foreach($companies as $company)@endforeach
Create contact
@forelse($companies as $company)@empty@endforelse
CompanyIndustryContacts
{{ $company->name }}{{ $company->industry ?: '—' }}{{ $company->contacts_count }}
@csrf @method('DELETE')Delete
No companies yet.
{{ $companies->links() }}
diff --git a/Modules/CRM/Routes/web.php b/Modules/CRM/Routes/web.php new file mode 100644 index 0000000..91fa33e --- /dev/null +++ b/Modules/CRM/Routes/web.php @@ -0,0 +1,3 @@ +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'); }); diff --git a/Modules/Blog/Tests/Feature/ModuleBootTest.php b/Modules/CRM/Tests/Feature/ModuleBootTest.php similarity index 61% rename from Modules/Blog/Tests/Feature/ModuleBootTest.php rename to Modules/CRM/Tests/Feature/ModuleBootTest.php index f57b6fc..ad355a2 100644 --- a/Modules/Blog/Tests/Feature/ModuleBootTest.php +++ b/Modules/CRM/Tests/Feature/ModuleBootTest.php @@ -1,10 +1,10 @@ assertSame('Blog', config('blog.name')); + $this->assertSame('CRM', config('crm.name')); } } diff --git a/Modules/CRM/module.json b/Modules/CRM/module.json new file mode 100644 index 0000000..8d2e7b2 --- /dev/null +++ b/Modules/CRM/module.json @@ -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"] +} diff --git a/Modules/Careers/Config/config.php b/Modules/Careers/Config/config.php new file mode 100644 index 0000000..171f890 --- /dev/null +++ b/Modules/Careers/Config/config.php @@ -0,0 +1,2 @@ + 'Careers']; diff --git a/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php b/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php new file mode 100644 index 0000000..a0a1cf6 --- /dev/null +++ b/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php @@ -0,0 +1,3 @@ +id();$t->string('title');$t->string('slug')->unique();$t->string('department')->nullable();$t->string('location')->nullable();$t->string('employment_type')->default('full-time');$t->longText('description')->nullable();$t->string('status')->default('draft')->index();$t->timestamp('published_at')->nullable();$t->timestamp('closes_at')->nullable();$t->timestamps();}); Schema::create('career_applications',function(Blueprint $t): void{$t->id();$t->foreignId('job_id')->constrained('career_jobs')->cascadeOnDelete();$t->string('name');$t->string('email')->index();$t->string('phone')->nullable();$t->string('cv_path')->nullable();$t->text('cover_letter')->nullable();$t->string('status')->default('new')->index();$t->text('notes')->nullable();$t->timestamps();}); } public function down(): void { Schema::dropIfExists('career_applications');Schema::dropIfExists('career_jobs'); } }; diff --git a/Modules/Careers/Http/Controllers/CareerController.php b/Modules/Careers/Http/Controllers/CareerController.php new file mode 100644 index 0000000..9fce28b --- /dev/null +++ b/Modules/Careers/Http/Controllers/CareerController.php @@ -0,0 +1,3 @@ +Job::withCount('applications')->latest()->paginate(20),'applications'=>Application::latest()->limit(10)->get()]); } public function store(Request $r): RedirectResponse { Job::create($r->validate(['title'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:career_jobs,slug'],'department'=>['nullable','string','max:120'],'location'=>['nullable','string','max:120'],'employment_type'=>['required','in:full-time,part-time,contract,temporary,internship'],'description'=>['nullable','string'],'status'=>['required','in:draft,published,closed'],'published_at'=>['nullable','date'],'closes_at'=>['nullable','date','after:published_at']])); return back()->with('status','Job created.'); } public function destroy(Job $job): RedirectResponse { $job->delete(); return back()->with('status','Job deleted.'); } } diff --git a/Modules/Careers/Models/Application.php b/Modules/Careers/Models/Application.php new file mode 100644 index 0000000..dcdf3c1 --- /dev/null +++ b/Modules/Careers/Models/Application.php @@ -0,0 +1,3 @@ +'datetime','closes_at'=>'datetime']; } public function applications(): HasMany { return $this->hasMany(Application::class); } } diff --git a/Modules/Careers/Providers/CareersServiceProvider.php b/Modules/Careers/Providers/CareersServiceProvider.php new file mode 100644 index 0000000..c730a60 --- /dev/null +++ b/Modules/Careers/Providers/CareersServiceProvider.php @@ -0,0 +1,16 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Careers', 'Config/config.php'), 'careers'); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Careers', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Careers', 'Resources/views'), 'careers'); + } +} diff --git a/Modules/Careers/Providers/RouteServiceProvider.php b/Modules/Careers/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..5646e53 --- /dev/null +++ b/Modules/Careers/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Careers', 'Routes/web.php')); + } +} diff --git a/Modules/Careers/Resources/views/admin/index.blade.php b/Modules/Careers/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..bac71a2 --- /dev/null +++ b/Modules/Careers/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Careers

Manage vacancies and candidate applications.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Create vacancy

Create vacancy
@forelse($jobs as $job)@empty@endforelse
VacancyDepartmentStatusApplications
{{ $job->title }}{{ $job->department ?: '—' }}{{ ucfirst($job->status) }}{{ $job->applications_count }}
@csrf @method('DELETE')Delete
No vacancies yet.
{{ $jobs->links() }}
diff --git a/Modules/Careers/Routes/web.php b/Modules/Careers/Routes/web.php new file mode 100644 index 0000000..9c96658 --- /dev/null +++ b/Modules/Careers/Routes/web.php @@ -0,0 +1,3 @@ +prefix('administration/careers')->name('administration.careers.')->group(function(): void { Route::get('/',[CareerController::class,'index'])->name('index'); Route::post('/',[CareerController::class,'store'])->name('store'); Route::delete('/{job}',[CareerController::class,'destroy'])->name('destroy'); }); diff --git a/Modules/Careers/Tests/Feature/ModuleBootTest.php b/Modules/Careers/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..afcdaf3 --- /dev/null +++ b/Modules/Careers/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,10 @@ +assertSame('Careers', config('careers.name')); + } +} diff --git a/Modules/Careers/module.json b/Modules/Careers/module.json new file mode 100644 index 0000000..cda83cf --- /dev/null +++ b/Modules/Careers/module.json @@ -0,0 +1,12 @@ +{ + "name": "Careers", + "alias": "careers", + "version": "1.0.0", + "description": "Job vacancies and candidate application workflow.", + "keywords": ["business", "careers"], + "priority": 43, + "providers": ["Modules\\Careers\\Providers\\CareersServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Modules/Events/Config/config.php b/Modules/Events/Config/config.php new file mode 100644 index 0000000..13c1a29 --- /dev/null +++ b/Modules/Events/Config/config.php @@ -0,0 +1,2 @@ + 'Events']; diff --git a/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php b/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php new file mode 100644 index 0000000..5368f69 --- /dev/null +++ b/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php @@ -0,0 +1,3 @@ +id();$t->string('name');$t->text('address')->nullable();$t->unsignedInteger('capacity')->nullable();$t->timestamps();}); Schema::create('events',function(Blueprint $t): void{$t->id();$t->foreignId('venue_id')->nullable()->constrained('event_venues')->nullOnDelete();$t->string('title');$t->string('slug')->unique();$t->text('summary')->nullable();$t->longText('description')->nullable();$t->timestamp('starts_at')->index();$t->timestamp('ends_at')->nullable();$t->string('status')->default('draft')->index();$t->unsignedInteger('capacity')->nullable();$t->decimal('price',10,2)->default(0);$t->boolean('registration_open')->default(false);$t->timestamps();}); Schema::create('event_registrations',function(Blueprint $t): void{$t->id();$t->foreignId('event_id')->constrained()->cascadeOnDelete();$t->string('name');$t->string('email')->index();$t->unsignedInteger('quantity')->default(1);$t->string('status')->default('confirmed')->index();$t->text('notes')->nullable();$t->timestamps();}); } public function down(): void { Schema::dropIfExists('event_registrations');Schema::dropIfExists('events');Schema::dropIfExists('event_venues'); } }; diff --git a/Modules/Events/Http/Controllers/EventController.php b/Modules/Events/Http/Controllers/EventController.php new file mode 100644 index 0000000..ef50ff9 --- /dev/null +++ b/Modules/Events/Http/Controllers/EventController.php @@ -0,0 +1,3 @@ +Event::with(['venue'])->withCount('registrations')->orderBy('starts_at')->paginate(20),'venues'=>Venue::orderBy('name')->get()]); } public function store(Request $r): RedirectResponse { Event::create($r->validate(['venue_id'=>['nullable','exists:event_venues,id'],'title'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:events,slug'],'summary'=>['nullable','string'],'description'=>['nullable','string'],'starts_at'=>['required','date'],'ends_at'=>['nullable','date','after:starts_at'],'status'=>['required','in:draft,published,cancelled,completed'],'capacity'=>['nullable','integer','min:1'],'price'=>['required','numeric','min:0']])+['registration_open'=>$r->boolean('registration_open')]); return back()->with('status','Event created.'); } public function destroy(Event $event): RedirectResponse { $event->delete(); return back()->with('status','Event deleted.'); } } diff --git a/Modules/Events/Models/Event.php b/Modules/Events/Models/Event.php new file mode 100644 index 0000000..a96b5f4 --- /dev/null +++ b/Modules/Events/Models/Event.php @@ -0,0 +1,3 @@ +'datetime','ends_at'=>'datetime','price'=>'decimal:2','registration_open'=>'boolean']; } public function venue(): BelongsTo { return $this->belongsTo(Venue::class); } public function registrations(): HasMany { return $this->hasMany(Registration::class); } } diff --git a/Modules/Events/Models/Registration.php b/Modules/Events/Models/Registration.php new file mode 100644 index 0000000..ac071c9 --- /dev/null +++ b/Modules/Events/Models/Registration.php @@ -0,0 +1,3 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Events', 'Config/config.php'), 'events'); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Events', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Events', 'Resources/views'), 'events'); + } +} diff --git a/Modules/Events/Providers/RouteServiceProvider.php b/Modules/Events/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..136f133 --- /dev/null +++ b/Modules/Events/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Events', 'Routes/web.php')); + } +} diff --git a/Modules/Events/Resources/views/admin/index.blade.php b/Modules/Events/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..8cab5c2 --- /dev/null +++ b/Modules/Events/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Events

Manage events, venues and registrations.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Create event

Create event
@forelse($events as $event)@empty@endforelse
EventStartsStatusRegistrations
{{ $event->title }}{{ $event->starts_at->format('j M Y H:i') }}{{ ucfirst($event->status) }}{{ $event->registrations_count }}
@csrf @method('DELETE')Delete
No events yet.
{{ $events->links() }}
diff --git a/Modules/Events/Routes/web.php b/Modules/Events/Routes/web.php new file mode 100644 index 0000000..a212ab9 --- /dev/null +++ b/Modules/Events/Routes/web.php @@ -0,0 +1,3 @@ +prefix('administration/events')->name('administration.events.')->group(function(): void { Route::get('/',[EventController::class,'index'])->name('index'); Route::post('/',[EventController::class,'store'])->name('store'); Route::delete('/{event}',[EventController::class,'destroy'])->name('destroy'); }); diff --git a/Modules/Events/Tests/Feature/ModuleBootTest.php b/Modules/Events/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..0c262ac --- /dev/null +++ b/Modules/Events/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,10 @@ +assertSame('Events', config('events.name')); + } +} diff --git a/Modules/Events/module.json b/Modules/Events/module.json new file mode 100644 index 0000000..7990472 --- /dev/null +++ b/Modules/Events/module.json @@ -0,0 +1,12 @@ +{ + "name": "Events", + "alias": "events", + "version": "1.0.0", + "description": "Events, venues and registration management.", + "keywords": ["business", "events"], + "priority": 42, + "providers": ["Modules\\Events\\Providers\\EventsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Modules/Locations/Config/config.php b/Modules/Locations/Config/config.php new file mode 100644 index 0000000..6269238 --- /dev/null +++ b/Modules/Locations/Config/config.php @@ -0,0 +1,2 @@ + 'Locations']; diff --git a/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php b/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php new file mode 100644 index 0000000..13f2c46 --- /dev/null +++ b/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php @@ -0,0 +1,3 @@ +id();$t->string('name');$t->string('slug')->unique();$t->string('type')->default('office');$t->text('address')->nullable();$t->string('postcode')->nullable();$t->string('country')->nullable();$t->string('phone')->nullable();$t->string('email')->nullable();$t->decimal('latitude',10,7)->nullable();$t->decimal('longitude',10,7)->nullable();$t->json('opening_hours')->nullable();$t->boolean('is_active')->default(true);$t->unsignedInteger('sort_order')->default(0);$t->timestamps();}); } public function down(): void { Schema::dropIfExists('locations'); } }; diff --git a/Modules/Locations/Http/Controllers/LocationController.php b/Modules/Locations/Http/Controllers/LocationController.php new file mode 100644 index 0000000..cb6dce7 --- /dev/null +++ b/Modules/Locations/Http/Controllers/LocationController.php @@ -0,0 +1,3 @@ +Location::orderBy('sort_order')->orderBy('name')->paginate(25)]); } public function store(Request $r): RedirectResponse { Location::create($r->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:locations,slug'],'type'=>['required','in:office,store,warehouse,venue,service'],'address'=>['nullable','string'],'postcode'=>['nullable','string','max:30'],'country'=>['nullable','string','max:120'],'phone'=>['nullable','string','max:80'],'email'=>['nullable','email','max:255'],'latitude'=>['nullable','numeric','between:-90,90'],'longitude'=>['nullable','numeric','between:-180,180'],'sort_order'=>['nullable','integer','min:0']])+['is_active'=>$r->boolean('is_active',true)]); return back()->with('status','Location created.'); } public function destroy(Location $location): RedirectResponse { $location->delete(); return back()->with('status','Location deleted.'); } } diff --git a/Modules/Locations/Models/Location.php b/Modules/Locations/Models/Location.php new file mode 100644 index 0000000..8ac3d1d --- /dev/null +++ b/Modules/Locations/Models/Location.php @@ -0,0 +1,3 @@ +'decimal:7','longitude'=>'decimal:7','opening_hours'=>'array','is_active'=>'boolean']; } } diff --git a/Modules/Locations/Providers/LocationsServiceProvider.php b/Modules/Locations/Providers/LocationsServiceProvider.php new file mode 100644 index 0000000..a13736e --- /dev/null +++ b/Modules/Locations/Providers/LocationsServiceProvider.php @@ -0,0 +1,16 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Locations', 'Config/config.php'), 'locations'); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Locations', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Locations', 'Resources/views'), 'locations'); + } +} diff --git a/Modules/Locations/Providers/RouteServiceProvider.php b/Modules/Locations/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..ceb62de --- /dev/null +++ b/Modules/Locations/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Locations', 'Routes/web.php')); + } +} diff --git a/Modules/Locations/Resources/views/admin/index.blade.php b/Modules/Locations/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..3b1f2c1 --- /dev/null +++ b/Modules/Locations/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Locations

Manage offices, stores, venues and service locations.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Add location

Create location
@forelse($locations as $location)@empty@endforelse
LocationTypeContactStatus
{{ $location->name }}
{{ $location->address }}
{{ ucfirst($location->type) }}{{ $location->phone ?: $location->email ?: '—' }}{{ $location->is_active ? 'Active' : 'Inactive' }}
@csrf @method('DELETE')Delete
No locations yet.
{{ $locations->links() }}
diff --git a/Modules/Locations/Routes/web.php b/Modules/Locations/Routes/web.php new file mode 100644 index 0000000..9497418 --- /dev/null +++ b/Modules/Locations/Routes/web.php @@ -0,0 +1,3 @@ +prefix('administration/locations')->name('administration.locations.')->group(function(): void { Route::get('/',[LocationController::class,'index'])->name('index'); Route::post('/',[LocationController::class,'store'])->name('store'); Route::delete('/{location}',[LocationController::class,'destroy'])->name('destroy'); }); diff --git a/Modules/Locations/Tests/Feature/ModuleBootTest.php b/Modules/Locations/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..1ae9804 --- /dev/null +++ b/Modules/Locations/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,10 @@ +assertSame('Locations', config('locations.name')); + } +} diff --git a/Modules/Locations/module.json b/Modules/Locations/module.json new file mode 100644 index 0000000..86865e1 --- /dev/null +++ b/Modules/Locations/module.json @@ -0,0 +1,12 @@ +{ + "name": "Locations", + "alias": "locations", + "version": "1.0.0", + "description": "Office and service location management.", + "keywords": ["business", "locations"], + "priority": 44, + "providers": ["Modules\\Locations\\Providers\\LocationsServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Modules/Newsletter/Config/config.php b/Modules/Newsletter/Config/config.php new file mode 100644 index 0000000..6b8fedf --- /dev/null +++ b/Modules/Newsletter/Config/config.php @@ -0,0 +1,2 @@ + 'Newsletter']; diff --git a/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php b/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php new file mode 100644 index 0000000..6253e31 --- /dev/null +++ b/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php @@ -0,0 +1,3 @@ +id();$t->string('name');$t->text('description')->nullable();$t->boolean('is_active')->default(true);$t->timestamps();}); Schema::create('newsletter_subscribers',function(Blueprint $t): void{$t->id();$t->string('email')->unique();$t->string('name')->nullable();$t->string('status')->default('pending')->index();$t->string('confirmation_token')->nullable()->unique();$t->timestamp('confirmed_at')->nullable();$t->timestamp('unsubscribed_at')->nullable();$t->json('metadata')->nullable();$t->timestamps();}); Schema::create('newsletter_list_subscriber',function(Blueprint $t): void{$t->foreignId('list_id')->constrained('newsletter_lists')->cascadeOnDelete();$t->foreignId('subscriber_id')->constrained('newsletter_subscribers')->cascadeOnDelete();$t->primary(['list_id','subscriber_id']);}); Schema::create('newsletter_campaigns',function(Blueprint $t): void{$t->id();$t->foreignId('list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete();$t->string('name');$t->string('subject');$t->longText('content')->nullable();$t->string('status')->default('draft')->index();$t->timestamp('scheduled_at')->nullable();$t->timestamp('sent_at')->nullable();$t->unsignedInteger('sent_count')->default(0);$t->unsignedInteger('open_count')->default(0);$t->unsignedInteger('click_count')->default(0);$t->timestamps();}); } public function down(): void { Schema::dropIfExists('newsletter_campaigns');Schema::dropIfExists('newsletter_list_subscriber');Schema::dropIfExists('newsletter_subscribers');Schema::dropIfExists('newsletter_lists'); } }; diff --git a/Modules/Newsletter/Http/Controllers/NewsletterController.php b/Modules/Newsletter/Http/Controllers/NewsletterController.php new file mode 100644 index 0000000..b815a5b --- /dev/null +++ b/Modules/Newsletter/Http/Controllers/NewsletterController.php @@ -0,0 +1,3 @@ +Subscriber::latest()->paginate(20),'campaigns'=>Campaign::latest()->limit(10)->get(),'lists'=>MailingList::orderBy('name')->get()]); } public function storeSubscriber(Request $r): RedirectResponse { $d=$r->validate(['email'=>['required','email','unique:newsletter_subscribers,email'],'name'=>['nullable','string','max:255']]); Subscriber::create($d+['status'=>'confirmed','confirmation_token'=>Str::random(48),'confirmed_at'=>now()]); return back()->with('status','Subscriber added.'); } public function storeCampaign(Request $r): RedirectResponse { Campaign::create($r->validate(['list_id'=>['nullable','exists:newsletter_lists,id'],'name'=>['required','string','max:255'],'subject'=>['required','string','max:255'],'content'=>['nullable','string'],'status'=>['required','in:draft,scheduled,sent'],'scheduled_at'=>['nullable','date']])); return back()->with('status','Campaign created.'); } public function destroySubscriber(Subscriber $subscriber): RedirectResponse { $subscriber->delete(); return back()->with('status','Subscriber deleted.'); } } diff --git a/Modules/Newsletter/Models/Campaign.php b/Modules/Newsletter/Models/Campaign.php new file mode 100644 index 0000000..dd60a87 --- /dev/null +++ b/Modules/Newsletter/Models/Campaign.php @@ -0,0 +1,3 @@ +'datetime','sent_at'=>'datetime']; } } diff --git a/Modules/Newsletter/Models/MailingList.php b/Modules/Newsletter/Models/MailingList.php new file mode 100644 index 0000000..8df61d6 --- /dev/null +++ b/Modules/Newsletter/Models/MailingList.php @@ -0,0 +1,3 @@ +'boolean']; } } diff --git a/Modules/Newsletter/Models/Subscriber.php b/Modules/Newsletter/Models/Subscriber.php new file mode 100644 index 0000000..9ac6508 --- /dev/null +++ b/Modules/Newsletter/Models/Subscriber.php @@ -0,0 +1,3 @@ +'datetime','unsubscribed_at'=>'datetime','metadata'=>'array']; } } diff --git a/Modules/Newsletter/Providers/NewsletterServiceProvider.php b/Modules/Newsletter/Providers/NewsletterServiceProvider.php new file mode 100644 index 0000000..07ed786 --- /dev/null +++ b/Modules/Newsletter/Providers/NewsletterServiceProvider.php @@ -0,0 +1,16 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Newsletter', 'Config/config.php'), 'newsletter'); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Newsletter', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Newsletter', 'Resources/views'), 'newsletter'); + } +} diff --git a/Modules/Newsletter/Providers/RouteServiceProvider.php b/Modules/Newsletter/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..2ee035c --- /dev/null +++ b/Modules/Newsletter/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Newsletter', 'Routes/web.php')); + } +} diff --git a/Modules/Newsletter/Resources/views/admin/index.blade.php b/Modules/Newsletter/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..93c0489 --- /dev/null +++ b/Modules/Newsletter/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +

Newsletter

Manage subscribers and email campaigns.

@if(session('status'))
{{ session('status') }}
@endif
@csrf

Add subscriber

Add subscriber
@csrf

Create campaign

Create campaign
@forelse($subscribers as $subscriber)@empty@endforelse
SubscriberStatusConfirmed
{{ $subscriber->email }}
{{ $subscriber->name }}
{{ ucfirst($subscriber->status) }}{{ optional($subscriber->confirmed_at)->format('j M Y') ?: '—' }}
@csrf @method('DELETE')Delete
No subscribers yet.
{{ $subscribers->links() }}
diff --git a/Modules/Newsletter/Routes/web.php b/Modules/Newsletter/Routes/web.php new file mode 100644 index 0000000..6c46074 --- /dev/null +++ b/Modules/Newsletter/Routes/web.php @@ -0,0 +1,3 @@ +prefix('administration/newsletter')->name('administration.newsletter.')->group(function(): void { Route::get('/',[NewsletterController::class,'index'])->name('index'); Route::post('/subscribers',[NewsletterController::class,'storeSubscriber'])->name('subscribers.store'); Route::delete('/subscribers/{subscriber}',[NewsletterController::class,'destroySubscriber'])->name('subscribers.destroy'); Route::post('/campaigns',[NewsletterController::class,'storeCampaign'])->name('campaigns.store'); }); diff --git a/Modules/Newsletter/Tests/Feature/ModuleBootTest.php b/Modules/Newsletter/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..863df14 --- /dev/null +++ b/Modules/Newsletter/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,10 @@ +assertSame('Newsletter', config('newsletter.name')); + } +} diff --git a/Modules/Newsletter/module.json b/Modules/Newsletter/module.json new file mode 100644 index 0000000..620f465 --- /dev/null +++ b/Modules/Newsletter/module.json @@ -0,0 +1,12 @@ +{ + "name": "Newsletter", + "alias": "newsletter", + "version": "1.0.0", + "description": "Subscriber lists and email campaign management.", + "keywords": ["business", "newsletter"], + "priority": 41, + "providers": ["Modules\\Newsletter\\Providers\\NewsletterServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index d34c969..2c0140e 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -45,6 +45,11 @@ final class RolePermissionSeeder extends Seeder 'tags.manage', 'authors.manage', 'comments.moderate', + 'crm.manage', + 'newsletter.manage', + 'events.manage', + 'careers.manage', + 'locations.manage', ]; foreach ($permissions as $permission) { diff --git a/modules_statuses.json b/modules_statuses.json index 842b241..b21c7fe 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -1,5 +1,34 @@ { - "Stylist": true, "Core": true, "Administration": true, "Users": true, "Roles": true, "Settings": true, "Audit": true, "Media": true, "Seo": true, "Search": true, - "Pages": true, "Menus": true, "Redirects": true, "Forms": true, "Documents": true, "Banners": true, "Faq": true, "Team": true, "Testimonials": true, - "Categories": true, "Tags": true, "Authors": true, "Blog": true, "News": true, "Comments": true, "Feeds": true, "Sitemap": true + "Stylist": true, + "Core": true, + "Administration": true, + "Users": true, + "Roles": true, + "Settings": true, + "Audit": true, + "Media": true, + "Seo": true, + "Search": true, + "Pages": true, + "Menus": true, + "Redirects": true, + "Forms": true, + "Documents": true, + "Banners": true, + "Faq": true, + "Team": true, + "Testimonials": true, + "Categories": true, + "Tags": true, + "Authors": true, + "Blog": true, + "News": true, + "Comments": true, + "Feeds": true, + "Sitemap": true, + "CRM": true, + "Newsletter": true, + "Events": true, + "Careers": true, + "Locations": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index a65cdb4..2d8d3d6 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -5,6 +5,13 @@ + +{{ __('CRM') }} +{{ __('Newsletter') }} +{{ __('Events') }} +{{ __('Careers') }} +{{ __('Locations') }} + {{ __('Blog') }} {{ __('News') }}