This commit is contained in:
Codex
2026-07-10 11:18:07 -05:00
parent 42db078e47
commit e5a95a0687
32 changed files with 471 additions and 11 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Provider Exchange contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -2,6 +2,8 @@
Provider Exchange is an early Laravel prototype for a provider-centered field service network and work-order exchange. Provider Exchange is an early Laravel prototype for a provider-centered field service network and work-order exchange.
The code is intended to be open source under the MIT License.
The product goal is not to clone Field Nation. The first version focuses on: The product goal is not to clone Field Nation. The first version focuses on:
- provider and buyer profiles; - provider and buyer profiles;
@@ -9,10 +9,21 @@ use Illuminate\View\View;
class BuyerProfileController extends Controller class BuyerProfileController extends Controller
{ {
public function index(): View public function index(Request $request): View
{ {
$profiles = BuyerProfile::with('user', 'attachments', 'ratings.user');
if ($search = $request->string('q')->toString()) {
$profiles->where(fn ($query) => $query
->where('company_name', 'like', "%{$search}%")
->orWhere('headline', 'like', "%{$search}%")
->orWhere('service_categories', 'like', "%{$search}%")
->orWhere('hiring_regions', 'like', "%{$search}%")
);
}
return view('buyers.index', [ return view('buyers.index', [
'profiles' => BuyerProfile::with('user', 'attachments')->latest()->paginate(20), 'profiles' => $profiles->latest()->paginate(20)->withQueryString(),
]); ]);
} }
@@ -55,7 +66,7 @@ class BuyerProfileController extends Controller
public function show(BuyerProfile $buyer): View public function show(BuyerProfile $buyer): View
{ {
return view('buyers.show', [ return view('buyers.show', [
'profile' => $buyer->load('user', 'attachments'), 'profile' => $buyer->load('user', 'attachments', 'ratings.user'),
]); ]);
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@ class DisputeController extends Controller
{ {
public function show(Request $request, Dispute $dispute): View public function show(Request $request, Dispute $dispute): View
{ {
$dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy', 'votes.user', 'comments.user', 'attachments'); $dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy', 'votes.user', 'comments.user', 'attachments', 'ratings.user');
abort_unless( abort_unless(
in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true) in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true)
+19 -3
View File
@@ -9,10 +9,25 @@ use Illuminate\View\View;
class JobPostController extends Controller class JobPostController extends Controller
{ {
public function index(): View public function index(Request $request): View
{ {
$jobs = JobPost::with('buyer.buyerProfile', 'attachments', 'ratings.user');
if ($search = $request->string('q')->toString()) {
$jobs->where(fn ($query) => $query
->where('title', 'like', "%{$search}%")
->orWhere('service_category', 'like', "%{$search}%")
->orWhere('location', 'like', "%{$search}%")
->orWhere('scope', 'like', "%{$search}%")
);
}
if ($status = $request->string('status')->toString()) {
$jobs->where('status', $status);
}
return view('jobs.index', [ return view('jobs.index', [
'jobs' => JobPost::with('buyer.buyerProfile', 'attachments')->latest()->paginate(20), 'jobs' => $jobs->latest()->paginate(20)->withQueryString(),
]); ]);
} }
@@ -56,7 +71,8 @@ class JobPostController extends Controller
'quotes.revisions.user', 'quotes.revisions.user',
'workOrder', 'workOrder',
'attachments', 'attachments',
'comments.user' 'comments.user',
'ratings.user'
), ),
]); ]);
} }
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
return view('notifications.index', [
'notifications' => $request->user()->notifications()->latest()->paginate(30),
]);
}
public function markRead(Request $request, string $id): RedirectResponse
{
$notification = $request->user()->notifications()->whereKey($id)->firstOrFail();
$notification->markAsRead();
return back()->with('status', 'Notification marked read.');
}
public function markAllRead(Request $request): RedirectResponse
{
$request->user()->unreadNotifications->markAsRead();
return back()->with('status', 'Notifications marked read.');
}
}
@@ -9,10 +9,21 @@ use Illuminate\View\View;
class ProviderProfileController extends Controller class ProviderProfileController extends Controller
{ {
public function index(): View public function index(Request $request): View
{ {
$profiles = ProviderProfile::with('user', 'externalImports', 'attachments', 'ratings.user');
if ($search = $request->string('q')->toString()) {
$profiles->where(fn ($query) => $query
->where('business_name', 'like', "%{$search}%")
->orWhere('headline', 'like', "%{$search}%")
->orWhere('service_area', 'like', "%{$search}%")
->orWhere('skills', 'like', "%{$search}%")
);
}
return view('providers.index', [ return view('providers.index', [
'profiles' => ProviderProfile::with('user', 'externalImports', 'attachments')->latest()->paginate(20), 'profiles' => $profiles->latest()->paginate(20)->withQueryString(),
]); ]);
} }
@@ -59,7 +70,7 @@ class ProviderProfileController extends Controller
public function show(ProviderProfile $provider): View public function show(ProviderProfile $provider): View
{ {
return view('providers.show', [ return view('providers.show', [
'profile' => $provider->load('user', 'externalImports.attachments', 'attachments'), 'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'),
]); ]);
} }
} }
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\BuyerProfile;
use App\Models\Dispute;
use App\Models\JobPost;
use App\Models\ProviderProfile;
use App\Models\WorkOrder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class RatingController extends Controller
{
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'rateable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,job_post,work_order,dispute'],
'rateable_id' => ['required', 'integer'],
'category' => ['required', 'string', 'max:255'],
'stars' => ['nullable', 'integer', 'min:1', 'max:5'],
'thumbs_up' => ['nullable', 'boolean'],
'body' => ['nullable', 'string', 'max:2000'],
]);
abort_if(($data['stars'] ?? null) === null && ! $request->has('thumbs_up'), 422, 'A star rating or thumbs vote is required.');
$rateable = match ($data['rateable_type']) {
'provider_profile' => ProviderProfile::findOrFail($data['rateable_id']),
'buyer_profile' => BuyerProfile::findOrFail($data['rateable_id']),
'job_post' => JobPost::findOrFail($data['rateable_id']),
'work_order' => WorkOrder::findOrFail($data['rateable_id']),
'dispute' => Dispute::findOrFail($data['rateable_id']),
};
$this->authorizeRating($request, $rateable);
$rateable->ratings()->updateOrCreate(
[
'user_id' => $request->user()->id,
'category' => $data['category'],
],
[
'stars' => $data['stars'] ?? null,
'thumbs_up' => $request->has('thumbs_up') ? $request->boolean('thumbs_up') : null,
'body' => $data['body'] ?? null,
]
);
return back()->with('status', 'Rating saved.');
}
private function authorizeRating(Request $request, mixed $rateable): void
{
$user = $request->user();
if ($rateable instanceof ProviderProfile) {
abort_if($rateable->user_id === $user->id, 403);
abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403);
} elseif ($rateable instanceof BuyerProfile) {
abort_if($rateable->user_id === $user->id, 403);
abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403);
} elseif ($rateable instanceof JobPost) {
abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403);
} elseif ($rateable instanceof WorkOrder) {
abort_unless(in_array($user->id, [$rateable->buyer_id, $rateable->provider_id], true) || $user->hasRole('admin'), 403);
} elseif ($rateable instanceof Dispute) {
abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403);
}
}
}
+2 -1
View File
@@ -12,7 +12,7 @@ class WorkOrderController extends Controller
{ {
public function index(Request $request): View public function index(Request $request): View
{ {
$orders = WorkOrder::with('jobPost', 'buyer.buyerProfile', 'provider.providerProfile') $orders = WorkOrder::with('jobPost', 'buyer.buyerProfile', 'provider.providerProfile', 'ratings.user')
->where(fn ($query) => $query ->where(fn ($query) => $query
->where('buyer_id', $request->user()->id) ->where('buyer_id', $request->user()->id)
->orWhere('provider_id', $request->user()->id) ->orWhere('provider_id', $request->user()->id)
@@ -38,6 +38,7 @@ class WorkOrderController extends Controller
'disputes.openedBy', 'disputes.openedBy',
'messages.user', 'messages.user',
'attachments', 'attachments',
'ratings.user',
), ),
]); ]);
} }
+5
View File
@@ -23,4 +23,9 @@ class BuyerProfile extends Model
{ {
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function ratings(): MorphMany
{
return $this->morphMany(Rating::class, 'rateable');
}
} }
+5
View File
@@ -46,4 +46,9 @@ class Dispute extends Model
{ {
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function ratings(): MorphMany
{
return $this->morphMany(Rating::class, 'rateable');
}
} }
+5
View File
@@ -47,4 +47,9 @@ class JobPost extends Model
{ {
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function ratings(): MorphMany
{
return $this->morphMany(Rating::class, 'rateable');
}
} }
+5
View File
@@ -29,4 +29,9 @@ class ProviderProfile extends Model
{ {
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function ratings(): MorphMany
{
return $this->morphMany(Rating::class, 'rateable');
}
} }
+30
View File
@@ -0,0 +1,30 @@
<?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', 'category', 'stars', 'thumbs_up', 'body'])]
class Rating extends Model
{
protected function casts(): array
{
return [
'stars' => 'integer',
'thumbs_up' => 'boolean',
];
}
public function rateable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+5
View File
@@ -85,6 +85,11 @@ class WorkOrder extends Model
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function ratings(): MorphMany
{
return $this->morphMany(Rating::class, 'rateable');
}
public function canTransitionTo(string $status): bool public function canTransitionTo(string $status): bool
{ {
return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
@@ -0,0 +1,35 @@
<?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('ratings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->morphs('rateable');
$table->string('category')->default('overall');
$table->unsignedTinyInteger('stars')->nullable();
$table->boolean('thumbs_up')->nullable();
$table->text('body')->nullable();
$table->timestamps();
$table->unique(['user_id', 'rateable_type', 'rateable_id', 'category']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ratings');
}
};
+5
View File
@@ -1,11 +1,16 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Buyers</h2></x-slot> <x-slot name="header"><h2 class="text-xl font-semibold">Buyers</h2></x-slot>
<div class="mx-auto max-w-7xl space-y-4 p-6"> <div class="mx-auto max-w-7xl space-y-4 p-6">
<form method="GET" action="{{ route('buyers.index') }}" class="flex gap-3 rounded border bg-white p-4">
<input name="q" value="{{ request('q') }}" placeholder="Search buyers..." class="w-full rounded-md border-gray-300">
<x-primary-button>Search</x-primary-button>
</form>
@foreach ($profiles as $profile) @foreach ($profiles as $profile)
<a href="{{ route('buyers.show', $profile) }}" class="block rounded border bg-white p-4 shadow-sm"> <a href="{{ route('buyers.show', $profile) }}" class="block rounded border bg-white p-4 shadow-sm">
<h3 class="font-semibold">{{ $profile->company_name }}</h3> <h3 class="font-semibold">{{ $profile->company_name }}</h3>
<p class="text-sm text-gray-600">{{ $profile->headline }}</p> <p class="text-sm text-gray-600">{{ $profile->headline }}</p>
<p class="mt-2 text-sm">{{ $profile->hiring_regions }}</p> <p class="mt-2 text-sm">{{ $profile->hiring_regions }}</p>
<x-rating-summary :ratings="$profile->ratings" />
</a> </a>
@endforeach @endforeach
{{ $profiles->links() }} {{ $profiles->links() }}
+2
View File
@@ -10,5 +10,7 @@
<div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $profile->payment_terms }}</dd></div> <div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $profile->payment_terms }}</dd></div>
</dl> </dl>
<x-attachments :attachments="$profile->attachments" /> <x-attachments :attachments="$profile->attachments" />
<x-rating-summary :ratings="$profile->ratings" />
<x-rating-form type="buyer_profile" :id="$profile->id" category="buyer_overall" />
</div> </div>
</x-app-layout> </x-app-layout>
@@ -0,0 +1,21 @@
@props(['type', 'id', 'category' => 'overall', 'mode' => 'stars'])
@auth
<form method="POST" action="{{ route('ratings.store') }}" class="mt-4 space-y-3 rounded border p-4">
@csrf
<input type="hidden" name="rateable_type" value="{{ $type }}">
<input type="hidden" name="rateable_id" value="{{ $id }}">
<input type="hidden" name="category" value="{{ $category }}">
@if ($mode === 'thumbs')
<label class="block text-sm font-medium">Vote</label>
<select name="thumbs_up" class="rounded-md border-gray-300">
<option value="1">Thumbs up</option>
<option value="0">Thumbs down</option>
</select>
@else
<x-field name="stars" label="Stars 1-5" type="number" />
@endif
<x-field name="body" label="Reason" textarea />
<x-primary-button>Save rating</x-primary-button>
</form>
@endauth
@@ -0,0 +1,25 @@
@props(['ratings'])
@php
$starRatings = $ratings->whereNotNull('stars');
$thumbRatings = $ratings->whereNotNull('thumbs_up');
$average = $starRatings->count() ? round($starRatings->avg('stars'), 1) : null;
$up = $thumbRatings->where('thumbs_up', true)->count();
$down = $thumbRatings->where('thumbs_up', false)->count();
@endphp
<div class="mt-4 rounded border bg-gray-50 p-4 text-sm">
<p class="font-semibold">Community rating</p>
<p class="mt-1 text-gray-700">
Stars: {{ $average ? $average.'/5' : 'n/a' }} from {{ $starRatings->count() }} vote{{ $starRatings->count() === 1 ? '' : 's' }}
| Thumbs: {{ $up }} up / {{ $down }} down
</p>
@foreach ($ratings->take(5) as $rating)
@php
$value = $rating->stars
? $rating->stars.'/5'
: ($rating->thumbs_up === null ? 'no vote' : ($rating->thumbs_up ? 'thumbs up' : 'thumbs down'));
@endphp
<p class="mt-2 text-gray-600">{{ $rating->user->name ?? 'User' }} | {{ $rating->category }} | {{ $value }} {{ $rating->body ? '- '.$rating->body : '' }}</p>
@endforeach
</div>
+15
View File
@@ -11,9 +11,24 @@
<x-attachment-form type="dispute" :id="$dispute->id" kind="evidence" /> <x-attachment-form type="dispute" :id="$dispute->id" kind="evidence" />
<h3 class="mt-4 font-semibold">Recommended resolution</h3> <h3 class="mt-4 font-semibold">Recommended resolution</h3>
<p class="mt-2 whitespace-pre-line">{{ $dispute->recommended_resolution ?? 'Pending peer review.' }}</p> <p class="mt-2 whitespace-pre-line">{{ $dispute->recommended_resolution ?? 'Pending peer review.' }}</p>
<x-rating-summary :ratings="$dispute->ratings" />
<x-rating-form type="dispute" :id="$dispute->id" category="resolution_fairness" mode="thumbs" />
</section> </section>
<section class="rounded border bg-white p-6"> <section class="rounded border bg-white p-6">
<h3 class="font-semibold">Peer votes</h3> <h3 class="font-semibold">Peer votes</h3>
@php
$voteGroups = $dispute->votes->groupBy('recommendation')->map->count();
$voteTotal = max($dispute->votes->count(), 1);
@endphp
<div class="mt-3 grid gap-3 md:grid-cols-4">
@foreach (['provider' => 'Provider', 'buyer' => 'Buyer', 'split' => 'Split', 'insufficient_evidence' => 'Insufficient evidence'] as $key => $label)
@php $count = $voteGroups[$key] ?? 0; @endphp
<div class="rounded border bg-gray-50 p-3 text-sm">
<p class="font-semibold">{{ $label }}</p>
<p>{{ $count }} vote{{ $count === 1 ? '' : 's' }} | {{ round(($count / $voteTotal) * 100) }}%</p>
</div>
@endforeach
</div>
<form method="POST" action="{{ route('dispute-votes.store', $dispute) }}" class="mt-4 space-y-4"> <form method="POST" action="{{ route('dispute-votes.store', $dispute) }}" class="mt-4 space-y-4">
@csrf @csrf
<label class="block text-sm font-medium">Recommendation</label> <label class="block text-sm font-medium">Recommendation</label>
+11
View File
@@ -1,12 +1,23 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Jobs</h2></x-slot> <x-slot name="header"><h2 class="text-xl font-semibold">Jobs</h2></x-slot>
<div class="mx-auto max-w-7xl space-y-4 p-6"> <div class="mx-auto max-w-7xl space-y-4 p-6">
<form method="GET" action="{{ route('jobs.index') }}" class="grid gap-3 rounded border bg-white p-4 md:grid-cols-3">
<input name="q" value="{{ request('q') }}" placeholder="Search jobs..." class="rounded-md border-gray-300">
<select name="status" class="rounded-md border-gray-300">
<option value="">Any status</option>
@foreach (['open', 'assigned', 'closed', 'cancelled'] as $status)
<option value="{{ $status }}" @selected(request('status') === $status)>{{ $status }}</option>
@endforeach
</select>
<x-primary-button>Search</x-primary-button>
</form>
@auth @role('buyer')<a class="rounded bg-indigo-600 px-4 py-2 text-white" href="{{ route('jobs.create') }}">Post job</a>@endrole @endauth @auth @role('buyer')<a class="rounded bg-indigo-600 px-4 py-2 text-white" href="{{ route('jobs.create') }}">Post job</a>@endrole @endauth
@foreach ($jobs as $job) @foreach ($jobs as $job)
<a href="{{ route('jobs.show', $job) }}" class="block rounded border bg-white p-4 shadow-sm"> <a href="{{ route('jobs.show', $job) }}" class="block rounded border bg-white p-4 shadow-sm">
<h3 class="font-semibold">{{ $job->title }}</h3> <h3 class="font-semibold">{{ $job->title }}</h3>
<p class="text-sm text-gray-600">{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}</p> <p class="text-sm text-gray-600">{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}</p>
<p class="mt-2 line-clamp-2 text-sm">{{ $job->scope }}</p> <p class="mt-2 line-clamp-2 text-sm">{{ $job->scope }}</p>
<x-rating-summary :ratings="$job->ratings" />
</a> </a>
@endforeach @endforeach
{{ $jobs->links() }} {{ $jobs->links() }}
+2
View File
@@ -11,6 +11,8 @@
<div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $job->payment_terms }}</dd></div> <div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $job->payment_terms }}</dd></div>
</dl> </dl>
<x-attachments :attachments="$job->attachments" /> <x-attachments :attachments="$job->attachments" />
<x-rating-summary :ratings="$job->ratings" />
<x-rating-form type="job_post" :id="$job->id" category="job_quality" />
@auth @auth
@if (auth()->id() === $job->buyer_id) @if (auth()->id() === $job->buyer_id)
<x-attachment-form type="job_post" :id="$job->id" kind="job" /> <x-attachment-form type="job_post" :id="$job->id" kind="job" />
@@ -30,6 +30,14 @@
<x-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')"> <x-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
{{ __('Work Orders') }} {{ __('Work Orders') }}
</x-nav-link> </x-nav-link>
@auth
<x-nav-link :href="route('notifications.index')" :active="request()->routeIs('notifications.*')">
{{ __('Notifications') }}
@if (auth()->user()->unreadNotifications()->count())
({{ auth()->user()->unreadNotifications()->count() }})
@endif
</x-nav-link>
@endauth
@role('admin') @role('admin')
<x-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')"> <x-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')">
{{ __('Admin') }} {{ __('Admin') }}
@@ -122,6 +130,11 @@
<x-responsive-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')"> <x-responsive-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
{{ __('Work Orders') }} {{ __('Work Orders') }}
</x-responsive-nav-link> </x-responsive-nav-link>
@auth
<x-responsive-nav-link :href="route('notifications.index')" :active="request()->routeIs('notifications.*')">
{{ __('Notifications') }}
</x-responsive-nav-link>
@endauth
@auth @auth
@role('admin') @role('admin')
<x-responsive-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')"> <x-responsive-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')">
@@ -0,0 +1,27 @@
<x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Notifications</h2></x-slot>
<div class="mx-auto max-w-5xl space-y-4 p-6">
<form method="POST" action="{{ route('notifications.read-all') }}">
@csrf
<x-secondary-button>Mark all read</x-secondary-button>
</form>
@foreach ($notifications as $notification)
<div class="rounded border bg-white p-4 {{ $notification->read_at ? '' : 'border-indigo-400' }}">
<p class="font-semibold">{{ $notification->data['title'] ?? 'Notification' }}</p>
<p class="mt-1 text-sm text-gray-600">{{ $notification->data['body'] ?? '' }}</p>
<div class="mt-3 flex gap-3 text-sm">
@if (! empty($notification->data['url']))
<a class="text-indigo-600" href="{{ $notification->data['url'] }}">Open</a>
@endif
@unless ($notification->read_at)
<form method="POST" action="{{ route('notifications.read', $notification->id) }}">
@csrf
<button class="text-gray-600" type="submit">Mark read</button>
</form>
@endunless
</div>
</div>
@endforeach
{{ $notifications->links() }}
</div>
</x-app-layout>
@@ -1,11 +1,16 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Providers</h2></x-slot> <x-slot name="header"><h2 class="text-xl font-semibold">Providers</h2></x-slot>
<div class="mx-auto max-w-7xl space-y-4 p-6"> <div class="mx-auto max-w-7xl space-y-4 p-6">
<form method="GET" action="{{ route('providers.index') }}" class="flex gap-3 rounded border bg-white p-4">
<input name="q" value="{{ request('q') }}" placeholder="Search providers..." class="w-full rounded-md border-gray-300">
<x-primary-button>Search</x-primary-button>
</form>
@foreach ($profiles as $profile) @foreach ($profiles as $profile)
<a href="{{ route('providers.show', $profile) }}" class="block rounded border bg-white p-4 shadow-sm"> <a href="{{ route('providers.show', $profile) }}" class="block rounded border bg-white p-4 shadow-sm">
<h3 class="font-semibold">{{ $profile->business_name }}</h3> <h3 class="font-semibold">{{ $profile->business_name }}</h3>
<p class="text-sm text-gray-600">{{ $profile->headline }}</p> <p class="text-sm text-gray-600">{{ $profile->headline }}</p>
<p class="mt-2 text-sm">{{ $profile->service_area }}</p> <p class="mt-2 text-sm">{{ $profile->service_area }}</p>
<x-rating-summary :ratings="$profile->ratings" />
@foreach ($profile->externalImports as $import) @foreach ($profile->externalImports as $import)
<span class="mt-2 inline-block rounded bg-gray-100 px-2 py-1 text-xs">{{ $import->platform }} {{ $import->rating ? $import->rating.'/5' : '' }}</span> <span class="mt-2 inline-block rounded bg-gray-100 px-2 py-1 text-xs">{{ $import->platform }} {{ $import->rating ? $import->rating.'/5' : '' }}</span>
@endforeach @endforeach
+2
View File
@@ -13,6 +13,8 @@
<div><dt class="text-sm text-gray-500">Travel policy</dt><dd class="whitespace-pre-line">{{ $profile->travel_policy }}</dd></div> <div><dt class="text-sm text-gray-500">Travel policy</dt><dd class="whitespace-pre-line">{{ $profile->travel_policy }}</dd></div>
</dl> </dl>
<x-attachments :attachments="$profile->attachments" /> <x-attachments :attachments="$profile->attachments" />
<x-rating-summary :ratings="$profile->ratings" />
<x-rating-form type="provider_profile" :id="$profile->id" category="provider_overall" />
</section> </section>
<section class="rounded border bg-white p-6"> <section class="rounded border bg-white p-6">
<h3 class="font-semibold">External profile history</h3> <h3 class="font-semibold">External profile history</h3>
+15
View File
@@ -0,0 +1,15 @@
<x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Platform Terms</h2></x-slot>
<div class="mx-auto max-w-4xl space-y-6 p-6">
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Core guardrails</h3>
<ul class="mt-4 list-disc space-y-2 pl-6 text-sm text-gray-700">
<li>The platform does not hold money or provide escrow in this version.</li>
<li>The platform does not set, recommend, or enforce market rates.</li>
<li>Providers and buyers negotiate directly and remain independent businesses.</li>
<li>Ratings, reviews, votes, and dispute records are community reputation signals, not legal judgments.</li>
<li>Dispute voting is intended to be transparent, democratic, and reviewable by the community.</li>
</ul>
</section>
</div>
</x-app-layout>
@@ -5,6 +5,7 @@
<a href="{{ route('work-orders.show', $order) }}" class="block rounded border bg-white p-4 shadow-sm"> <a href="{{ route('work-orders.show', $order) }}" class="block rounded border bg-white p-4 shadow-sm">
<h3 class="font-semibold">{{ $order->jobPost->title }}</h3> <h3 class="font-semibold">{{ $order->jobPost->title }}</h3>
<p class="text-sm text-gray-600">{{ $order->status }} | Buyer: {{ $order->buyer->name }} | Provider: {{ $order->provider->name }}</p> <p class="text-sm text-gray-600">{{ $order->status }} | Buyer: {{ $order->buyer->name }} | Provider: {{ $order->provider->name }}</p>
<x-rating-summary :ratings="$order->ratings" />
</a> </a>
@endforeach @endforeach
{{ $orders->links() }} {{ $orders->links() }}
@@ -7,6 +7,8 @@
<h3 class="mt-6 font-semibold">Deliverables</h3> <h3 class="mt-6 font-semibold">Deliverables</h3>
<p class="mt-2 whitespace-pre-line">{{ $workOrder->deliverables_checklist }}</p> <p class="mt-2 whitespace-pre-line">{{ $workOrder->deliverables_checklist }}</p>
<x-attachments :attachments="$workOrder->attachments" /> <x-attachments :attachments="$workOrder->attachments" />
<x-rating-summary :ratings="$workOrder->ratings" />
<x-rating-form type="work_order" :id="$workOrder->id" category="work_order_outcome" mode="thumbs" />
<x-attachment-form type="work_order" :id="$workOrder->id" kind="work_order" /> <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"> <form method="POST" action="{{ route('work-orders.transition', $workOrder) }}" class="mt-6 space-y-4">
@csrf @method('PATCH') @csrf @method('PATCH')
+11
View File
@@ -9,8 +9,10 @@ use App\Http\Controllers\DisputeController;
use App\Http\Controllers\DisputeVoteController; use App\Http\Controllers\DisputeVoteController;
use App\Http\Controllers\ExternalProfileImportController; use App\Http\Controllers\ExternalProfileImportController;
use App\Http\Controllers\JobPostController; use App\Http\Controllers\JobPostController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProviderProfileController; use App\Http\Controllers\ProviderProfileController;
use App\Http\Controllers\QuoteController; use App\Http\Controllers\QuoteController;
use App\Http\Controllers\RatingController;
use App\Http\Controllers\ReviewController; use App\Http\Controllers\ReviewController;
use App\Http\Controllers\SocialPostController; use App\Http\Controllers\SocialPostController;
use App\Http\Controllers\WorkOrderMessageController; use App\Http\Controllers\WorkOrderMessageController;
@@ -21,6 +23,10 @@ Route::get('/', function () {
return view('welcome'); return view('welcome');
}); });
Route::get('/terms', function () {
return view('terms');
})->name('terms');
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return view('dashboard'); return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');
@@ -40,6 +46,11 @@ Route::middleware('auth')->group(function () {
Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store'); Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store');
Route::post('/comments', [CommentController::class, 'store'])->name('comments.store'); Route::post('/comments', [CommentController::class, 'store'])->name('comments.store');
Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store'); Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store');
Route::post('/ratings', [RatingController::class, 'store'])->name('ratings.store');
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::post('/notifications/read-all', [NotificationController::class, 'markAllRead'])->name('notifications.read-all');
Route::post('/notifications/{id}/read', [NotificationController::class, 'markRead'])->name('notifications.read');
Route::get('/jobs/create', [JobPostController::class, 'create'])->name('jobs.create'); Route::get('/jobs/create', [JobPostController::class, 'create'])->name('jobs.create');
Route::post('/jobs', [JobPostController::class, 'store'])->name('jobs.store'); Route::post('/jobs', [JobPostController::class, 'store'])->name('jobs.store');
+48
View File
@@ -3,9 +3,11 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\JobPost; use App\Models\JobPost;
use App\Models\ProviderProfile;
use App\Models\SocialPost; use App\Models\SocialPost;
use App\Models\User; use App\Models\User;
use App\Models\WorkOrder; use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -168,6 +170,52 @@ class ExchangeWorkflowTest extends TestCase
$this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']); $this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']);
} }
public function test_everything_rating_layer_accepts_profile_and_work_order_ratings(): void
{
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
$profile = ProviderProfile::create([
'user_id' => $provider->id,
'business_name' => 'Rated Provider',
]);
$this->actingAs($buyer)->post('/ratings', [
'rateable_type' => 'provider_profile',
'rateable_id' => $profile->id,
'category' => 'provider_overall',
'stars' => 5,
'body' => 'Reliable provider.',
])->assertRedirect();
$this->actingAs($provider)->post('/ratings', [
'rateable_type' => 'work_order',
'rateable_id' => $workOrder->id,
'category' => 'work_order_outcome',
'thumbs_up' => 1,
'body' => 'Good job outcome.',
])->assertRedirect();
$this->assertDatabaseHas('ratings', ['category' => 'provider_overall', 'stars' => 5]);
$this->assertDatabaseHas('ratings', ['category' => 'work_order_outcome', 'thumbs_up' => true]);
}
public function test_notification_inbox_lists_and_marks_notifications_read(): void
{
$user = $this->userWithRole('provider');
$user->notify(new ExchangeEventNotification('Test notice', 'Body text', '/jobs', 'test'));
$notification = $user->notifications()->firstOrFail();
$this->actingAs($user)->get('/notifications')
->assertOk()
->assertSee('Test notice');
$this->actingAs($user)->post("/notifications/{$notification->id}/read")
->assertRedirect();
$this->assertNotNull($notification->fresh()->read_at);
}
private function userWithRole(string $role): User private function userWithRole(string $role): User
{ {
Role::findOrCreate($role); Role::findOrCreate($role);