This commit is contained in:
Codex
2026-07-10 10:38:02 -05:00
parent 63820994f8
commit 42db078e47
51 changed files with 1173 additions and 26 deletions
+18 -5
View File
@@ -32,6 +32,7 @@ cp .env.example .env
php artisan key:generate
touch database/database.sqlite
php artisan migrate:fresh --seed
php artisan storage:link
npm run build
php artisan serve --host=0.0.0.0 --port=8123
```
@@ -59,11 +60,23 @@ npm run build
3. Social layer: community feed posts.
4. Job posts: buyer job-post creation and listing.
5. Quote flow: provider quotes and buyer acceptance.
6. Work orders: assignment, status transitions, deliverables, completion notes.
7. Reviews: mutual buyer/provider reviews.
8. Disputes: peer-review dispute records.
9. API prep: Sanctum installed with starter API routes.
10. Hardening: tests, seed data, setup docs, and no-payment/no-rate-setting guardrails.
6. Work orders: assignment, role-aware status transitions, deliverables, messages, evidence files, completion notes.
7. Reviews: mutual buyer/provider reviews with category metrics.
8. Disputes: peer-review dispute records with comments, evidence files, and structured votes.
9. API prep: Sanctum installed with starter API routes and authenticated work-order/dispute feeds.
10. Hardening: tests, seed data, setup docs, database notifications, admin overview, and no-payment/no-rate-setting guardrails.
## Implemented Hardening Pass
- Polymorphic attachments for provider profiles, buyer profiles, feed posts, jobs, work orders, disputes, and imported external profiles.
- Polymorphic comments for feed posts, jobs, and disputes.
- Work-order messages between buyer and provider.
- Quote revision history and quote decline flow.
- Role-aware work-order transition rules.
- Category review metrics for communication, scope accuracy, payment reliability, workmanship, and timeliness.
- Dispute peer votes with provider/buyer/split/insufficient-evidence recommendations.
- Database notifications for quote, work-order, review, and dispute events.
- Admin overview page for users, jobs, work orders, disputes, and attachment counts.
## External Profile Imports
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\Dispute;
use App\Models\JobPost;
use App\Models\User;
use App\Models\WorkOrder;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AdminController extends Controller
{
public function index(Request $request): View
{
abort_unless($request->user()->hasRole('admin'), 403);
return view('admin.index', [
'counts' => [
'users' => User::count(),
'jobs' => JobPost::count(),
'work_orders' => WorkOrder::count(),
'disputes' => Dispute::count(),
'attachments' => Attachment::count(),
],
'users' => User::with('roles')->latest()->limit(10)->get(),
'jobs' => JobPost::with('buyer')->latest()->limit(10)->get(),
'workOrders' => WorkOrder::with('jobPost', 'buyer', 'provider')->latest()->limit(10)->get(),
'disputes' => Dispute::with('workOrder.jobPost', 'openedBy')->latest()->limit(10)->get(),
]);
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dispute;
use App\Models\ExternalProfileImport;
use App\Models\JobPost;
use App\Models\ProviderProfile;
use App\Models\SocialPost;
use App\Models\WorkOrder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class AttachmentController extends Controller
{
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'attachable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,social_post,job_post,work_order,dispute,external_profile_import'],
'attachable_id' => ['required', 'integer'],
'kind' => ['nullable', 'string', 'max:255'],
'caption' => ['nullable', 'string', 'max:1000'],
'file' => ['required', 'file', 'max:10240'],
]);
$attachable = $this->resolveAttachable($data['attachable_type'], (int) $data['attachable_id']);
$this->authorizeAttachment($request, $attachable);
$file = $request->file('file');
$path = $file->store('attachments/'.Str::slug($data['attachable_type']), 'public');
$attachable->attachments()->create([
'user_id' => $request->user()->id,
'kind' => $data['kind'] ?? 'general',
'disk' => 'public',
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getClientMimeType(),
'size' => $file->getSize() ?: 0,
'caption' => $data['caption'] ?? null,
]);
return back()->with('status', 'File uploaded.');
}
private function resolveAttachable(string $type, int $id): mixed
{
return match ($type) {
'provider_profile' => ProviderProfile::findOrFail($id),
'buyer_profile' => \App\Models\BuyerProfile::findOrFail($id),
'social_post' => SocialPost::findOrFail($id),
'job_post' => JobPost::findOrFail($id),
'work_order' => WorkOrder::findOrFail($id),
'dispute' => Dispute::findOrFail($id),
'external_profile_import' => ExternalProfileImport::findOrFail($id),
};
}
private function authorizeAttachment(Request $request, mixed $attachable): void
{
$user = $request->user();
if ($attachable instanceof ProviderProfile) {
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof \App\Models\BuyerProfile) {
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof SocialPost) {
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof JobPost) {
abort_unless($attachable->buyer_id === $user->id || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof WorkOrder) {
abort_unless(in_array($user->id, [$attachable->buyer_id, $attachable->provider_id], true) || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof Dispute) {
abort_unless(in_array($user->id, [$attachable->workOrder->buyer_id, $attachable->workOrder->provider_id], true) || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof ExternalProfileImport) {
abort_unless($attachable->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403);
}
}
}
@@ -12,7 +12,7 @@ class BuyerProfileController extends Controller
public function index(): View
{
return view('buyers.index', [
'profiles' => BuyerProfile::with('user')->latest()->paginate(20),
'profiles' => BuyerProfile::with('user', 'attachments')->latest()->paginate(20),
]);
}
@@ -21,7 +21,7 @@ class BuyerProfileController extends Controller
abort_unless($request->user()->hasRole('buyer'), 403);
return view('buyers.edit', [
'profile' => $request->user()->buyerProfile,
'profile' => $request->user()->buyerProfile?->load('attachments'),
]);
}
@@ -55,7 +55,7 @@ class BuyerProfileController extends Controller
public function show(BuyerProfile $buyer): View
{
return view('buyers.show', [
'profile' => $buyer->load('user'),
'profile' => $buyer->load('user', 'attachments'),
]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dispute;
use App\Models\JobPost;
use App\Models\SocialPost;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CommentController extends Controller
{
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'commentable_type' => ['required', 'string', 'in:social_post,job_post,dispute'],
'commentable_id' => ['required', 'integer'],
'body' => ['required', 'string', 'max:5000'],
]);
$commentable = match ($data['commentable_type']) {
'social_post' => SocialPost::findOrFail($data['commentable_id']),
'job_post' => JobPost::findOrFail($data['commentable_id']),
'dispute' => Dispute::findOrFail($data['commentable_id']),
};
if ($commentable instanceof Dispute) {
abort_unless(
in_array($request->user()->id, [$commentable->workOrder->buyer_id, $commentable->workOrder->provider_id], true)
|| $request->user()->hasRole('admin'),
403
);
}
$commentable->comments()->create([
'user_id' => $request->user()->id,
'body' => $data['body'],
]);
return back()->with('status', 'Comment saved.');
}
}
+13 -1
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Dispute;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -12,7 +13,7 @@ class DisputeController extends Controller
{
public function show(Request $request, Dispute $dispute): View
{
$dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy');
$dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy', 'votes.user', 'comments.user', 'attachments');
abort_unless(
in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true)
@@ -40,6 +41,17 @@ class DisputeController extends Controller
$workOrder->update(['status' => 'disputed']);
$recipient = $request->user()->id === $workOrder->buyer_id
? $workOrder->provider
: $workOrder->buyer;
$recipient->notify(new ExchangeEventNotification(
'Dispute opened',
$request->user()->name.' opened a dispute on '.$workOrder->jobPost->title.'.',
route('disputes.show', $dispute),
'dispute_opened'
));
return redirect()->route('disputes.show', $dispute)->with('status', 'Dispute opened for peer review.');
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dispute;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class DisputeVoteController extends Controller
{
public function store(Request $request, Dispute $dispute): RedirectResponse
{
abort_unless($request->user()->hasAnyRole(['provider', 'buyer', 'admin']), 403);
$data = $request->validate([
'recommendation' => ['required', 'string', 'in:provider,buyer,split,insufficient_evidence'],
'reason' => ['nullable', 'string', 'max:2000'],
]);
$dispute->votes()->updateOrCreate(
['user_id' => $request->user()->id],
$data
);
return redirect()->route('disputes.show', $dispute)->with('status', 'Peer vote saved.');
}
}
+9 -2
View File
@@ -12,7 +12,7 @@ class JobPostController extends Controller
public function index(): View
{
return view('jobs.index', [
'jobs' => JobPost::with('buyer.buyerProfile')->latest()->paginate(20),
'jobs' => JobPost::with('buyer.buyerProfile', 'attachments')->latest()->paginate(20),
]);
}
@@ -50,7 +50,14 @@ class JobPostController extends Controller
public function show(JobPost $job): View
{
return view('jobs.show', [
'job' => $job->load('buyer.buyerProfile', 'quotes.provider.providerProfile', 'workOrder'),
'job' => $job->load(
'buyer.buyerProfile',
'quotes.provider.providerProfile',
'quotes.revisions.user',
'workOrder',
'attachments',
'comments.user'
),
]);
}
}
@@ -12,7 +12,7 @@ class ProviderProfileController extends Controller
public function index(): View
{
return view('providers.index', [
'profiles' => ProviderProfile::with('user', 'externalImports')->latest()->paginate(20),
'profiles' => ProviderProfile::with('user', 'externalImports', 'attachments')->latest()->paginate(20),
]);
}
@@ -21,7 +21,7 @@ class ProviderProfileController extends Controller
abort_unless($request->user()->hasRole('provider'), 403);
return view('providers.edit', [
'profile' => $request->user()->providerProfile,
'profile' => $request->user()->providerProfile?->load('attachments', 'externalImports.attachments'),
]);
}
@@ -59,7 +59,7 @@ class ProviderProfileController extends Controller
public function show(ProviderProfile $provider): View
{
return view('providers.show', [
'profile' => $provider->load('user', 'externalImports'),
'profile' => $provider->load('user', 'externalImports.attachments', 'attachments'),
]);
}
}
+79 -1
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\JobPost;
use App\Models\Quote;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -23,14 +24,80 @@ class QuoteController extends Controller
'terms' => ['nullable', 'string'],
]);
$job->quotes()->create($data + [
$quote = $job->quotes()->create($data + [
'provider_id' => $request->user()->id,
'status' => 'submitted',
]);
$quote->revisions()->create($data + [
'user_id' => $request->user()->id,
'action' => 'submitted',
]);
$job->buyer->notify(new ExchangeEventNotification(
'New quote received',
$request->user()->name.' submitted a quote for '.$job->title.'.',
route('jobs.show', $job),
'quote_submitted'
));
return redirect()->route('jobs.show', $job)->with('status', 'Quote submitted.');
}
public function update(Request $request, Quote $quote): RedirectResponse
{
$quote->load('jobPost');
abort_unless($quote->provider_id === $request->user()->id, 403);
abort_unless(in_array($quote->status, ['submitted', 'countered', 'revised'], true), 422);
abort_unless($quote->jobPost->status === 'open', 422);
$data = $request->validate([
'requested_amount' => ['nullable', 'numeric', 'min:0'],
'rate_summary' => ['nullable', 'string', 'max:255'],
'message' => ['nullable', 'string'],
'terms' => ['nullable', 'string'],
]);
$quote->update($data + ['status' => 'revised']);
$quote->revisions()->create($data + [
'user_id' => $request->user()->id,
'action' => 'revised',
]);
$quote->jobPost->buyer->notify(new ExchangeEventNotification(
'Quote revised',
$request->user()->name.' revised a quote for '.$quote->jobPost->title.'.',
route('jobs.show', $quote->jobPost),
'quote_revised'
));
return redirect()->route('jobs.show', $quote->jobPost)->with('status', 'Quote revised.');
}
public function decline(Request $request, Quote $quote): RedirectResponse
{
$quote->load('jobPost');
abort_unless($quote->jobPost->buyer_id === $request->user()->id, 403);
abort_unless($quote->jobPost->status === 'open', 422);
$quote->update(['status' => 'declined']);
$quote->revisions()->create([
'user_id' => $request->user()->id,
'action' => 'declined',
]);
$quote->provider->notify(new ExchangeEventNotification(
'Quote declined',
$quote->jobPost->buyer->name.' declined your quote for '.$quote->jobPost->title.'.',
route('jobs.show', $quote->jobPost),
'quote_declined'
));
return redirect()->route('jobs.show', $quote->jobPost)->with('status', 'Quote declined.');
}
public function accept(Request $request, Quote $quote): RedirectResponse
{
$quote->load('jobPost');
@@ -39,6 +106,10 @@ class QuoteController extends Controller
abort_unless($quote->jobPost->status === 'open', 422);
$quote->update(['status' => 'accepted']);
$quote->revisions()->create([
'user_id' => $request->user()->id,
'action' => 'accepted',
]);
$quote->jobPost->quotes()->whereKeyNot($quote->id)->update(['status' => 'declined']);
$quote->jobPost->update(['status' => 'assigned']);
@@ -57,6 +128,13 @@ class QuoteController extends Controller
]],
]);
$quote->provider->notify(new ExchangeEventNotification(
'Quote accepted',
$quote->jobPost->buyer->name.' accepted your quote for '.$quote->jobPost->title.'.',
route('work-orders.show', $workOrder),
'quote_accepted'
));
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Quote accepted and work order created.');
}
}
+17
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -18,6 +19,11 @@ class ReviewController extends Controller
$data = $request->validate([
'rating' => ['required', 'integer', 'min:1', 'max:5'],
'communication_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'scope_accuracy_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'payment_reliability_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'workmanship_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'timeliness_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'body' => ['nullable', 'string'],
]);
@@ -31,6 +37,17 @@ class ReviewController extends Controller
]
);
$reviewee = $request->user()->id === $workOrder->buyer_id
? $workOrder->provider
: $workOrder->buyer;
$reviewee->notify(new ExchangeEventNotification(
'New review received',
$request->user()->name.' reviewed a completed work order.',
route('work-orders.show', $workOrder),
'review_received'
));
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Review saved.');
}
}
@@ -12,7 +12,7 @@ class SocialPostController extends Controller
public function index(): View
{
return view('feed.index', [
'posts' => SocialPost::with('user')->latest()->paginate(20),
'posts' => SocialPost::with('user', 'attachments', 'comments.user')->latest()->paginate(20),
]);
}
+35 -1
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -34,7 +35,9 @@ class WorkOrderController extends Controller
'acceptedQuote',
'reviews.reviewer',
'reviews.reviewee',
'disputes.openedBy'
'disputes.openedBy',
'messages.user',
'attachments',
),
]);
}
@@ -49,6 +52,9 @@ class WorkOrderController extends Controller
]);
$status = $data['status'];
abort_unless($workOrder->canTransitionTo($status), 422, 'This status transition is not allowed.');
$this->authorizeTransitionRole($request, $workOrder, $status);
$timestamps = [
'en_route' => 'en_route_at',
'on_site' => 'on_site_at',
@@ -77,6 +83,17 @@ class WorkOrderController extends Controller
$workOrder->update($updates);
$recipient = $request->user()->id === $workOrder->buyer_id
? $workOrder->provider
: $workOrder->buyer;
$recipient->notify(new ExchangeEventNotification(
'Work order updated',
$request->user()->name.' changed '.$workOrder->jobPost->title.' to '.str_replace('_', ' ', $status).'.',
route('work-orders.show', $workOrder),
'work_order_status'
));
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order updated.');
}
@@ -88,4 +105,21 @@ class WorkOrderController extends Controller
403
);
}
private function authorizeTransitionRole(Request $request, WorkOrder $workOrder, string $status): void
{
$userId = $request->user()->id;
$providerStatuses = ['en_route', 'on_site', 'in_progress', 'completed'];
$buyerStatuses = ['buyer_approved', 'closed', 'cancelled'];
$sharedStatuses = ['disputed'];
if (in_array($status, $providerStatuses, true)) {
abort_unless($workOrder->provider_id === $userId || $request->user()->hasRole('admin'), 403);
} elseif (in_array($status, $buyerStatuses, true)) {
abort_unless($workOrder->buyer_id === $userId || $request->user()->hasRole('admin'), 403);
} elseif (in_array($status, $sharedStatuses, true)) {
abort_unless(in_array($userId, [$workOrder->buyer_id, $workOrder->provider_id], true) || $request->user()->hasRole('admin'), 403);
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class WorkOrderMessageController extends Controller
{
public function store(Request $request, WorkOrder $workOrder): RedirectResponse
{
abort_unless(
in_array($request->user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true)
|| $request->user()->hasRole('admin'),
403
);
$data = $request->validate([
'body' => ['required', 'string', 'max:5000'],
]);
$workOrder->messages()->create([
'user_id' => $request->user()->id,
'body' => $data['body'],
]);
$recipient = $request->user()->id === $workOrder->buyer_id
? $workOrder->provider
: $workOrder->buyer;
$recipient->notify(new ExchangeEventNotification(
'New work-order message',
$request->user()->name.' sent a message on '.$workOrder->jobPost->title.'.',
route('work-orders.show', $workOrder),
'work_order_message'
));
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Message sent.');
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['user_id', 'kind', 'disk', 'path', 'original_name', 'mime_type', 'size', 'caption'])]
class Attachment extends Model
{
public function attachable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+6
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -17,4 +18,9 @@ class BuyerProfile extends Model
{
return $this->belongsTo(User::class);
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['user_id', 'body', 'is_hidden'])]
class Comment extends Model
{
protected function casts(): array
{
return ['is_hidden' => 'boolean'];
}
public function commentable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+17
View File
@@ -4,6 +4,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -29,4 +31,19 @@ class Dispute extends Model
{
return $this->belongsTo(User::class, 'opened_by_id');
}
public function votes(): HasMany
{
return $this->hasMany(DisputeVote::class);
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['dispute_id', 'user_id', 'recommendation', 'reason'])]
class DisputeVote extends Model
{
public function dispute(): BelongsTo
{
return $this->belongsTo(Dispute::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+6
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -26,4 +27,9 @@ class ExternalProfileImport extends Model
{
return $this->belongsTo(ProviderProfile::class);
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}
+11
View File
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -36,4 +37,14 @@ class JobPost extends Model
{
return $this->hasOne(WorkOrder::class);
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}
+6
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -23,4 +24,9 @@ class ProviderProfile extends Model
{
return $this->hasMany(ExternalProfileImport::class);
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}
+6
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['job_post_id', 'provider_id', 'status', 'requested_amount', 'rate_summary', 'message', 'terms'])]
@@ -18,4 +19,9 @@ class Quote extends Model
{
return $this->belongsTo(User::class, 'provider_id');
}
public function revisions(): HasMany
{
return $this->hasMany(QuoteRevision::class);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['quote_id', 'user_id', 'action', 'requested_amount', 'rate_summary', 'message', 'terms'])]
class QuoteRevision extends Model
{
public function quote(): BelongsTo
{
return $this->belongsTo(Quote::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+5 -1
View File
@@ -6,7 +6,11 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['work_order_id', 'reviewer_id', 'reviewee_id', 'rating', 'body', 'review_type'])]
#[Fillable([
'work_order_id', 'reviewer_id', 'reviewee_id', 'rating', 'communication_rating',
'scope_accuracy_rating', 'payment_reliability_rating', 'workmanship_rating',
'timeliness_rating', 'body', 'review_type',
])]
class Review extends Model
{
public function workOrder(): BelongsTo
+11
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['user_id', 'title', 'body', 'visibility'])]
@@ -13,4 +14,14 @@ class SocialPost extends Model
{
return $this->belongsTo(User::class);
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}
+28
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -19,6 +20,18 @@ class WorkOrder extends Model
'buyer_approved', 'disputed', 'closed', 'cancelled',
];
public const ALLOWED_TRANSITIONS = [
'assigned' => ['en_route', 'cancelled', 'disputed'],
'en_route' => ['on_site', 'cancelled', 'disputed'],
'on_site' => ['in_progress', 'completed', 'disputed'],
'in_progress' => ['completed', 'disputed'],
'completed' => ['buyer_approved', 'disputed'],
'buyer_approved' => ['closed', 'disputed'],
'disputed' => ['closed'],
'closed' => [],
'cancelled' => [],
];
protected function casts(): array
{
return [
@@ -61,4 +74,19 @@ class WorkOrder extends Model
{
return $this->hasMany(Dispute::class);
}
public function messages(): HasMany
{
return $this->hasMany(WorkOrderMessage::class);
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
public function canTransitionTo(string $status): bool
{
return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['work_order_id', 'user_id', 'body', 'is_system'])]
class WorkOrderMessage extends Model
{
protected function casts(): array
{
return ['is_system' => 'boolean'];
}
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class ExchangeEventNotification extends Notification
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public string $title,
public string $body,
public ?string $url = null,
public string $type = 'exchange_event',
) {
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'type' => $this->type,
'title' => $this->title,
'body' => $this->body,
'url' => $this->url,
];
}
}
@@ -17,6 +17,11 @@ return new class extends Migration
$table->foreignId('reviewer_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('reviewee_id')->constrained('users')->cascadeOnDelete();
$table->unsignedTinyInteger('rating');
$table->unsignedTinyInteger('communication_rating')->nullable();
$table->unsignedTinyInteger('scope_accuracy_rating')->nullable();
$table->unsignedTinyInteger('payment_reliability_rating')->nullable();
$table->unsignedTinyInteger('workmanship_rating')->nullable();
$table->unsignedTinyInteger('timeliness_rating')->nullable();
$table->text('body')->nullable();
$table->string('review_type');
$table->timestamps();
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->morphs('attachable');
$table->string('kind')->default('general');
$table->string('disk')->default('public');
$table->string('path');
$table->string('original_name');
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('size')->default(0);
$table->text('caption')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachments');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->morphs('commentable');
$table->text('body');
$table->boolean('is_hidden')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('comments');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('work_order_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('work_order_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->boolean('is_system')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('work_order_messages');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('dispute_votes', function (Blueprint $table) {
$table->id();
$table->foreignId('dispute_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('recommendation');
$table->text('reason')->nullable();
$table->timestamps();
$table->unique(['dispute_id', 'user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('dispute_votes');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('quote_revisions', function (Blueprint $table) {
$table->id();
$table->foreignId('quote_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('action');
$table->decimal('requested_amount', 10, 2)->nullable();
$table->string('rate_summary')->nullable();
$table->text('message')->nullable();
$table->text('terms')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('quote_revisions');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};
+40
View File
@@ -0,0 +1,40 @@
<x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Admin</h2></x-slot>
<div class="mx-auto max-w-7xl space-y-6 p-6">
<section class="grid gap-4 md:grid-cols-5">
@foreach ($counts as $label => $count)
<div class="rounded border bg-white p-4">
<p class="text-sm text-gray-500">{{ str_replace('_', ' ', $label) }}</p>
<p class="text-2xl font-semibold">{{ $count }}</p>
</div>
@endforeach
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="rounded border bg-white p-6">
<h3 class="font-semibold">Recent users</h3>
@foreach ($users as $user)
<p class="mt-3 text-sm">{{ $user->name }} | {{ $user->email }} | {{ $user->roles->pluck('name')->join(', ') }}</p>
@endforeach
</div>
<div class="rounded border bg-white p-6">
<h3 class="font-semibold">Recent jobs</h3>
@foreach ($jobs as $job)
<p class="mt-3 text-sm"><a class="text-indigo-600" href="{{ route('jobs.show', $job) }}">{{ $job->title }}</a> | {{ $job->status }} | {{ $job->buyer->name }}</p>
@endforeach
</div>
<div class="rounded border bg-white p-6">
<h3 class="font-semibold">Recent work orders</h3>
@foreach ($workOrders as $order)
<p class="mt-3 text-sm"><a class="text-indigo-600" href="{{ route('work-orders.show', $order) }}">{{ $order->jobPost->title }}</a> | {{ $order->status }}</p>
@endforeach
</div>
<div class="rounded border bg-white p-6">
<h3 class="font-semibold">Recent disputes</h3>
@foreach ($disputes as $dispute)
<p class="mt-3 text-sm"><a class="text-indigo-600" href="{{ route('disputes.show', $dispute) }}">{{ $dispute->summary }}</a> | {{ $dispute->status }} | {{ $dispute->openedBy->name }}</p>
@endforeach
</div>
</section>
</div>
</x-app-layout>
+7
View File
@@ -15,5 +15,12 @@
<label class="flex gap-2 text-sm"><input type="checkbox" name="public_contact" value="1" @checked($profile?->public_contact)> Show contact details publicly</label>
<x-primary-button>Save buyer profile</x-primary-button>
</form>
@if ($profile)
<section class="mt-6 rounded border bg-white p-6">
<h3 class="font-semibold">Company photos and files</h3>
<x-attachments :attachments="$profile->attachments" />
<x-attachment-form type="buyer_profile" :id="$profile->id" kind="profile" />
</section>
@endif
</div>
</x-app-layout>
+1
View File
@@ -9,5 +9,6 @@
<div><dt class="text-sm text-gray-500">Vendor onboarding</dt><dd class="whitespace-pre-line">{{ $profile->vendor_onboarding }}</dd></div>
<div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $profile->payment_terms }}</dd></div>
</dl>
<x-attachments :attachments="$profile->attachments" />
</div>
</x-app-layout>
@@ -0,0 +1,11 @@
@props(['type', 'id', 'kind' => 'general'])
<form method="POST" action="{{ route('attachments.store') }}" enctype="multipart/form-data" class="mt-4 space-y-3 rounded border p-4">
@csrf
<input type="hidden" name="attachable_type" value="{{ $type }}">
<input type="hidden" name="attachable_id" value="{{ $id }}">
<input type="hidden" name="kind" value="{{ $kind }}">
<x-field name="caption" label="Caption" />
<input type="file" name="file" required class="block w-full text-sm">
<x-primary-button>Upload file</x-primary-button>
</form>
@@ -0,0 +1,12 @@
@props(['attachments'])
@if ($attachments->count())
<div class="mt-4 grid gap-3 md:grid-cols-2">
@foreach ($attachments as $attachment)
<a href="{{ asset('storage/'.$attachment->path) }}" class="block rounded border p-3 text-sm" target="_blank">
<span class="font-semibold">{{ $attachment->original_name }}</span>
<span class="block text-gray-500">{{ $attachment->caption }}</span>
</a>
@endforeach
</div>
@endif
+39
View File
@@ -7,8 +7,47 @@
<p class="mt-2 whitespace-pre-line">{{ $dispute->claim }}</p>
<h3 class="mt-4 font-semibold">Evidence notes</h3>
<p class="mt-2 whitespace-pre-line">{{ $dispute->evidence_notes }}</p>
<x-attachments :attachments="$dispute->attachments" />
<x-attachment-form type="dispute" :id="$dispute->id" kind="evidence" />
<h3 class="mt-4 font-semibold">Recommended resolution</h3>
<p class="mt-2 whitespace-pre-line">{{ $dispute->recommended_resolution ?? 'Pending peer review.' }}</p>
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Peer votes</h3>
<form method="POST" action="{{ route('dispute-votes.store', $dispute) }}" class="mt-4 space-y-4">
@csrf
<label class="block text-sm font-medium">Recommendation</label>
<select name="recommendation" class="rounded-md border-gray-300">
<option value="provider">Provider position</option>
<option value="buyer">Buyer position</option>
<option value="split">Split resolution</option>
<option value="insufficient_evidence">Insufficient evidence</option>
</select>
<x-field name="reason" label="Reason" textarea />
<x-primary-button>Save vote</x-primary-button>
</form>
@foreach ($dispute->votes as $vote)
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
<p class="font-semibold">{{ $vote->user->name }} | {{ str_replace('_', ' ', $vote->recommendation) }}</p>
<p class="whitespace-pre-line">{{ $vote->reason }}</p>
</div>
@endforeach
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Comments</h3>
<form method="POST" action="{{ route('comments.store') }}" class="mt-4 space-y-4">
@csrf
<input type="hidden" name="commentable_type" value="dispute">
<input type="hidden" name="commentable_id" value="{{ $dispute->id }}">
<x-field name="body" label="Comment" textarea />
<x-primary-button>Comment</x-primary-button>
</form>
@foreach ($dispute->comments as $comment)
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
<p class="font-semibold">{{ $comment->user->name }}</p>
<p class="whitespace-pre-line">{{ $comment->body }}</p>
</div>
@endforeach
</section>
</div>
</x-app-layout>
+19
View File
@@ -15,6 +15,25 @@
<p class="text-sm text-gray-500">{{ $post->user->name }} | {{ $post->created_at->diffForHumans() }}</p>
@if ($post->title)<h3 class="mt-2 font-semibold">{{ $post->title }}</h3>@endif
<p class="mt-3 whitespace-pre-line">{{ $post->body }}</p>
<x-attachments :attachments="$post->attachments" />
@auth
@if ($post->user_id === auth()->id())
<x-attachment-form type="social_post" :id="$post->id" kind="post" />
@endif
<form method="POST" action="{{ route('comments.store') }}" class="mt-4 space-y-3">
@csrf
<input type="hidden" name="commentable_type" value="social_post">
<input type="hidden" name="commentable_id" value="{{ $post->id }}">
<x-field name="body" label="Comment" textarea />
<x-primary-button>Comment</x-primary-button>
</form>
@endauth
@foreach ($post->comments as $comment)
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
<p class="font-semibold">{{ $comment->user->name }}</p>
<p class="whitespace-pre-line">{{ $comment->body }}</p>
</div>
@endforeach
</article>
@endforeach
{{ $posts->links() }}
+47 -1
View File
@@ -10,6 +10,12 @@
<div><dt class="text-sm text-gray-500">Deliverables</dt><dd class="whitespace-pre-line">{{ $job->deliverables }}</dd></div>
<div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $job->payment_terms }}</dd></div>
</dl>
<x-attachments :attachments="$job->attachments" />
@auth
@if (auth()->id() === $job->buyer_id)
<x-attachment-form type="job_post" :id="$job->id" kind="job" />
@endif
@endauth
</section>
@auth
@role('provider')
@@ -33,12 +39,34 @@
<p class="font-semibold">{{ $quote->provider->providerProfile?->business_name ?? $quote->provider->name }}</p>
<p class="text-sm text-gray-600">{{ $quote->status }} | {{ $quote->rate_summary }} | {{ $quote->requested_amount }}</p>
<p class="mt-2 whitespace-pre-line text-sm">{{ $quote->message }}</p>
@if ($quote->revisions->count())
<details class="mt-3 text-sm">
<summary class="cursor-pointer font-semibold">Revision history</summary>
@foreach ($quote->revisions as $revision)
<p class="mt-2 text-gray-600">{{ $revision->created_at->diffForHumans() }} | {{ $revision->action }} | {{ $revision->rate_summary }} | {{ $revision->requested_amount }}</p>
@endforeach
</details>
@endif
@auth
@if (auth()->id() === $quote->provider_id && in_array($quote->status, ['submitted', 'countered', 'revised'], true) && $job->status === 'open')
<form method="POST" action="{{ route('quotes.update', $quote) }}" class="mt-4 space-y-3 rounded bg-gray-50 p-3">
@csrf @method('PATCH')
<x-field name="requested_amount" label="Revised amount" type="number" :value="$quote->requested_amount" />
<x-field name="rate_summary" label="Rate summary" :value="$quote->rate_summary" />
<x-field name="message" label="Message" :value="$quote->message" textarea />
<x-field name="terms" label="Terms" :value="$quote->terms" textarea />
<x-primary-button>Revise quote</x-primary-button>
</form>
@endif
@if (auth()->id() === $job->buyer_id && $job->status === 'open')
<form method="POST" action="{{ route('quotes.accept', $quote) }}" class="mt-3">
<form method="POST" action="{{ route('quotes.accept', $quote) }}" class="mt-3 inline-block">
@csrf
<x-primary-button>Accept quote</x-primary-button>
</form>
<form method="POST" action="{{ route('quotes.decline', $quote) }}" class="mt-3 inline-block">
@csrf
<x-secondary-button>Decline</x-secondary-button>
</form>
@endif
@endauth
</div>
@@ -49,5 +77,23 @@
@if ($job->workOrder)
<a class="rounded bg-gray-900 px-4 py-2 text-white" href="{{ route('work-orders.show', $job->workOrder) }}">View work order</a>
@endif
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Discussion</h3>
@auth
<form method="POST" action="{{ route('comments.store') }}" class="mt-4 space-y-3">
@csrf
<input type="hidden" name="commentable_type" value="job_post">
<input type="hidden" name="commentable_id" value="{{ $job->id }}">
<x-field name="body" label="Comment" textarea />
<x-primary-button>Comment</x-primary-button>
</form>
@endauth
@foreach ($job->comments as $comment)
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
<p class="font-semibold">{{ $comment->user->name }}</p>
<p class="whitespace-pre-line">{{ $comment->body }}</p>
</div>
@endforeach
</section>
</div>
</x-app-layout>
@@ -30,6 +30,11 @@
<x-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
{{ __('Work Orders') }}
</x-nav-link>
@role('admin')
<x-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')">
{{ __('Admin') }}
</x-nav-link>
@endrole
</div>
</div>
@@ -117,6 +122,13 @@
<x-responsive-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
{{ __('Work Orders') }}
</x-responsive-nav-link>
@auth
@role('admin')
<x-responsive-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')">
{{ __('Admin') }}
</x-responsive-nav-link>
@endrole
@endauth
</div>
<!-- Responsive Settings Options -->
+5
View File
@@ -20,6 +20,11 @@
<x-primary-button>Save provider profile</x-primary-button>
</form>
@if ($profile)
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Profile photos and files</h3>
<x-attachments :attachments="$profile->attachments" />
<x-attachment-form type="provider_profile" :id="$profile->id" kind="profile" />
</section>
<form method="POST" action="{{ route('provider-imports.store') }}" class="space-y-4 rounded border bg-white p-6">
@csrf
<h3 class="font-semibold">External profile snapshot</h3>
+1
View File
@@ -12,6 +12,7 @@
<div><dt class="text-sm text-gray-500">Rate card</dt><dd class="whitespace-pre-line">{{ $profile->rate_card }}</dd></div>
<div><dt class="text-sm text-gray-500">Travel policy</dt><dd class="whitespace-pre-line">{{ $profile->travel_policy }}</dd></div>
</dl>
<x-attachments :attachments="$profile->attachments" />
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">External profile history</h3>
+23 -1
View File
@@ -6,11 +6,13 @@
<p class="mt-4 whitespace-pre-line">{{ $workOrder->agreed_terms }}</p>
<h3 class="mt-6 font-semibold">Deliverables</h3>
<p class="mt-2 whitespace-pre-line">{{ $workOrder->deliverables_checklist }}</p>
<x-attachments :attachments="$workOrder->attachments" />
<x-attachment-form type="work_order" :id="$workOrder->id" kind="work_order" />
<form method="POST" action="{{ route('work-orders.transition', $workOrder) }}" class="mt-6 space-y-4">
@csrf @method('PATCH')
<label class="block text-sm font-medium">Update status</label>
<select name="status" class="rounded-md border-gray-300">
@foreach (\App\Models\WorkOrder::STATUSES as $status)
@foreach (\App\Models\WorkOrder::ALLOWED_TRANSITIONS[$workOrder->status] ?? [] as $status)
<option value="{{ $status }}" @selected($workOrder->status === $status)>{{ str_replace('_', ' ', $status) }}</option>
@endforeach
</select>
@@ -18,17 +20,37 @@
<x-primary-button>Save status</x-primary-button>
</form>
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Messages</h3>
<form method="POST" action="{{ route('work-order-messages.store', $workOrder) }}" class="mt-4 space-y-4">
@csrf
<x-field name="body" label="Message" textarea />
<x-primary-button>Send message</x-primary-button>
</form>
@foreach ($workOrder->messages as $message)
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
<p class="font-semibold">{{ $message->user->name }} | {{ $message->created_at->diffForHumans() }}</p>
<p class="whitespace-pre-line">{{ $message->body }}</p>
</div>
@endforeach
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Reviews</h3>
<form method="POST" action="{{ route('reviews.store', $workOrder) }}" class="mt-4 space-y-4">
@csrf
<x-field name="rating" label="Rating 1-5" type="number" />
<x-field name="communication_rating" label="Communication 1-5" type="number" />
<x-field name="scope_accuracy_rating" label="Scope accuracy 1-5" type="number" />
<x-field name="payment_reliability_rating" label="Payment reliability 1-5" type="number" />
<x-field name="workmanship_rating" label="Workmanship 1-5" type="number" />
<x-field name="timeliness_rating" label="Timeliness 1-5" type="number" />
<x-field name="body" label="Review" textarea />
<x-primary-button>Save review</x-primary-button>
</form>
@foreach ($workOrder->reviews as $review)
<div class="mt-4 rounded border p-4 text-sm">
<p class="font-semibold">{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5</p>
<p class="mt-1 text-gray-600">Communication {{ $review->communication_rating ?? 'n/a' }} | Scope {{ $review->scope_accuracy_rating ?? 'n/a' }} | Payment {{ $review->payment_reliability_rating ?? 'n/a' }} | Workmanship {{ $review->workmanship_rating ?? 'n/a' }} | Timeliness {{ $review->timeliness_rating ?? 'n/a' }}</p>
<p class="mt-2 whitespace-pre-line">{{ $review->body }}</p>
</div>
@endforeach
+14 -2
View File
@@ -1,5 +1,6 @@
<?php
use App\Models\Dispute;
use App\Models\JobPost;
use App\Models\ProviderProfile;
use App\Models\WorkOrder;
@@ -16,11 +17,12 @@ Route::get('/jobs', function () {
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', function (Request $request) {
return $request->user()->load('roles', 'providerProfile', 'buyerProfile');
return $request->user()->load('roles', 'providerProfile', 'buyerProfile')
->setAttribute('unread_notifications_count', $request->user()->unreadNotifications()->count());
});
Route::get('/work-orders', function (Request $request) {
return WorkOrder::with('jobPost:id,title,location', 'buyer:id,name', 'provider:id,name')
return WorkOrder::with('jobPost:id,title,location', 'buyer:id,name', 'provider:id,name', 'messages.user:id,name', 'reviews', 'disputes')
->where(fn ($query) => $query
->where('buyer_id', $request->user()->id)
->orWhere('provider_id', $request->user()->id)
@@ -28,4 +30,14 @@ Route::middleware('auth:sanctum')->group(function () {
->latest()
->paginate(20);
});
Route::get('/disputes', function (Request $request) {
return Dispute::with('workOrder.jobPost:id,title', 'openedBy:id,name', 'votes.user:id,name')
->whereHas('workOrder', fn ($query) => $query
->where('buyer_id', $request->user()->id)
->orWhere('provider_id', $request->user()->id)
)
->latest()
->paginate(20);
});
});
+13
View File
@@ -1,14 +1,19 @@
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\AttachmentController;
use App\Http\Controllers\BuyerProfileController;
use App\Http\Controllers\CommentController;
use App\Http\Controllers\DisputeController;
use App\Http\Controllers\DisputeVoteController;
use App\Http\Controllers\ExternalProfileImportController;
use App\Http\Controllers\JobPostController;
use App\Http\Controllers\ProviderProfileController;
use App\Http\Controllers\QuoteController;
use App\Http\Controllers\ReviewController;
use App\Http\Controllers\SocialPostController;
use App\Http\Controllers\WorkOrderMessageController;
use App\Http\Controllers\WorkOrderController;
use Illuminate\Support\Facades\Route;
@@ -33,18 +38,26 @@ Route::middleware('auth')->group(function () {
Route::put('/buyer-profile', [BuyerProfileController::class, 'update'])->name('buyers.update');
Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store');
Route::post('/comments', [CommentController::class, 'store'])->name('comments.store');
Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store');
Route::get('/jobs/create', [JobPostController::class, 'create'])->name('jobs.create');
Route::post('/jobs', [JobPostController::class, 'store'])->name('jobs.store');
Route::post('/jobs/{job}/quotes', [QuoteController::class, 'store'])->name('quotes.store');
Route::patch('/quotes/{quote}', [QuoteController::class, 'update'])->name('quotes.update');
Route::post('/quotes/{quote}/accept', [QuoteController::class, 'accept'])->name('quotes.accept');
Route::post('/quotes/{quote}/decline', [QuoteController::class, 'decline'])->name('quotes.decline');
Route::get('/work-orders', [WorkOrderController::class, 'index'])->name('work-orders.index');
Route::get('/work-orders/{workOrder}', [WorkOrderController::class, 'show'])->name('work-orders.show');
Route::patch('/work-orders/{workOrder}/transition', [WorkOrderController::class, 'transition'])->name('work-orders.transition');
Route::post('/work-orders/{workOrder}/messages', [WorkOrderMessageController::class, 'store'])->name('work-order-messages.store');
Route::post('/work-orders/{workOrder}/reviews', [ReviewController::class, 'store'])->name('reviews.store');
Route::post('/work-orders/{workOrder}/disputes', [DisputeController::class, 'store'])->name('disputes.store');
Route::get('/disputes/{dispute}', [DisputeController::class, 'show'])->name('disputes.show');
Route::post('/disputes/{dispute}/votes', [DisputeVoteController::class, 'store'])->name('dispute-votes.store');
Route::get('/admin', [AdminController::class, 'index'])->name('admin.index');
});
Route::get('/providers', [ProviderProfileController::class, 'index'])->name('providers.index');
+66 -4
View File
@@ -3,9 +3,12 @@
namespace Tests\Feature;
use App\Models\JobPost;
use App\Models\SocialPost;
use App\Models\User;
use App\Models\WorkOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
@@ -67,10 +70,14 @@ class ExchangeWorkflowTest extends TestCase
{
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
$this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [
'status' => 'completed',
'completion_notes' => 'Completed with photos.',
])->assertRedirect("/work-orders/{$workOrder->id}");
foreach (['en_route', 'on_site', 'in_progress', 'completed'] as $status) {
$this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [
'status' => $status,
'completion_notes' => $status === 'completed' ? 'Completed with photos.' : null,
])->assertRedirect("/work-orders/{$workOrder->id}");
$workOrder->refresh();
}
$this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/reviews", [
'rating' => 5,
@@ -106,6 +113,61 @@ class ExchangeWorkflowTest extends TestCase
]);
}
public function test_invalid_work_order_transition_is_rejected(): void
{
[, $provider, $workOrder] = $this->workOrderFixture();
$this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [
'status' => 'completed',
])->assertStatus(422);
}
public function test_comments_messages_dispute_votes_and_uploads_work(): void
{
Storage::fake('public');
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
$post = SocialPost::create([
'user_id' => $provider->id,
'body' => 'Available for work.',
'visibility' => 'public',
]);
$this->actingAs($buyer)->post('/comments', [
'commentable_type' => 'social_post',
'commentable_id' => $post->id,
'body' => 'Good to know.',
])->assertRedirect();
$this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/messages", [
'body' => 'Please upload completion photos.',
])->assertRedirect("/work-orders/{$workOrder->id}");
$this->actingAs($provider)->post('/attachments', [
'attachable_type' => 'work_order',
'attachable_id' => $workOrder->id,
'caption' => 'Completion photo',
'file' => UploadedFile::fake()->image('complete.jpg'),
])->assertRedirect();
$response = $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/disputes", [
'summary' => 'Scope changed',
'claim' => 'Additional scope was requested.',
])->assertRedirect();
$dispute = $workOrder->disputes()->firstOrFail();
$this->actingAs($buyer)->post("/disputes/{$dispute->id}/votes", [
'recommendation' => 'split',
'reason' => 'Both sides have partial documentation.',
])->assertRedirect("/disputes/{$dispute->id}");
$this->assertDatabaseHas('comments', ['body' => 'Good to know.']);
$this->assertDatabaseHas('work_order_messages', ['body' => 'Please upload completion photos.']);
$this->assertDatabaseHas('attachments', ['caption' => 'Completion photo']);
$this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']);
}
private function userWithRole(string $role): User
{
Role::findOrCreate($role);