Add
This commit is contained in:
@@ -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,6 +2,8 @@
|
||||
|
||||
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:
|
||||
|
||||
- provider and buyer profiles;
|
||||
|
||||
@@ -9,10 +9,21 @@ use Illuminate\View\View;
|
||||
|
||||
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', [
|
||||
'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
|
||||
{
|
||||
return view('buyers.show', [
|
||||
'profile' => $buyer->load('user', 'attachments'),
|
||||
'profile' => $buyer->load('user', 'attachments', 'ratings.user'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class DisputeController extends Controller
|
||||
{
|
||||
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(
|
||||
in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true)
|
||||
|
||||
@@ -9,10 +9,25 @@ use Illuminate\View\View;
|
||||
|
||||
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', [
|
||||
'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',
|
||||
'workOrder',
|
||||
'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
|
||||
{
|
||||
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', [
|
||||
'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
|
||||
{
|
||||
return view('providers.show', [
|
||||
'profile' => $provider->load('user', 'externalImports.attachments', 'attachments'),
|
||||
'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class WorkOrderController extends Controller
|
||||
{
|
||||
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('buyer_id', $request->user()->id)
|
||||
->orWhere('provider_id', $request->user()->id)
|
||||
@@ -38,6 +38,7 @@ class WorkOrderController extends Controller
|
||||
'disputes.openedBy',
|
||||
'messages.user',
|
||||
'attachments',
|
||||
'ratings.user',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -23,4 +23,9 @@ class BuyerProfile extends Model
|
||||
{
|
||||
return $this->morphMany(Attachment::class, 'attachable');
|
||||
}
|
||||
|
||||
public function ratings(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Rating::class, 'rateable');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,9 @@ class Dispute extends Model
|
||||
{
|
||||
return $this->morphMany(Attachment::class, 'attachable');
|
||||
}
|
||||
|
||||
public function ratings(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Rating::class, 'rateable');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +47,9 @@ class JobPost extends Model
|
||||
{
|
||||
return $this->morphMany(Attachment::class, 'attachable');
|
||||
}
|
||||
|
||||
public function ratings(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Rating::class, 'rateable');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,9 @@ class ProviderProfile extends Model
|
||||
{
|
||||
return $this->morphMany(Attachment::class, 'attachable');
|
||||
}
|
||||
|
||||
public function ratings(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Rating::class, 'rateable');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,11 @@ class WorkOrder extends Model
|
||||
return $this->morphMany(Attachment::class, 'attachable');
|
||||
}
|
||||
|
||||
public function ratings(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Rating::class, 'rateable');
|
||||
}
|
||||
|
||||
public function canTransitionTo(string $status): bool
|
||||
{
|
||||
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');
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,16 @@
|
||||
<x-app-layout>
|
||||
<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">
|
||||
<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)
|
||||
<a href="{{ route('buyers.show', $profile) }}" class="block rounded border bg-white p-4 shadow-sm">
|
||||
<h3 class="font-semibold">{{ $profile->company_name }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ $profile->headline }}</p>
|
||||
<p class="mt-2 text-sm">{{ $profile->hiring_regions }}</p>
|
||||
<x-rating-summary :ratings="$profile->ratings" />
|
||||
</a>
|
||||
@endforeach
|
||||
{{ $profiles->links() }}
|
||||
|
||||
@@ -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>
|
||||
</dl>
|
||||
<x-attachments :attachments="$profile->attachments" />
|
||||
<x-rating-summary :ratings="$profile->ratings" />
|
||||
<x-rating-form type="buyer_profile" :id="$profile->id" category="buyer_overall" />
|
||||
</div>
|
||||
</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>
|
||||
@@ -11,9 +11,24 @@
|
||||
<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>
|
||||
<x-rating-summary :ratings="$dispute->ratings" />
|
||||
<x-rating-form type="dispute" :id="$dispute->id" category="resolution_fairness" mode="thumbs" />
|
||||
</section>
|
||||
<section class="rounded border bg-white p-6">
|
||||
<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">
|
||||
@csrf
|
||||
<label class="block text-sm font-medium">Recommendation</label>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<x-app-layout>
|
||||
<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">
|
||||
<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
|
||||
@foreach ($jobs as $job)
|
||||
<a href="{{ route('jobs.show', $job) }}" class="block rounded border bg-white p-4 shadow-sm">
|
||||
<h3 class="font-semibold">{{ $job->title }}</h3>
|
||||
<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>
|
||||
<x-rating-summary :ratings="$job->ratings" />
|
||||
</a>
|
||||
@endforeach
|
||||
{{ $jobs->links() }}
|
||||
|
||||
@@ -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>
|
||||
</dl>
|
||||
<x-attachments :attachments="$job->attachments" />
|
||||
<x-rating-summary :ratings="$job->ratings" />
|
||||
<x-rating-form type="job_post" :id="$job->id" category="job_quality" />
|
||||
@auth
|
||||
@if (auth()->id() === $job->buyer_id)
|
||||
<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.*')">
|
||||
{{ __('Work Orders') }}
|
||||
</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')
|
||||
<x-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')">
|
||||
{{ __('Admin') }}
|
||||
@@ -122,6 +130,11 @@
|
||||
<x-responsive-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
|
||||
{{ __('Work Orders') }}
|
||||
</x-responsive-nav-link>
|
||||
@auth
|
||||
<x-responsive-nav-link :href="route('notifications.index')" :active="request()->routeIs('notifications.*')">
|
||||
{{ __('Notifications') }}
|
||||
</x-responsive-nav-link>
|
||||
@endauth
|
||||
@auth
|
||||
@role('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-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">
|
||||
<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)
|
||||
<a href="{{ route('providers.show', $profile) }}" class="block rounded border bg-white p-4 shadow-sm">
|
||||
<h3 class="font-semibold">{{ $profile->business_name }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ $profile->headline }}</p>
|
||||
<p class="mt-2 text-sm">{{ $profile->service_area }}</p>
|
||||
<x-rating-summary :ratings="$profile->ratings" />
|
||||
@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>
|
||||
@endforeach
|
||||
|
||||
@@ -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>
|
||||
</dl>
|
||||
<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 class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">External profile history</h3>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<x-rating-summary :ratings="$order->ratings" />
|
||||
</a>
|
||||
@endforeach
|
||||
{{ $orders->links() }}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<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-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" />
|
||||
<form method="POST" action="{{ route('work-orders.transition', $workOrder) }}" class="mt-6 space-y-4">
|
||||
@csrf @method('PATCH')
|
||||
|
||||
@@ -9,8 +9,10 @@ use App\Http\Controllers\DisputeController;
|
||||
use App\Http\Controllers\DisputeVoteController;
|
||||
use App\Http\Controllers\ExternalProfileImportController;
|
||||
use App\Http\Controllers\JobPostController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use App\Http\Controllers\ProviderProfileController;
|
||||
use App\Http\Controllers\QuoteController;
|
||||
use App\Http\Controllers\RatingController;
|
||||
use App\Http\Controllers\ReviewController;
|
||||
use App\Http\Controllers\SocialPostController;
|
||||
use App\Http\Controllers\WorkOrderMessageController;
|
||||
@@ -21,6 +23,10 @@ Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
|
||||
Route::get('/terms', function () {
|
||||
return view('terms');
|
||||
})->name('terms');
|
||||
|
||||
Route::get('/dashboard', function () {
|
||||
return view('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('/comments', [CommentController::class, 'store'])->name('comments.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::post('/jobs', [JobPostController::class, 'store'])->name('jobs.store');
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\JobPost;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\SocialPost;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkOrder;
|
||||
use App\Notifications\ExchangeEventNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -168,6 +170,52 @@ class ExchangeWorkflowTest extends TestCase
|
||||
$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
|
||||
{
|
||||
Role::findOrCreate($role);
|
||||
|
||||
Reference in New Issue
Block a user