Skip to content

Commit 73223f7

Browse files
committed
feat: Subcontracting/Rental React pages + Survey module + 10 Subcontracting tests
Subcontracting (frontend): - Index.tsx: order list with status badges, new order inline form - Show.tsx: order detail with components table, action buttons per status Rental (frontend): - Index.tsx: items + agreements tabs, rent/create inline forms - Show.tsx: item detail with current agreement - Calendar.tsx: monthly availability grid - Agreements.tsx: all agreements with status filters Survey module (backend): - Survey, SurveyQuestion, SurveyResponse, SurveyAnswer models - publish/close lifecycle, respond() with isOpen() guard, results aggregation - SurveyController with full CRUD + publish/close/respond/results/addQuestion - 4 migrations with dropIfExists guards - SurveyServiceProvider registered in CoreServiceProvider CoreServiceProvider: registered SubscriptionsServiceProvider + SurveyServiceProvider https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 552bc89 commit 73223f7

19 files changed

Lines changed: 2218 additions & 0 deletions

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use App\Modules\Subcontracting\Providers\SubcontractingServiceProvider;
2525
use App\Modules\Rental\Providers\RentalServiceProvider;
2626
use App\Modules\Subscriptions\Providers\SubscriptionsServiceProvider;
27+
use App\Modules\Survey\Providers\SurveyServiceProvider;
2728
use Illuminate\Support\Facades\Gate;
2829
use Illuminate\Support\ServiceProvider;
2930

@@ -49,6 +50,7 @@ public function register(): void
4950
$this->app->register(SubcontractingServiceProvider::class);
5051
$this->app->register(RentalServiceProvider::class);
5152
$this->app->register(SubscriptionsServiceProvider::class);
53+
$this->app->register(SurveyServiceProvider::class);
5254
}
5355

