Build
This commit is contained in:
@@ -1,58 +1,80 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# Provider Exchange
|
||||||
|
|
||||||
<p align="center">
|
Provider Exchange is an early Laravel prototype for a provider-centered field service network and work-order exchange.
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About Laravel
|
The product goal is not to clone Field Nation. The first version focuses on:
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
- provider and buyer profiles;
|
||||||
|
- community posts;
|
||||||
|
- buyer job posts;
|
||||||
|
- provider quotes;
|
||||||
|
- work-order assignment and status tracking;
|
||||||
|
- mutual reviews;
|
||||||
|
- peer-review dispute records;
|
||||||
|
- external profile/rating snapshots from Field Nation, WorkMarket, and similar platforms.
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
## Guardrails
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
- The platform does not hold money in v1.
|
||||||
|
- The platform does not set or recommend market rates.
|
||||||
|
- Providers control their own rate cards and terms.
|
||||||
|
- Buyers control their own posted offer terms.
|
||||||
|
- Payment remains direct between buyer and provider unless a compliant payment partner is added later.
|
||||||
|
- Reputation is mutual: providers review buyers and buyers review providers.
|
||||||
|
- Dispute records are reputational/peer-review records, not legal adjudication.
|
||||||
|
|
||||||
## Learning Laravel
|
## Local Setup
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
|
||||||
|
|
||||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
|
||||||
|
|
||||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
|
||||||
|
|
||||||
## Agentic Development
|
|
||||||
|
|
||||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require laravel/boost --dev
|
composer install
|
||||||
|
npm install
|
||||||
php artisan boost:install
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
touch database/database.sqlite
|
||||||
|
php artisan migrate:fresh --seed
|
||||||
|
npm run build
|
||||||
|
php artisan serve --host=0.0.0.0 --port=8123
|
||||||
```
|
```
|
||||||
|
|
||||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
## Demo Accounts
|
||||||
|
|
||||||
## Contributing
|
All seeded demo accounts use password `password`.
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
- `admin@example.com`
|
||||||
|
- `provider@example.com`
|
||||||
|
- `buyer@example.com`
|
||||||
|
- `hybrid@example.com`
|
||||||
|
|
||||||
## Code of Conduct
|
## Test Commands
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
```bash
|
||||||
|
php artisan test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
## Security Vulnerabilities
|
## Current Roadmap Status
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
1. Foundation: Laravel app, auth, roles, seeded demo users.
|
||||||
|
2. Profiles: provider and buyer profiles.
|
||||||
|
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.
|
||||||
|
|
||||||
## License
|
## External Profile Imports
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
Provider profiles support manual external snapshots. For Field Nation, the first version records:
|
||||||
|
|
||||||
|
- platform name;
|
||||||
|
- external provider ID;
|
||||||
|
- profile URL if available;
|
||||||
|
- rating;
|
||||||
|
- review count;
|
||||||
|
- completed jobs;
|
||||||
|
- notes or copied review summaries.
|
||||||
|
|
||||||
|
Public search did not expose a Field Nation profile for provider ID `172-630`, so any automated Field Nation import should be treated as a later authenticated/export-assisted workflow rather than a public scrape assumption.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\BuyerProfile;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class BuyerProfileController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('buyers.index', [
|
||||||
|
'profiles' => BuyerProfile::with('user')->latest()->paginate(20),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): View
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->hasRole('buyer'), 403);
|
||||||
|
|
||||||
|
return view('buyers.edit', [
|
||||||
|
'profile' => $request->user()->buyerProfile,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->hasRole('buyer'), 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'company_name' => ['required', 'string', 'max:255'],
|
||||||
|
'headline' => ['nullable', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'service_categories' => ['nullable', 'string'],
|
||||||
|
'hiring_regions' => ['nullable', 'string'],
|
||||||
|
'vendor_onboarding' => ['nullable', 'string'],
|
||||||
|
'payment_terms' => ['nullable', 'string'],
|
||||||
|
'website_url' => ['nullable', 'url', 'max:255'],
|
||||||
|
'contact_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'public_contact' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['public_contact'] = $request->boolean('public_contact');
|
||||||
|
|
||||||
|
$request->user()->buyerProfile()->updateOrCreate(
|
||||||
|
['user_id' => $request->user()->id],
|
||||||
|
$data
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('buyers.edit')->with('status', 'Buyer profile saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(BuyerProfile $buyer): View
|
||||||
|
{
|
||||||
|
return view('buyers.show', [
|
||||||
|
'profile' => $buyer->load('user'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Dispute;
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DisputeController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, Dispute $dispute): View
|
||||||
|
{
|
||||||
|
$dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy');
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true)
|
||||||
|
|| $request->user()->hasRole('admin'),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('disputes.show', ['dispute' => $dispute]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, WorkOrder $workOrder): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(in_array($request->user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true), 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'summary' => ['required', 'string', 'max:255'],
|
||||||
|
'claim' => ['required', 'string'],
|
||||||
|
'evidence_notes' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispute = $workOrder->disputes()->create($data + [
|
||||||
|
'opened_by_id' => $request->user()->id,
|
||||||
|
'status' => 'open',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workOrder->update(['status' => 'disputed']);
|
||||||
|
|
||||||
|
return redirect()->route('disputes.show', $dispute)->with('status', 'Dispute opened for peer review.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ExternalProfileImportController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$profile = $request->user()->providerProfile;
|
||||||
|
|
||||||
|
abort_unless($request->user()->hasRole('provider') && $profile, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'platform' => ['required', 'string', 'max:255'],
|
||||||
|
'external_id' => ['nullable', 'string', 'max:255'],
|
||||||
|
'profile_url' => ['nullable', 'url', 'max:255'],
|
||||||
|
'rating' => ['nullable', 'numeric', 'min:0', 'max:5'],
|
||||||
|
'review_count' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'completed_jobs' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'notes' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->externalImports()->create($data + [
|
||||||
|
'status' => 'manual',
|
||||||
|
'imported_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('providers.edit')->with('status', 'External profile snapshot saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class JobPostController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('jobs.index', [
|
||||||
|
'jobs' => JobPost::with('buyer.buyerProfile')->latest()->paginate(20),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->hasRole('buyer'), 403);
|
||||||
|
|
||||||
|
return view('jobs.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->hasRole('buyer'), 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'service_category' => ['nullable', 'string', 'max:255'],
|
||||||
|
'location' => ['required', 'string', 'max:255'],
|
||||||
|
'starts_at' => ['nullable', 'date'],
|
||||||
|
'time_window' => ['nullable', 'string', 'max:255'],
|
||||||
|
'scope' => ['required', 'string'],
|
||||||
|
'required_skills' => ['nullable', 'string'],
|
||||||
|
'required_tools' => ['nullable', 'string'],
|
||||||
|
'deliverables' => ['nullable', 'string'],
|
||||||
|
'payment_terms' => ['nullable', 'string'],
|
||||||
|
'vendor_onboarding' => ['nullable', 'string'],
|
||||||
|
'visibility' => ['required', 'string', 'in:public,members'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = $request->user()->jobPosts()->create($data);
|
||||||
|
|
||||||
|
return redirect()->route('jobs.show', $job)->with('status', 'Job posted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(JobPost $job): View
|
||||||
|
{
|
||||||
|
return view('jobs.show', [
|
||||||
|
'job' => $job->load('buyer.buyerProfile', 'quotes.provider.providerProfile', 'workOrder'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ProviderProfile;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ProviderProfileController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('providers.index', [
|
||||||
|
'profiles' => ProviderProfile::with('user', 'externalImports')->latest()->paginate(20),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): View
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->hasRole('provider'), 403);
|
||||||
|
|
||||||
|
return view('providers.edit', [
|
||||||
|
'profile' => $request->user()->providerProfile,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->hasRole('provider'), 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'business_name' => ['required', 'string', 'max:255'],
|
||||||
|
'headline' => ['nullable', 'string', 'max:255'],
|
||||||
|
'bio' => ['nullable', 'string'],
|
||||||
|
'service_area' => ['nullable', 'string', 'max:255'],
|
||||||
|
'skills' => ['nullable', 'string'],
|
||||||
|
'tools' => ['nullable', 'string'],
|
||||||
|
'certifications' => ['nullable', 'string'],
|
||||||
|
'insurance_status' => ['nullable', 'string', 'max:255'],
|
||||||
|
'rate_card' => ['nullable', 'string'],
|
||||||
|
'travel_policy' => ['nullable', 'string'],
|
||||||
|
'availability_notes' => ['nullable', 'string'],
|
||||||
|
'website_url' => ['nullable', 'url', 'max:255'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:255'],
|
||||||
|
'public_contact' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['public_contact'] = $request->boolean('public_contact');
|
||||||
|
|
||||||
|
$request->user()->providerProfile()->updateOrCreate(
|
||||||
|
['user_id' => $request->user()->id],
|
||||||
|
$data
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('providers.edit')->with('status', 'Provider profile saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(ProviderProfile $provider): View
|
||||||
|
{
|
||||||
|
return view('providers.show', [
|
||||||
|
'profile' => $provider->load('user', 'externalImports'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\Quote;
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class QuoteController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, JobPost $job): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->hasRole('provider'), 403);
|
||||||
|
abort_if($job->buyer_id === $request->user()->id, 403);
|
||||||
|
abort_unless($job->status === 'open', 422);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'requested_amount' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'rate_summary' => ['nullable', 'string', 'max:255'],
|
||||||
|
'message' => ['nullable', 'string'],
|
||||||
|
'terms' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job->quotes()->create($data + [
|
||||||
|
'provider_id' => $request->user()->id,
|
||||||
|
'status' => 'submitted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('jobs.show', $job)->with('status', 'Quote submitted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accept(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' => 'accepted']);
|
||||||
|
$quote->jobPost->quotes()->whereKeyNot($quote->id)->update(['status' => 'declined']);
|
||||||
|
$quote->jobPost->update(['status' => 'assigned']);
|
||||||
|
|
||||||
|
$workOrder = WorkOrder::create([
|
||||||
|
'job_post_id' => $quote->job_post_id,
|
||||||
|
'buyer_id' => $quote->jobPost->buyer_id,
|
||||||
|
'provider_id' => $quote->provider_id,
|
||||||
|
'accepted_quote_id' => $quote->id,
|
||||||
|
'status' => 'assigned',
|
||||||
|
'agreed_terms' => trim(($quote->rate_summary ?? '')."\n\n".($quote->terms ?? '')),
|
||||||
|
'deliverables_checklist' => $quote->jobPost->deliverables,
|
||||||
|
'status_history' => [[
|
||||||
|
'status' => 'assigned',
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Quote accepted and work order created.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ReviewController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, WorkOrder $workOrder): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(in_array($request->user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true), 403);
|
||||||
|
|
||||||
|
$revieweeId = $request->user()->id === $workOrder->buyer_id
|
||||||
|
? $workOrder->provider_id
|
||||||
|
: $workOrder->buyer_id;
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'rating' => ['required', 'integer', 'min:1', 'max:5'],
|
||||||
|
'body' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workOrder->reviews()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'reviewer_id' => $request->user()->id,
|
||||||
|
'reviewee_id' => $revieweeId,
|
||||||
|
],
|
||||||
|
$data + [
|
||||||
|
'review_type' => $request->user()->id === $workOrder->buyer_id ? 'buyer_to_provider' : 'provider_to_buyer',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Review saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\SocialPost;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class SocialPostController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('feed.index', [
|
||||||
|
'posts' => SocialPost::with('user')->latest()->paginate(20),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'body' => ['required', 'string'],
|
||||||
|
'visibility' => ['required', 'string', 'in:public,members'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->socialPosts()->create($data);
|
||||||
|
|
||||||
|
return redirect()->route('feed.index')->with('status', 'Post published.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class WorkOrderController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$orders = WorkOrder::with('jobPost', 'buyer.buyerProfile', 'provider.providerProfile')
|
||||||
|
->where(fn ($query) => $query
|
||||||
|
->where('buyer_id', $request->user()->id)
|
||||||
|
->orWhere('provider_id', $request->user()->id)
|
||||||
|
)
|
||||||
|
->latest()
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
return view('work-orders.index', ['orders' => $orders]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, WorkOrder $workOrder): View
|
||||||
|
{
|
||||||
|
$this->authorizeParticipant($request, $workOrder);
|
||||||
|
|
||||||
|
return view('work-orders.show', [
|
||||||
|
'workOrder' => $workOrder->load(
|
||||||
|
'jobPost',
|
||||||
|
'buyer.buyerProfile',
|
||||||
|
'provider.providerProfile',
|
||||||
|
'acceptedQuote',
|
||||||
|
'reviews.reviewer',
|
||||||
|
'reviews.reviewee',
|
||||||
|
'disputes.openedBy'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transition(Request $request, WorkOrder $workOrder): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeParticipant($request, $workOrder);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'status' => ['required', 'string', 'in:'.implode(',', WorkOrder::STATUSES)],
|
||||||
|
'completion_notes' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = $data['status'];
|
||||||
|
$timestamps = [
|
||||||
|
'en_route' => 'en_route_at',
|
||||||
|
'on_site' => 'on_site_at',
|
||||||
|
'in_progress' => 'started_at',
|
||||||
|
'completed' => 'completed_at',
|
||||||
|
'buyer_approved' => 'approved_at',
|
||||||
|
'closed' => 'closed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
$history = $workOrder->status_history ?? [];
|
||||||
|
$history[] = [
|
||||||
|
'status' => $status,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
'status' => $status,
|
||||||
|
'status_history' => $history,
|
||||||
|
'completion_notes' => $data['completion_notes'] ?? $workOrder->completion_notes,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($timestamps[$status]) && ! $workOrder->{$timestamps[$status]}) {
|
||||||
|
$updates[$timestamps[$status]] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$workOrder->update($updates);
|
||||||
|
|
||||||
|
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeParticipant(Request $request, WorkOrder $workOrder): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
in_array($request->user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true)
|
||||||
|
|| $request->user()->hasRole('admin'),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'user_id', 'company_name', 'headline', 'description', 'service_categories',
|
||||||
|
'hiring_regions', 'vendor_onboarding', 'payment_terms', 'website_url',
|
||||||
|
'contact_email', 'public_contact',
|
||||||
|
])]
|
||||||
|
class BuyerProfile extends Model
|
||||||
|
{
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?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', 'opened_by_id', 'status', 'summary', 'claim', 'response',
|
||||||
|
'evidence_notes', 'recommended_resolution', 'peer_votes', 'resolved_at',
|
||||||
|
])]
|
||||||
|
class Dispute extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'peer_votes' => 'array',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workOrder(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WorkOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'opened_by_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'provider_profile_id', 'platform', 'external_id', 'profile_url', 'status',
|
||||||
|
'rating', 'review_count', 'completed_jobs', 'metrics', 'review_snapshots',
|
||||||
|
'notes', 'imported_at',
|
||||||
|
])]
|
||||||
|
class ExternalProfileImport extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metrics' => 'array',
|
||||||
|
'review_snapshots' => 'array',
|
||||||
|
'imported_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providerProfile(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProviderProfile::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'buyer_id', 'title', 'status', 'service_category', 'location', 'starts_at',
|
||||||
|
'time_window', 'scope', 'required_skills', 'required_tools', 'deliverables',
|
||||||
|
'payment_terms', 'vendor_onboarding', 'visibility',
|
||||||
|
])]
|
||||||
|
class JobPost extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buyer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'buyer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quotes(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Quote::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workOrder(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(WorkOrder::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'user_id', 'job_alerts', 'quote_updates', 'work_order_updates',
|
||||||
|
'review_updates', 'dispute_updates', 'email_enabled', 'push_enabled',
|
||||||
|
])]
|
||||||
|
class NotificationPreference extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'job_alerts' => 'boolean',
|
||||||
|
'quote_updates' => 'boolean',
|
||||||
|
'work_order_updates' => 'boolean',
|
||||||
|
'review_updates' => 'boolean',
|
||||||
|
'dispute_updates' => 'boolean',
|
||||||
|
'email_enabled' => 'boolean',
|
||||||
|
'push_enabled' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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([
|
||||||
|
'user_id', 'business_name', 'headline', 'bio', 'service_area', 'skills',
|
||||||
|
'tools', 'certifications', 'insurance_status', 'rate_card', 'travel_policy',
|
||||||
|
'availability_notes', 'website_url', 'phone', 'public_contact',
|
||||||
|
])]
|
||||||
|
class ProviderProfile extends Model
|
||||||
|
{
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function externalImports(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ExternalProfileImport::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(['job_post_id', 'provider_id', 'status', 'requested_amount', 'rate_summary', 'message', 'terms'])]
|
||||||
|
class Quote extends Model
|
||||||
|
{
|
||||||
|
public function jobPost(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(JobPost::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provider(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'provider_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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', 'reviewer_id', 'reviewee_id', 'rating', 'body', 'review_type'])]
|
||||||
|
class Review extends Model
|
||||||
|
{
|
||||||
|
public function workOrder(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WorkOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviewer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reviewer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviewee(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reviewee_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['user_id', 'title', 'body', 'visibility'])]
|
||||||
|
class SocialPost extends Model
|
||||||
|
{
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-1
@@ -8,7 +8,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
#[Fillable(['name', 'email', 'password'])]
|
||||||
@@ -16,7 +19,7 @@ use Spatie\Permission\Traits\HasRoles;
|
|||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, HasRoles, Notifiable;
|
use HasApiTokens, HasFactory, HasRoles, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
@@ -30,4 +33,34 @@ class User extends Authenticatable
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function providerProfile(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(ProviderProfile::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buyerProfile(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(BuyerProfile::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notificationPreference(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(NotificationPreference::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function socialPosts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SocialPost::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobPosts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(JobPost::class, 'buyer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quotes(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Quote::class, 'provider_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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', 'buyer_id', 'provider_id', 'accepted_quote_id', 'status',
|
||||||
|
'status_history', 'agreed_terms', 'deliverables_checklist', 'completion_notes',
|
||||||
|
'en_route_at', 'on_site_at', 'started_at', 'completed_at', 'approved_at', 'closed_at',
|
||||||
|
])]
|
||||||
|
class WorkOrder extends Model
|
||||||
|
{
|
||||||
|
public const STATUSES = [
|
||||||
|
'assigned', 'en_route', 'on_site', 'in_progress', 'completed',
|
||||||
|
'buyer_approved', 'disputed', 'closed', 'cancelled',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status_history' => 'array',
|
||||||
|
'en_route_at' => 'datetime',
|
||||||
|
'on_site_at' => 'datetime',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
'closed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobPost(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(JobPost::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buyer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'buyer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provider(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'provider_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptedQuote(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Quote::class, 'accepted_quote_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviews(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Review::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disputes(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Dispute::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use Illuminate\Http\Request;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^3.0"
|
"laravel/tinker": "^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
Generated
+64
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "17ccc90200e8783e28393fa8939abb4e",
|
"content-hash": "473e3a71038143cc7a799d39f29b9747",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -1340,6 +1340,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-26T00:11:25+00:00"
|
"time": "2026-06-26T00:11:25+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/sanctum",
|
||||||
|
"version": "v4.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/sanctum.git",
|
||||||
|
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
|
||||||
|
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/database": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"symfony/console": "^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||||
|
"phpstan/phpstan": "^1.10"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Sanctum\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||||
|
"keywords": [
|
||||||
|
"auth",
|
||||||
|
"laravel",
|
||||||
|
"sanctum"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/sanctum/issues",
|
||||||
|
"source": "https://github.com/laravel/sanctum"
|
||||||
|
},
|
||||||
|
"time": "2026-04-30T11:46:25+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/serializable-closure",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.13",
|
"version": "v2.0.13",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||||
|
Sanctum::currentApplicationUrlWithPort(),
|
||||||
|
// Sanctum::currentRequestHost(),
|
||||||
|
))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains the authentication guards that will be checked when
|
||||||
|
| Sanctum is trying to authenticate a request. If none of these guards
|
||||||
|
| are able to authenticate the request, Sanctum will use the bearer
|
||||||
|
| token that's present on an incoming request for authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the number of minutes until an issued token will be
|
||||||
|
| considered expired. This will override any values set in the token's
|
||||||
|
| "expires_at" attribute, but first-party sessions are not affected.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||||
|
| security scanning initiatives maintained by open source platforms
|
||||||
|
| that notify developers if they commit tokens into repositories.
|
||||||
|
|
|
||||||
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When authenticating your first-party SPA with Sanctum you may need to
|
||||||
|
| customize some of the middleware Sanctum uses while processing the
|
||||||
|
| request. You may change the middleware listed below as required.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
|
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?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('provider_profiles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('business_name');
|
||||||
|
$table->string('headline')->nullable();
|
||||||
|
$table->text('bio')->nullable();
|
||||||
|
$table->string('service_area')->nullable();
|
||||||
|
$table->text('skills')->nullable();
|
||||||
|
$table->text('tools')->nullable();
|
||||||
|
$table->text('certifications')->nullable();
|
||||||
|
$table->string('insurance_status')->nullable();
|
||||||
|
$table->text('rate_card')->nullable();
|
||||||
|
$table->text('travel_policy')->nullable();
|
||||||
|
$table->text('availability_notes')->nullable();
|
||||||
|
$table->string('website_url')->nullable();
|
||||||
|
$table->string('phone')->nullable();
|
||||||
|
$table->boolean('public_contact')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('provider_profiles');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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('buyer_profiles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('company_name');
|
||||||
|
$table->string('headline')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->text('service_categories')->nullable();
|
||||||
|
$table->text('hiring_regions')->nullable();
|
||||||
|
$table->text('vendor_onboarding')->nullable();
|
||||||
|
$table->text('payment_terms')->nullable();
|
||||||
|
$table->string('website_url')->nullable();
|
||||||
|
$table->string('contact_email')->nullable();
|
||||||
|
$table->boolean('public_contact')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('buyer_profiles');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('social_posts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('title')->nullable();
|
||||||
|
$table->text('body');
|
||||||
|
$table->string('visibility')->default('public');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('social_posts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?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('job_posts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('status')->default('open');
|
||||||
|
$table->string('service_category')->nullable();
|
||||||
|
$table->string('location');
|
||||||
|
$table->timestamp('starts_at')->nullable();
|
||||||
|
$table->string('time_window')->nullable();
|
||||||
|
$table->text('scope');
|
||||||
|
$table->text('required_skills')->nullable();
|
||||||
|
$table->text('required_tools')->nullable();
|
||||||
|
$table->text('deliverables')->nullable();
|
||||||
|
$table->text('payment_terms')->nullable();
|
||||||
|
$table->text('vendor_onboarding')->nullable();
|
||||||
|
$table->string('visibility')->default('public');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('job_posts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('quotes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('job_post_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('provider_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->string('status')->default('submitted');
|
||||||
|
$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('quotes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?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_orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('job_post_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('provider_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('accepted_quote_id')->nullable()->constrained('quotes')->nullOnDelete();
|
||||||
|
$table->string('status')->default('assigned');
|
||||||
|
$table->json('status_history')->nullable();
|
||||||
|
$table->text('agreed_terms')->nullable();
|
||||||
|
$table->text('deliverables_checklist')->nullable();
|
||||||
|
$table->text('completion_notes')->nullable();
|
||||||
|
$table->timestamp('en_route_at')->nullable();
|
||||||
|
$table->timestamp('on_site_at')->nullable();
|
||||||
|
$table->timestamp('started_at')->nullable();
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->timestamp('approved_at')->nullable();
|
||||||
|
$table->timestamp('closed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('work_orders');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('reviews', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('work_order_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('reviewer_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('reviewee_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->unsignedTinyInteger('rating');
|
||||||
|
$table->text('body')->nullable();
|
||||||
|
$table->string('review_type');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('reviews');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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('disputes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('work_order_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('opened_by_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->string('status')->default('open');
|
||||||
|
$table->string('summary');
|
||||||
|
$table->text('claim');
|
||||||
|
$table->text('response')->nullable();
|
||||||
|
$table->text('evidence_notes')->nullable();
|
||||||
|
$table->text('recommended_resolution')->nullable();
|
||||||
|
$table->json('peer_votes')->nullable();
|
||||||
|
$table->timestamp('resolved_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('disputes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?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('external_profile_imports', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('provider_profile_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('platform');
|
||||||
|
$table->string('external_id')->nullable();
|
||||||
|
$table->string('profile_url')->nullable();
|
||||||
|
$table->string('status')->default('manual');
|
||||||
|
$table->decimal('rating', 3, 2)->nullable();
|
||||||
|
$table->unsignedInteger('review_count')->nullable();
|
||||||
|
$table->unsignedInteger('completed_jobs')->nullable();
|
||||||
|
$table->json('metrics')->nullable();
|
||||||
|
$table->json('review_snapshots')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamp('imported_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('external_profile_imports');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('tokenable');
|
||||||
|
$table->text('name');
|
||||||
|
$table->string('token', 64)->unique();
|
||||||
|
$table->text('abilities')->nullable();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('personal_access_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('notification_preferences', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||||
|
$table->boolean('job_alerts')->default(true);
|
||||||
|
$table->boolean('quote_updates')->default(true);
|
||||||
|
$table->boolean('work_order_updates')->default(true);
|
||||||
|
$table->boolean('review_updates')->default(true);
|
||||||
|
$table->boolean('dispute_updates')->default(true);
|
||||||
|
$table->boolean('email_enabled')->default(true);
|
||||||
|
$table->boolean('push_enabled')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notification_preferences');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\WorkOrder;
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -46,5 +48,110 @@ class DatabaseSeeder extends Seeder
|
|||||||
['name' => 'Hybrid User', 'password' => Hash::make('password')]
|
['name' => 'Hybrid User', 'password' => Hash::make('password')]
|
||||||
);
|
);
|
||||||
$hybrid->syncRoles(['provider', 'buyer']);
|
$hybrid->syncRoles(['provider', 'buyer']);
|
||||||
|
|
||||||
|
foreach ([$admin, $provider, $buyer, $hybrid] as $user) {
|
||||||
|
$user->notificationPreference()->firstOrCreate([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerProfile = $provider->providerProfile()->updateOrCreate(
|
||||||
|
['user_id' => $provider->id],
|
||||||
|
[
|
||||||
|
'business_name' => 'Independent Field Tech Demo',
|
||||||
|
'headline' => 'POS, networking, cabling, and onsite IT support',
|
||||||
|
'bio' => 'Demo provider profile for the technician-owned exchange.',
|
||||||
|
'service_area' => 'Central US with regional travel',
|
||||||
|
'skills' => "POS\nNetworking\nStructured cabling\nPrinter support",
|
||||||
|
'tools' => "Laptop\nToner/probe\nCable tester\nBasic hand tools",
|
||||||
|
'certifications' => 'Low-voltage and platform-specific certifications can be listed here.',
|
||||||
|
'insurance_status' => 'Provider supplied',
|
||||||
|
'rate_card' => "Two-hour minimum\nStandard onsite hourly rate set by provider\nTravel negotiated per job",
|
||||||
|
'travel_policy' => 'Travel terms are negotiated directly with buyers.',
|
||||||
|
'availability_notes' => 'Weekdays and scheduled after-hours work.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$providerProfile->externalImports()->updateOrCreate(
|
||||||
|
['platform' => 'Field Nation', 'external_id' => '172-630'],
|
||||||
|
[
|
||||||
|
'status' => 'manual',
|
||||||
|
'notes' => 'Placeholder for imported Field Nation profile/rating history. Public search did not expose a profile by ID.',
|
||||||
|
'imported_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$buyer->buyerProfile()->updateOrCreate(
|
||||||
|
['user_id' => $buyer->id],
|
||||||
|
[
|
||||||
|
'company_name' => 'Demo Buyer Services',
|
||||||
|
'headline' => 'Transparent field service buyer profile',
|
||||||
|
'description' => 'Demo buyer profile showing onboarding and payment terms.',
|
||||||
|
'service_categories' => "Networking\nPOS\nPrinter support",
|
||||||
|
'hiring_regions' => 'Nationwide',
|
||||||
|
'vendor_onboarding' => 'Direct vendor onboarding outside the platform.',
|
||||||
|
'payment_terms' => 'Direct payment between buyer and provider.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$provider->socialPosts()->firstOrCreate(
|
||||||
|
['title' => 'Available for regional dispatch'],
|
||||||
|
['body' => 'Demo provider post showing the community feed.', 'visibility' => 'public']
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = JobPost::firstOrCreate(
|
||||||
|
['title' => 'Demo POS troubleshooting visit'],
|
||||||
|
[
|
||||||
|
'buyer_id' => $buyer->id,
|
||||||
|
'status' => 'assigned',
|
||||||
|
'service_category' => 'POS',
|
||||||
|
'location' => 'Tulsa, OK',
|
||||||
|
'time_window' => 'Morning',
|
||||||
|
'scope' => 'Troubleshoot intermittent POS terminal connectivity.',
|
||||||
|
'required_skills' => 'POS and basic networking',
|
||||||
|
'required_tools' => 'Laptop, patch cables, toner/probe',
|
||||||
|
'deliverables' => "Check in\nPhotos\nResolution notes",
|
||||||
|
'payment_terms' => 'Direct buyer/provider payment terms.',
|
||||||
|
'vendor_onboarding' => 'Buyer handles direct onboarding.',
|
||||||
|
'visibility' => 'public',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$quote = $job->quotes()->firstOrCreate(
|
||||||
|
['provider_id' => $provider->id],
|
||||||
|
[
|
||||||
|
'status' => 'accepted',
|
||||||
|
'requested_amount' => 250,
|
||||||
|
'rate_summary' => 'Two-hour minimum plus travel if needed',
|
||||||
|
'message' => 'Can handle this visit with standard POS/network tools.',
|
||||||
|
'terms' => 'Direct invoice to buyer after completion.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$workOrder = WorkOrder::firstOrCreate(
|
||||||
|
['job_post_id' => $job->id],
|
||||||
|
[
|
||||||
|
'buyer_id' => $buyer->id,
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'accepted_quote_id' => $quote->id,
|
||||||
|
'status' => 'assigned',
|
||||||
|
'agreed_terms' => $quote->terms,
|
||||||
|
'deliverables_checklist' => $job->deliverables,
|
||||||
|
'status_history' => [['status' => 'assigned', 'user_id' => $buyer->id, 'at' => now()->toIso8601String()]],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$workOrder->reviews()->firstOrCreate(
|
||||||
|
['reviewer_id' => $buyer->id, 'reviewee_id' => $provider->id],
|
||||||
|
['rating' => 5, 'body' => 'Demo review record.', 'review_type' => 'buyer_to_provider']
|
||||||
|
);
|
||||||
|
|
||||||
|
$workOrder->disputes()->firstOrCreate(
|
||||||
|
['summary' => 'Demo peer review record'],
|
||||||
|
[
|
||||||
|
'opened_by_id' => $provider->id,
|
||||||
|
'status' => 'open',
|
||||||
|
'claim' => 'This is a demo dispute record for peer review workflow testing.',
|
||||||
|
'evidence_notes' => 'Screenshots, chat, and deliverable notes would be attached here later.',
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">Buyer Profile</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-5xl p-6">
|
||||||
|
<form method="POST" action="{{ route('buyers.update') }}" class="space-y-4 rounded border bg-white p-6">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<x-field name="company_name" label="Company name" :value="$profile?->company_name" />
|
||||||
|
<x-field name="headline" label="Headline" :value="$profile?->headline" />
|
||||||
|
<x-field name="description" label="Description" :value="$profile?->description" textarea />
|
||||||
|
<x-field name="service_categories" label="Service categories" :value="$profile?->service_categories" textarea />
|
||||||
|
<x-field name="hiring_regions" label="Hiring regions" :value="$profile?->hiring_regions" textarea />
|
||||||
|
<x-field name="vendor_onboarding" label="Vendor onboarding" :value="$profile?->vendor_onboarding" textarea />
|
||||||
|
<x-field name="payment_terms" label="Payment terms" :value="$profile?->payment_terms" textarea />
|
||||||
|
<x-field name="website_url" label="Website URL" :value="$profile?->website_url" />
|
||||||
|
<x-field name="contact_email" label="Contact email" :value="$profile?->contact_email" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<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">
|
||||||
|
@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>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
{{ $profiles->links() }}
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">{{ $profile->company_name }}</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-5xl rounded border bg-white p-6">
|
||||||
|
<p class="text-lg font-semibold">{{ $profile->headline }}</p>
|
||||||
|
<p class="mt-4 whitespace-pre-line">{{ $profile->description }}</p>
|
||||||
|
<dl class="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<div><dt class="text-sm text-gray-500">Service categories</dt><dd class="whitespace-pre-line">{{ $profile->service_categories }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-gray-500">Hiring regions</dt><dd class="whitespace-pre-line">{{ $profile->hiring_regions }}</dd></div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@props(['name', 'label', 'value' => '', 'textarea' => false, 'type' => 'text'])
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="{{ $name }}" class="block text-sm font-medium text-gray-700">{{ $label }}</label>
|
||||||
|
@if ($textarea)
|
||||||
|
<textarea id="{{ $name }}" name="{{ $name }}" rows="4" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old($name, $value) }}</textarea>
|
||||||
|
@else
|
||||||
|
<input id="{{ $name }}" type="{{ $type }}" name="{{ $name }}" value="{{ old($name, $value) }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
@endif
|
||||||
|
@error($name)
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">Peer Review: {{ $dispute->summary }}</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-5xl space-y-6 p-6">
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<p class="text-sm text-gray-500">Opened by {{ $dispute->openedBy->name }} | {{ $dispute->status }}</p>
|
||||||
|
<h3 class="mt-4 font-semibold">Claim</h3>
|
||||||
|
<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>
|
||||||
|
<h3 class="mt-4 font-semibold">Recommended resolution</h3>
|
||||||
|
<p class="mt-2 whitespace-pre-line">{{ $dispute->recommended_resolution ?? 'Pending peer review.' }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">Community Feed</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
|
@auth
|
||||||
|
<form method="POST" action="{{ route('feed.store') }}" class="space-y-4 rounded border bg-white p-6">
|
||||||
|
@csrf
|
||||||
|
<x-field name="title" label="Title" />
|
||||||
|
<x-field name="body" label="Post" textarea />
|
||||||
|
<input type="hidden" name="visibility" value="public">
|
||||||
|
<x-primary-button>Publish</x-primary-button>
|
||||||
|
</form>
|
||||||
|
@endauth
|
||||||
|
@foreach ($posts as $post)
|
||||||
|
<article class="rounded border bg-white p-6">
|
||||||
|
<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>
|
||||||
|
</article>
|
||||||
|
@endforeach
|
||||||
|
{{ $posts->links() }}
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">Post Job</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-5xl p-6">
|
||||||
|
<form method="POST" action="{{ route('jobs.store') }}" class="space-y-4 rounded border bg-white p-6">
|
||||||
|
@csrf
|
||||||
|
<x-field name="title" label="Title" />
|
||||||
|
<x-field name="service_category" label="Service category" />
|
||||||
|
<x-field name="location" label="Location" />
|
||||||
|
<x-field name="starts_at" label="Start date/time" type="datetime-local" />
|
||||||
|
<x-field name="time_window" label="Time window" />
|
||||||
|
<x-field name="scope" label="Scope" textarea />
|
||||||
|
<x-field name="required_skills" label="Required skills" textarea />
|
||||||
|
<x-field name="required_tools" label="Required tools" textarea />
|
||||||
|
<x-field name="deliverables" label="Deliverables checklist" textarea />
|
||||||
|
<x-field name="payment_terms" label="Payment terms" textarea />
|
||||||
|
<x-field name="vendor_onboarding" label="Vendor onboarding" textarea />
|
||||||
|
<input type="hidden" name="visibility" value="public">
|
||||||
|
<x-primary-button>Post job</x-primary-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<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">
|
||||||
|
@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>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
{{ $jobs->links() }}
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">{{ $job->title }}</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-6xl space-y-6 p-6">
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<p class="text-sm text-gray-500">{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}</p>
|
||||||
|
<p class="mt-4 whitespace-pre-line">{{ $job->scope }}</p>
|
||||||
|
<dl class="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<div><dt class="text-sm text-gray-500">Required skills</dt><dd class="whitespace-pre-line">{{ $job->required_skills }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-gray-500">Required tools</dt><dd class="whitespace-pre-line">{{ $job->required_tools }}</dd></div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
@auth
|
||||||
|
@role('provider')
|
||||||
|
@if ($job->status === 'open' && $job->buyer_id !== auth()->id())
|
||||||
|
<form method="POST" action="{{ route('quotes.store', $job) }}" class="space-y-4 rounded border bg-white p-6">
|
||||||
|
@csrf
|
||||||
|
<h3 class="font-semibold">Submit quote</h3>
|
||||||
|
<x-field name="requested_amount" label="Requested amount" type="number" />
|
||||||
|
<x-field name="rate_summary" label="Rate summary" />
|
||||||
|
<x-field name="message" label="Message" textarea />
|
||||||
|
<x-field name="terms" label="Terms" textarea />
|
||||||
|
<x-primary-button>Submit quote</x-primary-button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
@endrole
|
||||||
|
@endauth
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<h3 class="font-semibold">Quotes</h3>
|
||||||
|
@forelse ($job->quotes as $quote)
|
||||||
|
<div class="mt-4 rounded border p-4">
|
||||||
|
<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>
|
||||||
|
@auth
|
||||||
|
@if (auth()->id() === $job->buyer_id && $job->status === 'open')
|
||||||
|
<form method="POST" action="{{ route('quotes.accept', $quote) }}" class="mt-3">
|
||||||
|
@csrf
|
||||||
|
<x-primary-button>Accept quote</x-primary-button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="mt-2 text-sm text-gray-600">No quotes yet.</p>
|
||||||
|
@endforelse
|
||||||
|
</section>
|
||||||
|
@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
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -15,11 +15,27 @@
|
|||||||
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||||
{{ __('Dashboard') }}
|
{{ __('Dashboard') }}
|
||||||
</x-nav-link>
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('providers.index')" :active="request()->routeIs('providers.*')">
|
||||||
|
{{ __('Providers') }}
|
||||||
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('buyers.index')" :active="request()->routeIs('buyers.*')">
|
||||||
|
{{ __('Buyers') }}
|
||||||
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('feed.index')" :active="request()->routeIs('feed.*')">
|
||||||
|
{{ __('Feed') }}
|
||||||
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('jobs.index')" :active="request()->routeIs('jobs.*')">
|
||||||
|
{{ __('Jobs') }}
|
||||||
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
|
||||||
|
{{ __('Work Orders') }}
|
||||||
|
</x-nav-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Dropdown -->
|
<!-- Settings Dropdown -->
|
||||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||||
|
@auth
|
||||||
<x-dropdown align="right" width="48">
|
<x-dropdown align="right" width="48">
|
||||||
<x-slot name="trigger">
|
<x-slot name="trigger">
|
||||||
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
||||||
@@ -37,6 +53,16 @@
|
|||||||
<x-dropdown-link :href="route('profile.edit')">
|
<x-dropdown-link :href="route('profile.edit')">
|
||||||
{{ __('Profile') }}
|
{{ __('Profile') }}
|
||||||
</x-dropdown-link>
|
</x-dropdown-link>
|
||||||
|
@role('provider')
|
||||||
|
<x-dropdown-link :href="route('providers.edit')">
|
||||||
|
{{ __('Provider Profile') }}
|
||||||
|
</x-dropdown-link>
|
||||||
|
@endrole
|
||||||
|
@role('buyer')
|
||||||
|
<x-dropdown-link :href="route('buyers.edit')">
|
||||||
|
{{ __('Buyer Profile') }}
|
||||||
|
</x-dropdown-link>
|
||||||
|
@endrole
|
||||||
|
|
||||||
<!-- Authentication -->
|
<!-- Authentication -->
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@@ -50,6 +76,12 @@
|
|||||||
</form>
|
</form>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-dropdown>
|
</x-dropdown>
|
||||||
|
@else
|
||||||
|
<div class="flex gap-4 text-sm">
|
||||||
|
<a href="{{ route('login') }}" class="text-gray-600 hover:text-gray-900">Log in</a>
|
||||||
|
<a href="{{ route('register') }}" class="text-gray-600 hover:text-gray-900">Register</a>
|
||||||
|
</div>
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hamburger -->
|
<!-- Hamburger -->
|
||||||
@@ -70,19 +102,47 @@
|
|||||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||||
{{ __('Dashboard') }}
|
{{ __('Dashboard') }}
|
||||||
</x-responsive-nav-link>
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('providers.index')" :active="request()->routeIs('providers.*')">
|
||||||
|
{{ __('Providers') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('buyers.index')" :active="request()->routeIs('buyers.*')">
|
||||||
|
{{ __('Buyers') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('feed.index')" :active="request()->routeIs('feed.*')">
|
||||||
|
{{ __('Feed') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('jobs.index')" :active="request()->routeIs('jobs.*')">
|
||||||
|
{{ __('Jobs') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
|
||||||
|
{{ __('Work Orders') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responsive Settings Options -->
|
<!-- Responsive Settings Options -->
|
||||||
<div class="pt-4 pb-1 border-t border-gray-200">
|
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||||
|
@auth
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
||||||
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endauth
|
||||||
|
|
||||||
<div class="mt-3 space-y-1">
|
<div class="mt-3 space-y-1">
|
||||||
|
@auth
|
||||||
<x-responsive-nav-link :href="route('profile.edit')">
|
<x-responsive-nav-link :href="route('profile.edit')">
|
||||||
{{ __('Profile') }}
|
{{ __('Profile') }}
|
||||||
</x-responsive-nav-link>
|
</x-responsive-nav-link>
|
||||||
|
@role('provider')
|
||||||
|
<x-responsive-nav-link :href="route('providers.edit')">
|
||||||
|
{{ __('Provider Profile') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
@endrole
|
||||||
|
@role('buyer')
|
||||||
|
<x-responsive-nav-link :href="route('buyers.edit')">
|
||||||
|
{{ __('Buyer Profile') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
@endrole
|
||||||
|
|
||||||
<!-- Authentication -->
|
<!-- Authentication -->
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@@ -94,6 +154,14 @@
|
|||||||
{{ __('Log Out') }}
|
{{ __('Log Out') }}
|
||||||
</x-responsive-nav-link>
|
</x-responsive-nav-link>
|
||||||
</form>
|
</form>
|
||||||
|
@else
|
||||||
|
<x-responsive-nav-link :href="route('login')">
|
||||||
|
{{ __('Log in') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('register')">
|
||||||
|
{{ __('Register') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">Provider Profile</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-5xl space-y-6 p-6">
|
||||||
|
<form method="POST" action="{{ route('providers.update') }}" class="space-y-4 rounded border bg-white p-6">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<x-field name="business_name" label="Business name" :value="$profile?->business_name" />
|
||||||
|
<x-field name="headline" label="Headline" :value="$profile?->headline" />
|
||||||
|
<x-field name="bio" label="Bio" :value="$profile?->bio" textarea />
|
||||||
|
<x-field name="service_area" label="Service area" :value="$profile?->service_area" />
|
||||||
|
<x-field name="skills" label="Skills" :value="$profile?->skills" textarea />
|
||||||
|
<x-field name="tools" label="Tools and equipment" :value="$profile?->tools" textarea />
|
||||||
|
<x-field name="certifications" label="Certifications" :value="$profile?->certifications" textarea />
|
||||||
|
<x-field name="insurance_status" label="Insurance status" :value="$profile?->insurance_status" />
|
||||||
|
<x-field name="rate_card" label="Rate card" :value="$profile?->rate_card" textarea />
|
||||||
|
<x-field name="travel_policy" label="Travel policy" :value="$profile?->travel_policy" textarea />
|
||||||
|
<x-field name="availability_notes" label="Availability notes" :value="$profile?->availability_notes" textarea />
|
||||||
|
<x-field name="website_url" label="Website URL" :value="$profile?->website_url" />
|
||||||
|
<x-field name="phone" label="Phone" :value="$profile?->phone" />
|
||||||
|
<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 provider profile</x-primary-button>
|
||||||
|
</form>
|
||||||
|
@if ($profile)
|
||||||
|
<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>
|
||||||
|
<x-field name="platform" label="Platform" value="Field Nation" />
|
||||||
|
<x-field name="external_id" label="External ID" value="172-630" />
|
||||||
|
<x-field name="profile_url" label="Profile URL" />
|
||||||
|
<x-field name="rating" label="Rating" type="number" />
|
||||||
|
<x-field name="review_count" label="Review count" type="number" />
|
||||||
|
<x-field name="completed_jobs" label="Completed jobs" type="number" />
|
||||||
|
<x-field name="notes" label="Notes or copied review summary" textarea />
|
||||||
|
<x-primary-button>Save external snapshot</x-primary-button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +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">
|
||||||
|
@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>
|
||||||
|
@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
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
{{ $profiles->links() }}
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">{{ $profile->business_name }}</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-5xl space-y-6 p-6">
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<p class="text-lg font-semibold">{{ $profile->headline }}</p>
|
||||||
|
<p class="mt-4 whitespace-pre-line">{{ $profile->bio }}</p>
|
||||||
|
<dl class="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<div><dt class="text-sm text-gray-500">Service area</dt><dd>{{ $profile->service_area }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-gray-500">Insurance</dt><dd>{{ $profile->insurance_status }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-gray-500">Skills</dt><dd class="whitespace-pre-line">{{ $profile->skills }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-gray-500">Tools</dt><dd class="whitespace-pre-line">{{ $profile->tools }}</dd></div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<h3 class="font-semibold">External profile history</h3>
|
||||||
|
@forelse ($profile->externalImports as $import)
|
||||||
|
<div class="mt-4 rounded border p-4">
|
||||||
|
<p class="font-semibold">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</p>
|
||||||
|
<p class="text-sm text-gray-600">Rating: {{ $import->rating ?? 'n/a' }} | Reviews: {{ $import->review_count ?? 'n/a' }} | Completed: {{ $import->completed_jobs ?? 'n/a' }}</p>
|
||||||
|
<p class="mt-2 whitespace-pre-line text-sm">{{ $import->notes }}</p>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="mt-2 text-sm text-gray-600">No external profile snapshots yet.</p>
|
||||||
|
@endforelse
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">Work Orders</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-7xl space-y-4 p-6">
|
||||||
|
@foreach ($orders as $order)
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
{{ $orders->links() }}
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header"><h2 class="text-xl font-semibold">Work Order: {{ $workOrder->jobPost->title }}</h2></x-slot>
|
||||||
|
<div class="mx-auto max-w-6xl space-y-6 p-6">
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<p class="text-sm text-gray-500">Status: {{ $workOrder->status }}</p>
|
||||||
|
<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>
|
||||||
|
<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)
|
||||||
|
<option value="{{ $status }}" @selected($workOrder->status === $status)>{{ str_replace('_', ' ', $status) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<x-field name="completion_notes" label="Completion notes" :value="$workOrder->completion_notes" textarea />
|
||||||
|
<x-primary-button>Save status</x-primary-button>
|
||||||
|
</form>
|
||||||
|
</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="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-2 whitespace-pre-line">{{ $review->body }}</p>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</section>
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<h3 class="font-semibold">Disputes / Peer Review</h3>
|
||||||
|
<form method="POST" action="{{ route('disputes.store', $workOrder) }}" class="mt-4 space-y-4">
|
||||||
|
@csrf
|
||||||
|
<x-field name="summary" label="Summary" />
|
||||||
|
<x-field name="claim" label="Claim" textarea />
|
||||||
|
<x-field name="evidence_notes" label="Evidence notes" textarea />
|
||||||
|
<x-primary-button>Open dispute</x-primary-button>
|
||||||
|
</form>
|
||||||
|
@foreach ($workOrder->disputes as $dispute)
|
||||||
|
<a href="{{ route('disputes.show', $dispute) }}" class="mt-4 block rounded border p-4 text-sm">
|
||||||
|
<span class="font-semibold">{{ $dispute->summary }}</span> | {{ $dispute->status }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\ProviderProfile;
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('/providers', function () {
|
||||||
|
return ProviderProfile::with('user:id,name')->latest()->paginate(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/jobs', function () {
|
||||||
|
return JobPost::with('buyer:id,name')->latest()->paginate(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
|
Route::get('/me', function (Request $request) {
|
||||||
|
return $request->user()->load('roles', 'providerProfile', 'buyerProfile');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/work-orders', function (Request $request) {
|
||||||
|
return WorkOrder::with('jobPost:id,title,location', 'buyer:id,name', 'provider:id,name')
|
||||||
|
->where(fn ($query) => $query
|
||||||
|
->where('buyer_id', $request->user()->id)
|
||||||
|
->orWhere('provider_id', $request->user()->id)
|
||||||
|
)
|
||||||
|
->latest()
|
||||||
|
->paginate(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\BuyerProfileController;
|
||||||
|
use App\Http\Controllers\DisputeController;
|
||||||
|
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\WorkOrderController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
@@ -15,6 +24,35 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||||
|
|
||||||
|
Route::get('/provider-profile', [ProviderProfileController::class, 'edit'])->name('providers.edit');
|
||||||
|
Route::put('/provider-profile', [ProviderProfileController::class, 'update'])->name('providers.update');
|
||||||
|
Route::post('/provider-profile/imports', [ExternalProfileImportController::class, 'store'])->name('provider-imports.store');
|
||||||
|
|
||||||
|
Route::get('/buyer-profile', [BuyerProfileController::class, 'edit'])->name('buyers.edit');
|
||||||
|
Route::put('/buyer-profile', [BuyerProfileController::class, 'update'])->name('buyers.update');
|
||||||
|
|
||||||
|
Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.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::post('/quotes/{quote}/accept', [QuoteController::class, 'accept'])->name('quotes.accept');
|
||||||
|
|
||||||
|
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}/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::get('/providers', [ProviderProfileController::class, 'index'])->name('providers.index');
|
||||||
|
Route::get('/providers/{provider}', [ProviderProfileController::class, 'show'])->name('providers.show');
|
||||||
|
Route::get('/buyers', [BuyerProfileController::class, 'index'])->name('buyers.index');
|
||||||
|
Route::get('/buyers/{buyer}', [BuyerProfileController::class, 'show'])->name('buyers.show');
|
||||||
|
Route::get('/feed', [SocialPostController::class, 'index'])->name('feed.index');
|
||||||
|
Route::get('/jobs', [JobPostController::class, 'index'])->name('jobs.index');
|
||||||
|
Route::get('/jobs/{job}', [JobPostController::class, 'show'])->name('jobs.show');
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ExchangeWorkflowTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_provider_and_buyer_profiles_can_be_saved(): void
|
||||||
|
{
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$buyer = $this->userWithRole('buyer');
|
||||||
|
|
||||||
|
$this->actingAs($provider)->put('/provider-profile', [
|
||||||
|
'business_name' => 'Provider LLC',
|
||||||
|
'service_area' => 'Oklahoma',
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$this->actingAs($buyer)->put('/buyer-profile', [
|
||||||
|
'company_name' => 'Buyer Inc',
|
||||||
|
'payment_terms' => 'Direct ACH',
|
||||||
|
])->assertRedirect('/buyer-profile');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('provider_profiles', ['business_name' => 'Provider LLC']);
|
||||||
|
$this->assertDatabaseHas('buyer_profiles', ['company_name' => 'Buyer Inc']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_provider_can_submit_quote_and_buyer_can_create_work_order(): void
|
||||||
|
{
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$buyer = $this->userWithRole('buyer');
|
||||||
|
|
||||||
|
$job = JobPost::create([
|
||||||
|
'buyer_id' => $buyer->id,
|
||||||
|
'title' => 'Install router',
|
||||||
|
'location' => 'Tulsa, OK',
|
||||||
|
'scope' => 'Install and test router.',
|
||||||
|
'visibility' => 'public',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/jobs/{$job->id}/quotes", [
|
||||||
|
'requested_amount' => 300,
|
||||||
|
'rate_summary' => 'Two-hour minimum',
|
||||||
|
])->assertRedirect("/jobs/{$job->id}");
|
||||||
|
|
||||||
|
$quote = $job->quotes()->firstOrFail();
|
||||||
|
|
||||||
|
$this->actingAs($buyer)->post("/quotes/{$quote->id}/accept")
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('work_orders', [
|
||||||
|
'job_post_id' => $job->id,
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'buyer_id' => $buyer->id,
|
||||||
|
'status' => 'assigned',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_work_order_can_transition_review_and_open_dispute(): void
|
||||||
|
{
|
||||||
|
[$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}");
|
||||||
|
|
||||||
|
$this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/reviews", [
|
||||||
|
'rating' => 5,
|
||||||
|
'body' => 'Clean work.',
|
||||||
|
])->assertRedirect("/work-orders/{$workOrder->id}");
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/work-orders/{$workOrder->id}/disputes", [
|
||||||
|
'summary' => 'Scope changed',
|
||||||
|
'claim' => 'Additional work was requested onsite.',
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('work_orders', ['id' => $workOrder->id, 'status' => 'disputed']);
|
||||||
|
$this->assertDatabaseHas('reviews', ['work_order_id' => $workOrder->id, 'rating' => 5]);
|
||||||
|
$this->assertDatabaseHas('disputes', ['work_order_id' => $workOrder->id, 'summary' => 'Scope changed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_external_profile_snapshot_can_be_saved(): void
|
||||||
|
{
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$provider->providerProfile()->create(['business_name' => 'Provider LLC']);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post('/provider-profile/imports', [
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'external_id' => '172-630',
|
||||||
|
'rating' => 4.9,
|
||||||
|
'review_count' => 500,
|
||||||
|
'completed_jobs' => 1200,
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('external_profile_imports', [
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'external_id' => '172-630',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userWithRole(string $role): User
|
||||||
|
{
|
||||||
|
Role::findOrCreate($role);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->assignRole($role);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workOrderFixture(): array
|
||||||
|
{
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$buyer = $this->userWithRole('buyer');
|
||||||
|
$job = JobPost::create([
|
||||||
|
'buyer_id' => $buyer->id,
|
||||||
|
'title' => 'Repair printer',
|
||||||
|
'location' => 'OKC, OK',
|
||||||
|
'scope' => 'Repair printer.',
|
||||||
|
'visibility' => 'public',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workOrder = WorkOrder::create([
|
||||||
|
'job_post_id' => $job->id,
|
||||||
|
'buyer_id' => $buyer->id,
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'status' => 'assigned',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$buyer, $provider, $workOrder];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user