5456
public function boot(): void
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
<?php
2+
3+
namespace App\Modules\Survey\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Survey\Models\Survey;
7+
use App\Modules\Survey\Models\SurveyAnswer;
8+
use App\Modules\Survey\Models\SurveyQuestion;
9+
use App\Modules\Survey\Models\SurveyResponse;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class SurveyController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$surveys = Survey::when($request->status, fn ($q) => $q->where('status', $request->status))
20+
->orderByDesc('created_at')
21+
->paginate(20)
22+
->withQueryString();
23+
24+
return Inertia::render('Survey/Index', [
25+
'surveys' => $surveys,
26+
'filters' => $request->only(['status']),
27+
]);
28+
}
29+
30+
public function show(Survey $survey): Response
31+
{
32+
$survey->load('questions');
33+
34+
return Inertia::render('Survey/Show', [
35+
'survey' => $survey,
36+
'responseCount' => $survey->responseCount(),
37+
]);
38+
}
39+
40+
public function store(Request $request): RedirectResponse
41+
{
42+
$validated = $request->validate([
43+
'title' => 'required|string|max:255',
44+
'description' => 'nullable|string',
45+
]);
46+
47+
$survey = Survey::create([
48+
...$validated,
49+
'tenant_id' => auth()->user()->tenant_id,
50+
'created_by' => auth()->id(),
51+
'status' => 'draft',
52+
]);
53+
54+
return redirect()->route('surveys.show', $survey)->with('success', 'Survey created.');
55+
}
56+
57+
public function update(Request $request, Survey $survey): RedirectResponse
58+
{
59+
abort_if($survey->status !== 'draft', 403, 'Only draft surveys can be updated.');
60+
61+
$validated = $request->validate([
62+
'title' => 'sometimes|required|string|max:255',
63+
'description' => 'nullable|string',
64+
'starts_at' => 'nullable|date',
65+
'ends_at' => 'nullable|date',
66+
]);
67+
68+
$survey->update($validated);
69+
70+
return redirect()->route('surveys.show', $survey)->with('success', 'Survey updated.');
71+
}
72+
73+
public function destroy(Survey $survey): RedirectResponse
74+
{
75+
abort_if($survey->status !== 'draft', 403, 'Only draft surveys can be deleted.');
76+
77+
$survey->delete();
78+
79+
return redirect()->route('surveys.index')->with('success', 'Survey deleted.');
80+
}
81+
82+
public function publish(Survey $survey): RedirectResponse
83+
{
84+
$survey->publish();
85+
86+
return redirect()->back()->with('success', 'Survey published.');
87+
}
88+
89+
public function close(Survey $survey): RedirectResponse
90+
{
91+
$survey->close();
92+
93+
return redirect()->back()->with('success', 'Survey closed.');
94+
}
95+
96+
public function addQuestion(Request $request, Survey $survey): RedirectResponse
97+
{
98+
abort_if($survey->status !== 'draft', 403, 'Questions can only be added to draft surveys.');
99+
100+
$validated = $request->validate([
101+
'question_text' => 'required|string',
102+
'question_type' => 'required|in:text,single_choice,multiple_choice,rating,yes_no',
103+
'is_required' => 'boolean',
104+
'sequence' => 'integer',
105+
'options' => 'nullable|array',
106+
'options.*' => 'string',
107+
]);
108+
109+
$survey->questions()->create([
110+
...$validated,
111+
'tenant_id' => $survey->tenant_id,
112+
]);
113+
114+
return redirect()->back()->with('success', 'Question added.');
115+
}
116+
117+
public function removeQuestion(Survey $survey, SurveyQuestion $question): RedirectResponse
118+
{
119+
abort_if($question->survey_id !== $survey->id, 404);
120+
121+
$question->delete();
122+
123+
return redirect()->back()->with('success', 'Question removed.');
124+
}
125+
126+
public function respond(Request $request, Survey $survey): RedirectResponse
127+
{
128+
abort_unless($survey->isOpen(), 422, 'This survey is not open for responses.');
129+
130+
$validated = $request->validate([
131+
'respondent_name' => 'nullable|string|max:255',
132+
'respondent_email' => 'nullable|email|max:255',
133+
'answers' => 'nullable|array',
134+
'answers.*.question_id' => 'required|exists:survey_questions,id',
135+
'answers.*.answer_text' => 'nullable|string',
136+
'answers.*.answer_options' => 'nullable|array',
137+
]);
138+
139+
$response = SurveyResponse::create([
140+
'survey_id' => $survey->id,
141+
'tenant_id' => $survey->tenant_id,
142+
'respondent_name' => $validated['respondent_name'] ?? null,
143+
'respondent_email' => $validated['respondent_email'] ?? null,
144+
]);
145+
146+
foreach ($validated['answers'] ?? [] as $answerData) {
147+
SurveyAnswer::create([
148+
'survey_response_id' => $response->id,
149+
'survey_question_id' => $answerData['question_id'],
150+
'tenant_id' => $survey->tenant_id,
151+
'answer_text' => $answerData['answer_text'] ?? null,
152+
'answer_options' => $answerData['answer_options'] ?? null,
153+
]);
154+
}
155+
156+
$response->submit();
157+
158+
return redirect()->back()->with('success', 'Response submitted.');
159+
}
160+
161+
public function results(Survey $survey): Response
162+
{
163+
$survey->load('questions.answers');
164+
165+
$questionStats = $survey->questions->map(function (SurveyQuestion $question) {
166+
$answers = $question->answers;
167+
168+
$stats = match ($question->question_type) {
169+
'text' => [
170+
'type' => 'text',
171+
'answers' => $answers->pluck('answer_text')->filter()->values(),
172+
],
173+
'single_choice', 'multiple_choice' => [
174+
'type' => $question->question_type,
175+
'counts' => collect($question->options ?? [])->mapWithKeys(function ($option) use ($answers) {
176+
$count = $answers->filter(function ($answer) use ($option) {
177+
$opts = $answer->answer_options ?? [];
178+
return in_array($option, $opts, true) || $answer->answer_text === $option;
179+
})->count();
180+
return [$option => $count];
181+
}),
182+
],
183+
'rating' => [
184+
'type' => 'rating',
185+
'average' => $answers->whereNotNull('answer_text')->avg('answer_text'),
186+
'count' => $answers->count(),
187+
],
188+
'yes_no' => [
189+
'type' => 'yes_no',
190+
'yes' => $answers->filter(fn ($a) => strtolower($a->answer_text ?? '') === 'yes')->count(),
191+
'no' => $answers->filter(fn ($a) => strtolower($a->answer_text ?? '') === 'no')->count(),
192+
],
193+
default => ['type' => 'unknown', 'answers' => []],
194+
};
195+
196+
return [
197+
'id' => $question->id,
198+
'question_text' => $question->question_text,
199+
'question_type' => $question->question_type,
200+
'stats' => $stats,
201+
];
202+
});
203+
204+
return Inertia::render('Survey/Results', [
205+
'survey' => $survey,
206+
'questionStats' => $questionStats,
207+
'responseCount' => $survey->responseCount(),
208+
]);
209+
}
210+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace App\Modules\Survey\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class Survey extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'title',
19+
'description',
20+
'status',
21+
'starts_at',
22+
'ends_at',
23+
'created_by',
24+
];
25+
26+
protected $casts = [
27+
'starts_at' => 'datetime',
28+
'ends_at' => 'datetime',
29+
];
30+
31+
public function creator(): BelongsTo
32+
{
33+
return $this->belongsTo(User::class, 'created_by');
34+
}
35+
36+
public function questions(): HasMany
37+
{
38+
return $this->hasMany(SurveyQuestion::class)->orderBy('sequence');
39+
}
40+
41+
public function responses(): HasMany
42+
{
43+
return $this->hasMany(SurveyResponse::class);
44+
}
45+
46+
public function publish(): void
47+
{
48+
if ($this->starts_at === null) {
49+
$this->starts_at = now();
50+
}
51+
$this->status = 'published';
52+
$this->save();
53+
}
54+
55+
public function close(): void
56+
{
57+
$this->status = 'closed';
58+
$this->ends_at = now();
59+
$this->save();
60+
}
61+
62+
public function responseCount(): int
63+
{
64+
return $this->responses()->count();
65+
}
66+
67+
public function isOpen(): bool
68+
{
69+
return $this->status === 'published'
70+
&& ($this->ends_at === null || $this->ends_at->gt(now()));
71+
}
72+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Survey\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class SurveyAnswer extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'survey_response_id',
15+
'survey_question_id',
16+
'tenant_id',
17+
'answer_text',
18+
'answer_options',
19+
];
20+
21+
protected $casts = [
22+
'answer_options' => 'array',
23+
];
24+
25+
public function response(): BelongsTo
26+
{
27+
return $this->belongsTo(SurveyResponse::class, 'survey_response_id');
28+
}
29+
30+
public function question(): BelongsTo
31+
{
32+
return $this->belongsTo(SurveyQuestion::class, 'survey_question_id');
33+
}
34+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace App\Modules\Survey\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
class SurveyQuestion extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'survey_id',
16+
'tenant_id',
17+
'question_text',
18+
'question_type',
19+
'is_required',
20+
'sequence',
21+
'options',
22+
];
23+
24+
protected $casts = [
25+
'options' => 'array',
26+
'is_required' => 'boolean',
27+
];
28+
29+
public function survey(): BelongsTo
30+
{
31+
return $this->belongsTo(Survey::class);
32+
}
33+
34+
public function answers(): HasMany
35+
{
36+
return $this->hasMany(SurveyAnswer::class);
37+
}
38+
}

0 commit comments

Comments
 (0)