Phase 4 work order profile depth

This commit is contained in:
Codex
2026-07-10 13:24:07 -05:00
parent 084b945e75
commit 24fd8c0c5c
21 changed files with 1031 additions and 110 deletions
+1
View File
@@ -14,6 +14,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
- Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout. - Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout.
- Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states. - Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states.
- Added Phase 3 asset and storage management with configurable attachment disk/root/size/MIME policy, image previews, file metadata display, attachment deletion, update-script storage linking, and upload policy tests. - Added Phase 3 asset and storage management with configurable attachment disk/root/size/MIME policy, image previews, file metadata display, attachment deletion, update-script storage linking, and upload policy tests.
- Added Phase 4 work-order and profile depth with structured work-order setup, checklist completion, evidence rules, change requests, print packets, structured provider services/tools/certifications, structured buyer policies/locations, and public/private profile section controls.
- Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts. - Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts.
- Added provider and buyer profile management with service areas, profile details, and external profile snapshot records. - Added provider and buyer profile management with service areas, profile details, and external profile snapshot records.
- Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. - Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance.
@@ -71,14 +71,25 @@ class BuyerProfileController extends Controller
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'service_categories' => ['nullable', 'string'], 'service_categories' => ['nullable', 'string'],
'hiring_regions' => ['nullable', 'string'], 'hiring_regions' => ['nullable', 'string'],
'hiring_policies_text' => ['nullable', 'string'],
'locations_text' => ['nullable', 'string'],
'vendor_onboarding' => ['nullable', 'string'], 'vendor_onboarding' => ['nullable', 'string'],
'payment_terms' => ['nullable', 'string'], 'payment_terms' => ['nullable', 'string'],
'website_url' => ['nullable', 'url', 'max:255'], 'website_url' => ['nullable', 'url', 'max:255'],
'contact_email' => ['nullable', 'email', 'max:255'], 'contact_email' => ['nullable', 'email', 'max:255'],
'public_contact' => ['nullable', 'boolean'], 'public_contact' => ['nullable', 'boolean'],
'profile_visibility' => ['nullable', 'array'],
'profile_visibility.*' => ['nullable', 'boolean'],
'private_notes' => ['nullable', 'string'],
]); ]);
$data['public_contact'] = $request->boolean('public_contact'); $data['public_contact'] = $request->boolean('public_contact');
$data['hiring_policies'] = $this->linesToRecords($data['hiring_policies_text'] ?? '', ['name', 'summary']);
$data['locations'] = $this->linesToRecords($data['locations_text'] ?? '', ['name', 'region']);
$data['profile_visibility'] = $this->normalizeVisibility($request, [
'description', 'categories', 'locations', 'policies', 'payment_terms', 'onboarding',
]);
unset($data['hiring_policies_text'], $data['locations_text']);
$request->user()->buyerProfile()->updateOrCreate( $request->user()->buyerProfile()->updateOrCreate(
['user_id' => $request->user()->id], ['user_id' => $request->user()->id],
@@ -94,4 +105,21 @@ class BuyerProfileController extends Controller
'profile' => $buyer->load('user', 'attachments', 'ratings.user'), 'profile' => $buyer->load('user', 'attachments', 'ratings.user'),
]); ]);
} }
private function linesToRecords(string $text, array $keys): array
{
return collect(preg_split('/\r\n|\r|\n/', $text))
->map(fn (string $line): array => array_pad(array_map('trim', explode('|', $line, count($keys))), count($keys), null))
->filter(fn (array $parts): bool => (bool) ($parts[0] ?? null))
->map(fn (array $parts): array => collect($keys)->mapWithKeys(fn (string $key, int $index): array => [$key => $parts[$index] ?? null])->all())
->values()
->all();
}
private function normalizeVisibility(Request $request, array $fields): array
{
return collect($fields)
->mapWithKeys(fn (string $field): array => [$field => $request->boolean("profile_visibility.$field")])
->all();
}
} }
@@ -71,8 +71,11 @@ class ProviderProfileController extends Controller
'bio' => ['nullable', 'string'], 'bio' => ['nullable', 'string'],
'service_area' => ['nullable', 'string', 'max:255'], 'service_area' => ['nullable', 'string', 'max:255'],
'skills' => ['nullable', 'string'], 'skills' => ['nullable', 'string'],
'services_text' => ['nullable', 'string'],
'tools' => ['nullable', 'string'], 'tools' => ['nullable', 'string'],
'tool_inventory_text' => ['nullable', 'string'],
'certifications' => ['nullable', 'string'], 'certifications' => ['nullable', 'string'],
'certification_records_text' => ['nullable', 'string'],
'insurance_status' => ['nullable', 'string', 'max:255'], 'insurance_status' => ['nullable', 'string', 'max:255'],
'rate_card' => ['nullable', 'string'], 'rate_card' => ['nullable', 'string'],
'travel_policy' => ['nullable', 'string'], 'travel_policy' => ['nullable', 'string'],
@@ -80,9 +83,19 @@ class ProviderProfileController extends Controller
'website_url' => ['nullable', 'url', 'max:255'], 'website_url' => ['nullable', 'url', 'max:255'],
'phone' => ['nullable', 'string', 'max:255'], 'phone' => ['nullable', 'string', 'max:255'],
'public_contact' => ['nullable', 'boolean'], 'public_contact' => ['nullable', 'boolean'],
'profile_visibility' => ['nullable', 'array'],
'profile_visibility.*' => ['nullable', 'boolean'],
'private_notes' => ['nullable', 'string'],
]); ]);
$data['public_contact'] = $request->boolean('public_contact'); $data['public_contact'] = $request->boolean('public_contact');
$data['services'] = $this->linesToRecords($data['services_text'] ?? '', ['name', 'level']);
$data['tool_inventory'] = $this->linesToRecords($data['tool_inventory_text'] ?? '', ['name', 'category']);
$data['certification_records'] = $this->linesToRecords($data['certification_records_text'] ?? '', ['name', 'issuer']);
$data['profile_visibility'] = $this->normalizeVisibility($request, [
'bio', 'services', 'tools', 'certifications', 'rate_card', 'availability', 'imports',
]);
unset($data['services_text'], $data['tool_inventory_text'], $data['certification_records_text']);
$request->user()->providerProfile()->updateOrCreate( $request->user()->providerProfile()->updateOrCreate(
['user_id' => $request->user()->id], ['user_id' => $request->user()->id],
@@ -98,4 +111,21 @@ class ProviderProfileController extends Controller
'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'), 'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'),
]); ]);
} }
private function linesToRecords(string $text, array $keys): array
{
return collect(preg_split('/\r\n|\r|\n/', $text))
->map(fn (string $line): array => array_pad(array_map('trim', explode('|', $line, count($keys))), count($keys), null))
->filter(fn (array $parts): bool => (bool) ($parts[0] ?? null))
->map(fn (array $parts): array => collect($keys)->mapWithKeys(fn (string $key, int $index): array => [$key => $parts[$index] ?? null])->all())
->values()
->all();
}
private function normalizeVisibility(Request $request, array $fields): array
{
return collect($fields)
->mapWithKeys(fn (string $field): array => [$field => $request->boolean("profile_visibility.$field")])
->all();
}
} }
+8
View File
@@ -121,6 +121,14 @@ class QuoteController extends Controller
'status' => 'assigned', 'status' => 'assigned',
'agreed_terms' => trim(($quote->rate_summary ?? '')."\n\n".($quote->terms ?? '')), 'agreed_terms' => trim(($quote->rate_summary ?? '')."\n\n".($quote->terms ?? '')),
'deliverables_checklist' => $quote->jobPost->deliverables, 'deliverables_checklist' => $quote->jobPost->deliverables,
'scheduled_at' => $quote->jobPost->starts_at,
'appointment_window' => $quote->jobPost->time_window,
'checklist_items' => collect(preg_split('/\r\n|\r|\n/', (string) $quote->jobPost->deliverables))
->map(fn (string $item): string => trim($item, " \t\n\r\0\x0B-[]"))
->filter()
->values()
->all(),
'required_evidence' => $quote->jobPost->deliverables,
'status_history' => [[ 'status_history' => [[
'status' => 'assigned', 'status' => 'assigned',
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
@@ -6,6 +6,7 @@ use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification; use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\View\View; use Illuminate\View\View;
class WorkOrderController extends Controller class WorkOrderController extends Controller
@@ -43,6 +44,21 @@ class WorkOrderController extends Controller
]); ]);
} }
public function print(Request $request, WorkOrder $workOrder): View
{
$this->authorizeParticipant($request, $workOrder);
return view('work-orders.print', [
'workOrder' => $workOrder->load(
'jobPost',
'buyer.buyerProfile',
'provider.providerProfile',
'acceptedQuote',
'attachments',
),
]);
}
public function transition(Request $request, WorkOrder $workOrder): RedirectResponse public function transition(Request $request, WorkOrder $workOrder): RedirectResponse
{ {
$this->authorizeParticipant($request, $workOrder); $this->authorizeParticipant($request, $workOrder);
@@ -50,6 +66,8 @@ class WorkOrderController extends Controller
$data = $request->validate([ $data = $request->validate([
'status' => ['required', 'string', 'in:'.implode(',', WorkOrder::STATUSES)], 'status' => ['required', 'string', 'in:'.implode(',', WorkOrder::STATUSES)],
'completion_notes' => ['nullable', 'string'], 'completion_notes' => ['nullable', 'string'],
'checklist_completed' => ['nullable', 'array'],
'checklist_completed.*' => ['nullable', 'boolean'],
]); ]);
$status = $data['status']; $status = $data['status'];
@@ -76,6 +94,10 @@ class WorkOrderController extends Controller
'status' => $status, 'status' => $status,
'status_history' => $history, 'status_history' => $history,
'completion_notes' => $data['completion_notes'] ?? $workOrder->completion_notes, 'completion_notes' => $data['completion_notes'] ?? $workOrder->completion_notes,
'checklist_completed' => $this->normalizeChecklistCompletion(
$workOrder,
$data['checklist_completed'] ?? $workOrder->checklistCompleted()
),
]; ];
if (isset($timestamps[$status]) && ! $workOrder->{$timestamps[$status]}) { if (isset($timestamps[$status]) && ! $workOrder->{$timestamps[$status]}) {
@@ -98,6 +120,76 @@ class WorkOrderController extends Controller
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order updated.'); return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order updated.');
} }
public function updateDetails(Request $request, WorkOrder $workOrder): RedirectResponse
{
$this->authorizeParticipant($request, $workOrder);
abort_unless($request->user()->id === $workOrder->buyer_id || $request->user()->hasRole('admin'), 403);
$data = $request->validate([
'scheduled_at' => ['nullable', 'date'],
'appointment_window' => ['nullable', 'string', 'max:255'],
'agreed_terms' => ['nullable', 'string'],
'deliverables_checklist' => ['nullable', 'string'],
'required_evidence' => ['nullable', 'string'],
'evidence_rules' => ['nullable', 'array'],
'evidence_rules.*' => ['nullable', 'string', 'max:255'],
]);
$checklistItems = collect(preg_split('/\r\n|\r|\n/', (string) ($data['deliverables_checklist'] ?? '')))
->map(fn (string $item): string => trim($item, " \t\n\r\0\x0B-[]"))
->filter()
->values()
->all();
$workOrder->update([
'scheduled_at' => $data['scheduled_at'] ?? null,
'appointment_window' => $data['appointment_window'] ?? null,
'agreed_terms' => $data['agreed_terms'] ?? null,
'deliverables_checklist' => $data['deliverables_checklist'] ?? null,
'checklist_items' => $checklistItems,
'checklist_completed' => $this->normalizeChecklistCompletion($workOrder, $workOrder->checklistCompleted(), $checklistItems),
'required_evidence' => $data['required_evidence'] ?? null,
'evidence_rules' => array_values(array_filter($data['evidence_rules'] ?? [])),
]);
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order details updated.');
}
public function requestChange(Request $request, WorkOrder $workOrder): RedirectResponse
{
$this->authorizeParticipant($request, $workOrder);
$data = $request->validate([
'summary' => ['required', 'string', 'max:255'],
'details' => ['nullable', 'string'],
]);
$requests = $workOrder->changeRequests();
$requests[] = [
'summary' => $data['summary'],
'details' => $data['details'] ?? null,
'requested_by_id' => $request->user()->id,
'requested_by_name' => $request->user()->name,
'status' => 'open',
'requested_at' => now()->toIso8601String(),
];
$workOrder->update(['change_requests' => $requests]);
$recipient = $request->user()->id === $workOrder->buyer_id
? $workOrder->provider
: $workOrder->buyer;
$recipient->notify(new ExchangeEventNotification(
'Work order change requested',
$request->user()->name.' requested a change on '.$workOrder->jobPost->title.'.',
route('work-orders.show', $workOrder),
'work_order_change_request'
));
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request recorded.');
}
private function authorizeParticipant(Request $request, WorkOrder $workOrder): void private function authorizeParticipant(Request $request, WorkOrder $workOrder): void
{ {
abort_unless( abort_unless(
@@ -123,4 +215,13 @@ class WorkOrderController extends Controller
abort_unless(in_array($userId, [$workOrder->buyer_id, $workOrder->provider_id], true) || $request->user()->hasRole('admin'), 403); abort_unless(in_array($userId, [$workOrder->buyer_id, $workOrder->provider_id], true) || $request->user()->hasRole('admin'), 403);
} }
} }
private function normalizeChecklistCompletion(WorkOrder $workOrder, array $completed, ?array $items = null): array
{
$items ??= $workOrder->checklistItems();
return collect($items)
->mapWithKeys(fn (string $item): array => [$item => (bool) Arr::get($completed, $item, false)])
->all();
}
} }
+20 -2
View File
@@ -9,11 +9,22 @@ use Illuminate\Database\Eloquent\Model;
#[Fillable([ #[Fillable([
'user_id', 'company_name', 'headline', 'description', 'service_categories', 'user_id', 'company_name', 'headline', 'description', 'service_categories',
'hiring_regions', 'vendor_onboarding', 'payment_terms', 'website_url', 'hiring_regions', 'hiring_policies', 'locations', 'vendor_onboarding',
'contact_email', 'public_contact', 'payment_terms', 'website_url', 'contact_email', 'public_contact',
'profile_visibility', 'private_notes',
])] ])]
class BuyerProfile extends Model class BuyerProfile extends Model
{ {
protected function casts(): array
{
return [
'hiring_policies' => 'array',
'locations' => 'array',
'profile_visibility' => 'array',
'public_contact' => 'boolean',
];
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
@@ -28,4 +39,11 @@ class BuyerProfile extends Model
{ {
return $this->morphMany(Rating::class, 'rateable'); return $this->morphMany(Rating::class, 'rateable');
} }
public function visible(string $field): bool
{
$visibility = $this->profile_visibility ?? [];
return (bool) ($visibility[$field] ?? true);
}
} }
+21 -2
View File
@@ -10,11 +10,23 @@ use Illuminate\Database\Eloquent\Model;
#[Fillable([ #[Fillable([
'user_id', 'business_name', 'headline', 'bio', 'service_area', 'skills', 'user_id', 'business_name', 'headline', 'bio', 'service_area', 'skills',
'tools', 'certifications', 'insurance_status', 'rate_card', 'travel_policy', 'services', 'tools', 'tool_inventory', 'certifications', 'certification_records',
'availability_notes', 'website_url', 'phone', 'public_contact', 'insurance_status', 'rate_card', 'travel_policy', 'availability_notes',
'website_url', 'phone', 'public_contact', 'profile_visibility', 'private_notes',
])] ])]
class ProviderProfile extends Model class ProviderProfile extends Model
{ {
protected function casts(): array
{
return [
'services' => 'array',
'tool_inventory' => 'array',
'certification_records' => 'array',
'profile_visibility' => 'array',
'public_contact' => 'boolean',
];
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
@@ -34,4 +46,11 @@ class ProviderProfile extends Model
{ {
return $this->morphMany(Rating::class, 'rateable'); return $this->morphMany(Rating::class, 'rateable');
} }
public function visible(string $field): bool
{
$visibility = $this->profile_visibility ?? [];
return (bool) ($visibility[$field] ?? true);
}
} }
+43 -1
View File
@@ -10,7 +10,9 @@ use Illuminate\Database\Eloquent\Model;
#[Fillable([ #[Fillable([
'job_post_id', 'buyer_id', 'provider_id', 'accepted_quote_id', 'status', 'job_post_id', 'buyer_id', 'provider_id', 'accepted_quote_id', 'status',
'status_history', 'agreed_terms', 'deliverables_checklist', 'completion_notes', 'status_history', 'agreed_terms', 'deliverables_checklist', 'scheduled_at',
'appointment_window', 'checklist_items', 'checklist_completed', 'required_evidence',
'evidence_rules', 'change_requests', 'completion_notes',
'en_route_at', 'on_site_at', 'started_at', 'completed_at', 'approved_at', 'closed_at', 'en_route_at', 'on_site_at', 'started_at', 'completed_at', 'approved_at', 'closed_at',
])] ])]
class WorkOrder extends Model class WorkOrder extends Model
@@ -36,6 +38,11 @@ class WorkOrder extends Model
{ {
return [ return [
'status_history' => 'array', 'status_history' => 'array',
'scheduled_at' => 'datetime',
'checklist_items' => 'array',
'checklist_completed' => 'array',
'evidence_rules' => 'array',
'change_requests' => 'array',
'en_route_at' => 'datetime', 'en_route_at' => 'datetime',
'on_site_at' => 'datetime', 'on_site_at' => 'datetime',
'started_at' => 'datetime', 'started_at' => 'datetime',
@@ -94,4 +101,39 @@ class WorkOrder extends Model
{ {
return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
} }
public function checklistItems(): array
{
if (is_array($this->checklist_items) && $this->checklist_items !== []) {
return array_values(array_filter($this->checklist_items));
}
return collect(preg_split('/\r\n|\r|\n/', (string) $this->deliverables_checklist))
->map(fn (string $item): string => trim($item, " \t\n\r\0\x0B-[]"))
->filter()
->values()
->all();
}
public function checklistCompleted(): array
{
return is_array($this->checklist_completed) ? $this->checklist_completed : [];
}
public function checklistProgress(): array
{
$items = $this->checklistItems();
$completed = $this->checklistCompleted();
$done = collect($items)->filter(fn (string $item): bool => (bool) ($completed[$item] ?? false))->count();
return [
'done' => $done,
'total' => count($items),
];
}
public function changeRequests(): array
{
return is_array($this->change_requests) ? array_values($this->change_requests) : [];
}
} }
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('work_orders', function (Blueprint $table) {
$table->timestamp('scheduled_at')->nullable()->after('deliverables_checklist');
$table->string('appointment_window')->nullable()->after('scheduled_at');
$table->json('checklist_items')->nullable()->after('appointment_window');
$table->json('checklist_completed')->nullable()->after('checklist_items');
$table->text('required_evidence')->nullable()->after('checklist_completed');
$table->json('evidence_rules')->nullable()->after('required_evidence');
$table->json('change_requests')->nullable()->after('evidence_rules');
});
}
public function down(): void
{
Schema::table('work_orders', function (Blueprint $table) {
$table->dropColumn([
'scheduled_at',
'appointment_window',
'checklist_items',
'checklist_completed',
'required_evidence',
'evidence_rules',
'change_requests',
]);
});
}
};
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('provider_profiles', function (Blueprint $table) {
$table->json('services')->nullable()->after('skills');
$table->json('tool_inventory')->nullable()->after('tools');
$table->json('certification_records')->nullable()->after('certifications');
$table->json('profile_visibility')->nullable()->after('public_contact');
$table->text('private_notes')->nullable()->after('profile_visibility');
});
Schema::table('buyer_profiles', function (Blueprint $table) {
$table->json('hiring_policies')->nullable()->after('hiring_regions');
$table->json('locations')->nullable()->after('hiring_policies');
$table->json('profile_visibility')->nullable()->after('public_contact');
$table->text('private_notes')->nullable()->after('profile_visibility');
});
}
public function down(): void
{
Schema::table('provider_profiles', function (Blueprint $table) {
$table->dropColumn([
'services',
'tool_inventory',
'certification_records',
'profile_visibility',
'private_notes',
]);
});
Schema::table('buyer_profiles', function (Blueprint $table) {
$table->dropColumn([
'hiring_policies',
'locations',
'profile_visibility',
'private_notes',
]);
});
}
};
+42
View File
@@ -61,12 +61,34 @@ class DatabaseSeeder extends Seeder
'bio' => 'Demo provider profile for the technician-owned exchange.', 'bio' => 'Demo provider profile for the technician-owned exchange.',
'service_area' => 'Central US with regional travel', 'service_area' => 'Central US with regional travel',
'skills' => "POS\nNetworking\nStructured cabling\nPrinter support", 'skills' => "POS\nNetworking\nStructured cabling\nPrinter support",
'services' => [
['name' => 'Smart hands dispatch', 'level' => 'experienced'],
['name' => 'Network troubleshooting', 'level' => 'advanced'],
['name' => 'POS installation', 'level' => 'experienced'],
],
'tools' => "Laptop\nToner/probe\nCable tester\nBasic hand tools", 'tools' => "Laptop\nToner/probe\nCable tester\nBasic hand tools",
'tool_inventory' => [
['name' => 'Laptop', 'category' => 'diagnostics'],
['name' => 'Toner/probe', 'category' => 'cabling'],
['name' => 'Cable tester', 'category' => 'network'],
],
'certifications' => 'Low-voltage and platform-specific certifications can be listed here.', 'certifications' => 'Low-voltage and platform-specific certifications can be listed here.',
'certification_records' => [
['name' => 'Low-voltage certification placeholder', 'issuer' => 'State or authority'],
],
'insurance_status' => 'Provider supplied', 'insurance_status' => 'Provider supplied',
'rate_card' => "Two-hour minimum\nStandard onsite hourly rate set by provider\nTravel negotiated per job", '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.', 'travel_policy' => 'Travel terms are negotiated directly with buyers.',
'availability_notes' => 'Weekdays and scheduled after-hours work.', 'availability_notes' => 'Weekdays and scheduled after-hours work.',
'profile_visibility' => [
'bio' => true,
'services' => true,
'tools' => true,
'certifications' => true,
'rate_card' => true,
'availability' => true,
'imports' => true,
],
] ]
); );
@@ -87,8 +109,23 @@ class DatabaseSeeder extends Seeder
'description' => 'Demo buyer profile showing onboarding and payment terms.', 'description' => 'Demo buyer profile showing onboarding and payment terms.',
'service_categories' => "Networking\nPOS\nPrinter support", 'service_categories' => "Networking\nPOS\nPrinter support",
'hiring_regions' => 'Nationwide', 'hiring_regions' => 'Nationwide',
'hiring_policies' => [
['name' => 'Direct vendor onboarding', 'summary' => 'Buyer handles vendor setup outside the platform.'],
['name' => 'Clear scope required', 'summary' => 'Work orders should include deliverables and evidence requirements.'],
],
'locations' => [
['name' => 'National dispatch', 'region' => 'United States'],
],
'vendor_onboarding' => 'Direct vendor onboarding outside the platform.', 'vendor_onboarding' => 'Direct vendor onboarding outside the platform.',
'payment_terms' => 'Direct payment between buyer and provider.', 'payment_terms' => 'Direct payment between buyer and provider.',
'profile_visibility' => [
'description' => true,
'categories' => true,
'locations' => true,
'policies' => true,
'payment_terms' => true,
'onboarding' => true,
],
] ]
); );
@@ -135,6 +172,11 @@ class DatabaseSeeder extends Seeder
'status' => 'assigned', 'status' => 'assigned',
'agreed_terms' => $quote->terms, 'agreed_terms' => $quote->terms,
'deliverables_checklist' => $job->deliverables, 'deliverables_checklist' => $job->deliverables,
'scheduled_at' => $job->starts_at,
'appointment_window' => $job->time_window,
'checklist_items' => ['Check in', 'Photos', 'Resolution notes'],
'required_evidence' => $job->deliverables,
'evidence_rules' => ['Arrival photo', 'Completion photo'],
'status_history' => [['status' => 'assigned', 'user_id' => $buyer->id, 'at' => now()->toIso8601String()]], 'status_history' => [['status' => 'assigned', 'user_id' => $buyer->id, 'at' => now()->toIso8601String()]],
] ]
); );
+3 -1
View File
@@ -34,7 +34,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service
## Next Build Phases ## Next Build Phases
1. Profile depth: richer galleries, equipment lists, certifications, insurance fields, service coverage maps, and imported review summaries. 1. Profile depth: richer provider galleries for vans, tools, job photos, certifications, insurance, and past work; buyer galleries for company identity, logos, headers, locations, and example work; equipment/tool pickers with reusable tags; certification upload/proof records with starter certification lists; service coverage maps; and imported review summaries.
2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages. 2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages.
3. Asset and storage management: configurable upload roots, file policy, attachment previews, deletion, and update-path storage link handling. 3. Asset and storage management: configurable upload roots, file policy, attachment previews, deletion, and update-path storage link handling.
4. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets. 4. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets.
@@ -70,3 +70,5 @@ Provider Exchange is a Laravel application for a provider-centered field-service
- Marketplace-wide dispute binding beyond voluntary community reputation. - Marketplace-wide dispute binding beyond voluntary community reputation.
- Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear. - Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear.
- Automated competency promotion rules beyond transparent, reviewable tag and level signals. - Automated competency promotion rules beyond transparent, reviewable tag and level signals.
- Work-order template libraries for buyers and providers, including reusable sample work orders that can seed future jobs.
- Broader licensed trade profiles, such as electricians, where state certification proof and trade-specific scopes can be added without hard-coding the platform around only IT technicians.
+42 -7
View File
@@ -1,23 +1,58 @@
@php
$policiesText = collect($profile?->hiring_policies ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['summary'] ?? ''), ' |'))->implode("\n");
$locationsText = collect($profile?->locations ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['region'] ?? ''), ' |'))->implode("\n");
$visibility = $profile?->profile_visibility ?? [];
$visible = fn (string $field): bool => (bool) ($visibility[$field] ?? true);
@endphp
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Buyer Profile</h2></x-slot> <x-slot name="header">
<div class="mx-auto max-w-5xl p-6"> <x-page-header
<form method="POST" action="{{ route('buyers.update') }}" class="space-y-4 rounded border bg-white p-6"> title="Buyer Profile"
@csrf @method('PUT') description="Build a public company profile with hiring regions, onboarding policies, locations, and sample identity files."
/>
</x-slot>
<div class="mx-auto max-w-5xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<form method="POST" action="{{ route('buyers.update') }}" class="tse-panel space-y-4 p-6">
@csrf
@method('PUT')
<x-field name="company_name" label="Company name" :value="$profile?->company_name" /> <x-field name="company_name" label="Company name" :value="$profile?->company_name" />
<x-field name="headline" label="Headline" :value="$profile?->headline" /> <x-field name="headline" label="Headline" :value="$profile?->headline" />
<x-field name="description" label="Description" :value="$profile?->description" textarea /> <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="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="hiring_regions" label="Hiring regions" :value="$profile?->hiring_regions" textarea />
<x-field name="hiring_policies_text" label="Structured hiring policies" :value="$policiesText" textarea help="One per line. Use: policy name | summary, for example Net 15 ACH | Direct payment after approval." />
<x-field name="locations_text" label="Locations" :value="$locationsText" textarea help="One per line. Use: location name | region, for example Tulsa dispatch | Oklahoma." />
<x-field name="vendor_onboarding" label="Vendor onboarding" :value="$profile?->vendor_onboarding" 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="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="website_url" label="Website URL" :value="$profile?->website_url" />
<x-field name="contact_email" label="Contact email" :value="$profile?->contact_email" /> <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>
<div class="rounded-md border border-slate-200 p-4 dark:border-slate-800">
<h3 class="font-semibold text-slate-950 dark:text-white">Public sections</h3>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Unchecked sections remain private to the buyer profile owner.</p>
<div class="mt-3 grid gap-3 md:grid-cols-3">
@foreach (['description' => 'Description', 'categories' => 'Categories', 'locations' => 'Locations', 'policies' => 'Hiring policies', 'payment_terms' => 'Payment terms', 'onboarding' => 'Onboarding'] as $key => $label)
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="profile_visibility[{{ $key }}]" value="1" @checked($visible($key)) class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
{{ $label }}
</label>
@endforeach
</div>
</div>
<label class="flex gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="public_contact" value="1" @checked($profile?->public_contact) class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
Show contact details publicly
</label>
<x-field name="private_notes" label="Private notes" :value="$profile?->private_notes" textarea help="Internal notes for your company account. These are not shown publicly." />
<x-primary-button>Save buyer profile</x-primary-button> <x-primary-button>Save buyer profile</x-primary-button>
</form> </form>
@if ($profile) @if ($profile)
<section class="mt-6 rounded border bg-white p-6"> <section class="tse-panel p-6">
<h3 class="font-semibold">Company photos and files</h3> <h3 class="font-semibold text-slate-950 dark:text-white">Company identity, logos, location photos, and sample files</h3>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Use captions to distinguish logo, header, office, site, or example work-order files.</p>
<x-attachments :attachments="$profile->attachments" /> <x-attachments :attachments="$profile->attachments" />
<x-attachment-form type="buyer_profile" :id="$profile->id" kind="profile" /> <x-attachment-form type="buyer_profile" :id="$profile->id" kind="profile" />
</section> </section>
+62 -8
View File
@@ -1,16 +1,70 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">{{ $profile->company_name }}</h2></x-slot> <x-slot name="header">
<div class="mx-auto max-w-5xl rounded border bg-white p-6"> <x-page-header
<p class="text-lg font-semibold">{{ $profile->headline }}</p> title="{{ $profile->company_name }}"
<p class="mt-4 whitespace-pre-line">{{ $profile->description }}</p> :description="$profile->headline"
/>
</x-slot>
<div class="mx-auto max-w-5xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<section class="tse-panel p-6">
<div class="flex flex-wrap gap-2">
@if ($profile->public_contact)
<x-badge tone="emerald">Public contact</x-badge>
@endif
@if ($profile->hiring_regions)
<x-badge tone="slate">{{ $profile->hiring_regions }}</x-badge>
@endif
</div>
@if ($profile->visible('description'))
<p class="mt-4 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->description }}</p>
@endif
<dl class="mt-6 grid gap-4 md:grid-cols-2"> <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> @if ($profile->visible('categories'))
<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-slate-500 dark:text-slate-400">Service categories</dt><dd class="whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->service_categories }}</dd></div>
<div><dt class="text-sm text-gray-500">Vendor onboarding</dt><dd class="whitespace-pre-line">{{ $profile->vendor_onboarding }}</dd></div> @endif
<div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $profile->payment_terms }}</dd></div> @if ($profile->visible('locations'))
<div>
<dt class="text-sm text-slate-500 dark:text-slate-400">Locations</dt>
<dd class="mt-2 space-y-2">
@forelse ($profile->locations ?? [] as $location)
<x-badge tone="slate">{{ $location['name'] }}{{ ! empty($location['region']) ? ' | '.$location['region'] : '' }}</x-badge>
@empty
<span class="whitespace-pre-line text-sm text-slate-600 dark:text-slate-400">{{ $profile->hiring_regions }}</span>
@endforelse
</dd>
</div>
@endif
@if ($profile->visible('policies'))
<div>
<dt class="text-sm text-slate-500 dark:text-slate-400">Hiring policies</dt>
<dd class="mt-2 space-y-2">
@forelse ($profile->hiring_policies ?? [] as $policy)
<div class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
<p class="font-semibold text-slate-950 dark:text-white">{{ $policy['name'] }}</p>
@if (! empty($policy['summary']))
<p class="mt-1 text-slate-600 dark:text-slate-400">{{ $policy['summary'] }}</p>
@endif
</div>
@empty
<span class="text-sm text-slate-600 dark:text-slate-400">No structured policies listed.</span>
@endforelse
</dd>
</div>
@endif
@if ($profile->visible('onboarding'))
<div><dt class="text-sm text-slate-500 dark:text-slate-400">Vendor onboarding</dt><dd class="whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->vendor_onboarding }}</dd></div>
@endif
@if ($profile->visible('payment_terms'))
<div><dt class="text-sm text-slate-500 dark:text-slate-400">Payment terms</dt><dd class="whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->payment_terms }}</dd></div>
@endif
</dl> </dl>
<x-attachments :attachments="$profile->attachments" /> <x-attachments :attachments="$profile->attachments" />
<x-rating-summary :ratings="$profile->ratings" /> <x-rating-summary :ratings="$profile->ratings" />
<x-rating-form type="buyer_profile" :id="$profile->id" category="buyer_overall" /> <x-rating-form type="buyer_profile" :id="$profile->id" category="buyer_overall" />
</section>
</div> </div>
</x-app-layout> </x-app-layout>
+49 -12
View File
@@ -1,33 +1,70 @@
@php
$servicesText = collect($profile?->services ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['level'] ?? ''), ' |'))->implode("\n");
$toolsText = collect($profile?->tool_inventory ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['category'] ?? ''), ' |'))->implode("\n");
$certsText = collect($profile?->certification_records ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['issuer'] ?? ''), ' |'))->implode("\n");
$visibility = $profile?->profile_visibility ?? [];
$visible = fn (string $field): bool => (bool) ($visibility[$field] ?? true);
@endphp
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Provider Profile</h2></x-slot> <x-slot name="header">
<div class="mx-auto max-w-5xl space-y-6 p-6"> <x-page-header
<form method="POST" action="{{ route('providers.update') }}" class="space-y-4 rounded border bg-white p-6"> title="Provider Profile"
@csrf @method('PUT') description="Build a public business profile with services, tools, proof records, galleries, and private owner notes."
/>
</x-slot>
<div class="mx-auto max-w-5xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<form method="POST" action="{{ route('providers.update') }}" class="tse-panel space-y-4 p-6">
@csrf
@method('PUT')
<x-field name="business_name" label="Business name" :value="$profile?->business_name" /> <x-field name="business_name" label="Business name" :value="$profile?->business_name" />
<x-field name="headline" label="Headline" :value="$profile?->headline" /> <x-field name="headline" label="Headline" :value="$profile?->headline" />
<x-field name="bio" label="Bio" :value="$profile?->bio" textarea /> <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="service_area" label="Service area" :value="$profile?->service_area" />
<x-field name="skills" label="Skills" :value="$profile?->skills" textarea /> <x-field name="skills" label="Skills summary" :value="$profile?->skills" textarea />
<x-field name="tools" label="Tools and equipment" :value="$profile?->tools" textarea /> <x-field name="services_text" label="Structured services" :value="$servicesText" textarea help="One per line. Use: service name | level, for example POS install | experienced." />
<x-field name="certifications" label="Certifications" :value="$profile?->certifications" textarea /> <x-field name="tools" label="Tools and equipment summary" :value="$profile?->tools" textarea />
<x-field name="tool_inventory_text" label="Tool inventory" :value="$toolsText" textarea help="One per line. Use: tool name | category, for example Cable tester | network." />
<x-field name="certifications" label="Certifications summary" :value="$profile?->certifications" textarea />
<x-field name="certification_records_text" label="Certification records" :value="$certsText" textarea help="One per line. Use: certification | issuer. Upload proof files below." />
<x-field name="insurance_status" label="Insurance status" :value="$profile?->insurance_status" /> <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="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="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="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="website_url" label="Website URL" :value="$profile?->website_url" />
<x-field name="phone" label="Phone" :value="$profile?->phone" /> <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>
<div class="rounded-md border border-slate-200 p-4 dark:border-slate-800">
<h3 class="font-semibold text-slate-950 dark:text-white">Public sections</h3>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Unchecked sections remain available to you but do not display on the public provider profile.</p>
<div class="mt-3 grid gap-3 md:grid-cols-3">
@foreach (['bio' => 'Bio', 'services' => 'Services', 'tools' => 'Tools', 'certifications' => 'Certifications', 'rate_card' => 'Rate card', 'availability' => 'Availability', 'imports' => 'Imported history'] as $key => $label)
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="profile_visibility[{{ $key }}]" value="1" @checked($visible($key)) class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
{{ $label }}
</label>
@endforeach
</div>
</div>
<label class="flex gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="public_contact" value="1" @checked($profile?->public_contact) class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
Show contact details publicly
</label>
<x-field name="private_notes" label="Private notes" :value="$profile?->private_notes" textarea help="Internal notes for your account. These are not shown publicly." />
<x-primary-button>Save provider profile</x-primary-button> <x-primary-button>Save provider profile</x-primary-button>
</form> </form>
@if ($profile) @if ($profile)
<section class="rounded border bg-white p-6"> <section class="tse-panel p-6">
<h3 class="font-semibold">Profile photos and files</h3> <h3 class="font-semibold text-slate-950 dark:text-white">Profile photos, certification proof, tools, and job files</h3>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Use captions to distinguish van photos, tool photos, certification proof, job photos, and insurance documents.</p>
<x-attachments :attachments="$profile->attachments" /> <x-attachments :attachments="$profile->attachments" />
<x-attachment-form type="provider_profile" :id="$profile->id" kind="profile" /> <x-attachment-form type="provider_profile" :id="$profile->id" kind="profile" />
</section> </section>
<form method="POST" action="{{ route('provider-imports.store') }}" class="space-y-4 rounded border bg-white p-6"> <form method="POST" action="{{ route('provider-imports.store') }}" class="tse-panel space-y-4 p-6">
@csrf @csrf
<h3 class="font-semibold">External profile snapshot</h3> <h3 class="font-semibold text-slate-950 dark:text-white">External profile snapshot</h3>
<x-field name="platform" label="Platform" value="Field Nation" /> <x-field name="platform" label="Platform" value="Field Nation" />
<x-field name="external_id" label="External ID" value="172-630" /> <x-field name="external_id" label="External ID" value="172-630" />
<x-field name="profile_url" label="Profile URL" /> <x-field name="profile_url" label="Profile URL" />
+80 -18
View File
@@ -1,32 +1,94 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">{{ $profile->business_name }}</h2></x-slot> <x-slot name="header">
<div class="mx-auto max-w-5xl space-y-6 p-6"> <x-page-header
<section class="rounded border bg-white p-6"> title="{{ $profile->business_name }}"
<p class="text-lg font-semibold">{{ $profile->headline }}</p> :description="$profile->headline"
<p class="mt-4 whitespace-pre-line">{{ $profile->bio }}</p> />
</x-slot>
<div class="mx-auto max-w-5xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<section class="tse-panel p-6">
<div class="flex flex-wrap gap-2">
@if ($profile->service_area)
<x-badge tone="slate">{{ $profile->service_area }}</x-badge>
@endif
@if ($profile->public_contact)
<x-badge tone="emerald">Public contact</x-badge>
@endif
@if ($profile->insurance_status)
<x-badge tone="sky">{{ $profile->insurance_status }}</x-badge>
@endif
</div>
@if ($profile->visible('bio'))
<p class="mt-4 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->bio }}</p>
@endif
<dl class="mt-6 grid gap-4 md:grid-cols-2"> <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> @if ($profile->visible('services'))
<div><dt class="text-sm text-gray-500">Insurance</dt><dd>{{ $profile->insurance_status }}</dd></div> <div>
<div><dt class="text-sm text-gray-500">Skills</dt><dd class="whitespace-pre-line">{{ $profile->skills }}</dd></div> <dt class="text-sm text-slate-500 dark:text-slate-400">Services</dt>
<div><dt class="text-sm text-gray-500">Tools</dt><dd class="whitespace-pre-line">{{ $profile->tools }}</dd></div> <dd class="mt-2 space-y-2">
<div><dt class="text-sm text-gray-500">Rate card</dt><dd class="whitespace-pre-line">{{ $profile->rate_card }}</dd></div> @forelse ($profile->services ?? [] as $service)
<div><dt class="text-sm text-gray-500">Travel policy</dt><dd class="whitespace-pre-line">{{ $profile->travel_policy }}</dd></div> <x-badge tone="sky">{{ $service['name'] }}{{ ! empty($service['level']) ? ' | '.$service['level'] : '' }}</x-badge>
@empty
<span class="text-sm text-slate-600 dark:text-slate-400">{{ $profile->skills }}</span>
@endforelse
</dd>
</div>
@endif
@if ($profile->visible('tools'))
<div>
<dt class="text-sm text-slate-500 dark:text-slate-400">Tools</dt>
<dd class="mt-2 space-y-2">
@forelse ($profile->tool_inventory ?? [] as $tool)
<x-badge tone="slate">{{ $tool['name'] }}{{ ! empty($tool['category']) ? ' | '.$tool['category'] : '' }}</x-badge>
@empty
<span class="whitespace-pre-line text-sm text-slate-600 dark:text-slate-400">{{ $profile->tools }}</span>
@endforelse
</dd>
</div>
@endif
@if ($profile->visible('certifications'))
<div>
<dt class="text-sm text-slate-500 dark:text-slate-400">Certifications</dt>
<dd class="mt-2 space-y-2">
@forelse ($profile->certification_records ?? [] as $certification)
<x-badge tone="amber">{{ $certification['name'] }}{{ ! empty($certification['issuer']) ? ' | '.$certification['issuer'] : '' }}</x-badge>
@empty
<span class="whitespace-pre-line text-sm text-slate-600 dark:text-slate-400">{{ $profile->certifications }}</span>
@endforelse
</dd>
</div>
@endif
@if ($profile->visible('rate_card'))
<div><dt class="text-sm text-slate-500 dark:text-slate-400">Rate card</dt><dd class="whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->rate_card }}</dd></div>
@endif
@if ($profile->visible('availability'))
<div><dt class="text-sm text-slate-500 dark:text-slate-400">Travel policy</dt><dd class="whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->travel_policy }}</dd></div>
<div><dt class="text-sm text-slate-500 dark:text-slate-400">Availability</dt><dd class="whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $profile->availability_notes }}</dd></div>
@endif
</dl> </dl>
<x-attachments :attachments="$profile->attachments" /> <x-attachments :attachments="$profile->attachments" />
<x-rating-summary :ratings="$profile->ratings" /> <x-rating-summary :ratings="$profile->ratings" />
<x-rating-form type="provider_profile" :id="$profile->id" category="provider_overall" /> <x-rating-form type="provider_profile" :id="$profile->id" category="provider_overall" />
</section> </section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">External profile history</h3> @if ($profile->visible('imports'))
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Imported profile and review history</h3>
@forelse ($profile->externalImports as $import) @forelse ($profile->externalImports as $import)
<div class="mt-4 rounded border p-4"> <div class="mt-4 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<p class="font-semibold">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</p> <p class="font-semibold text-slate-950 dark:text-white">{{ $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="text-sm text-slate-600 dark:text-slate-400">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> <p class="mt-2 whitespace-pre-line text-sm text-slate-700 dark:text-slate-300">{{ $import->notes }}</p>
<x-attachments :attachments="$import->attachments" />
</div> </div>
@empty @empty
<p class="mt-2 text-sm text-gray-600">No external profile snapshots yet.</p> <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">No external profile snapshots yet.</p>
@endforelse @endforelse
</section> </section>
@endif
</div> </div>
</x-app-layout> </x-app-layout>
+37 -8
View File
@@ -1,13 +1,42 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Work Orders</h2></x-slot> <x-slot name="header">
<div class="mx-auto max-w-7xl space-y-4 p-6"> <x-page-header
@foreach ($orders as $order) title="Work Orders"
<a href="{{ route('work-orders.show', $order) }}" class="block rounded border bg-white p-4 shadow-sm"> description="Accepted quotes become work orders with status tracking, checklists, evidence, reviews, and peer review."
<h3 class="font-semibold">{{ $order->jobPost->title }}</h3> />
<p class="text-sm text-gray-600">{{ $order->status }} | Buyer: {{ $order->buyer->name }} | Provider: {{ $order->provider->name }}</p> </x-slot>
<x-rating-summary :ratings="$order->ratings" />
<div class="mx-auto max-w-7xl space-y-4 px-4 py-8 sm:px-6 lg:px-8">
@forelse ($orders as $order)
@php($progress = $order->checklistProgress())
<a href="{{ route('work-orders.show', $order) }}" class="tse-panel block p-5 transition hover:border-sky-300 hover:shadow-md dark:hover:border-sky-700">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="font-semibold text-slate-950 dark:text-white">{{ $order->jobPost->title }}</h3>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
Buyer: {{ $order->buyer->buyerProfile?->company_name ?? $order->buyer->name }}
| Provider: {{ $order->provider->providerProfile?->business_name ?? $order->provider->name }}
</p>
</div>
<div class="flex flex-wrap gap-2 sm:justify-end">
<x-badge tone="sky">{{ str_replace('_', ' ', $order->status) }}</x-badge>
<x-badge tone="slate">Checklist {{ $progress['done'] }}/{{ $progress['total'] }}</x-badge>
</div>
</div>
<p class="mt-3 text-sm text-slate-600 dark:text-slate-400">
{{ $order->scheduled_at?->format('M j, Y g:i A') ?? 'No scheduled time' }}
@if ($order->appointment_window)
| {{ $order->appointment_window }}
@endif
</p>
</a> </a>
@endforeach @empty
<div class="tse-panel p-8 text-center">
<h3 class="text-base font-semibold text-slate-950 dark:text-white">No work orders yet.</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Accepted quotes will appear here.</p>
</div>
@endforelse
{{ $orders->links() }} {{ $orders->links() }}
</div> </div>
</x-app-layout> </x-app-layout>
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Order {{ $workOrder->id }} - {{ $workOrder->jobPost->title }}</title>
<style>
body { font-family: Arial, sans-serif; color: #111827; margin: 32px; }
h1, h2 { margin-bottom: 8px; }
section { border-top: 1px solid #d1d5db; padding-top: 16px; margin-top: 20px; }
.muted { color: #4b5563; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.box { border: 1px solid #d1d5db; padding: 12px; border-radius: 6px; }
ul { padding-left: 20px; }
@media print {
body { margin: 18mm; }
button { display: none; }
}
</style>
</head>
<body>
<button onclick="window.print()">Print</button>
<h1>{{ $workOrder->jobPost->title }}</h1>
<p class="muted">Work Order #{{ $workOrder->id }} | {{ str_replace('_', ' ', $workOrder->status) }}</p>
<section class="grid">
<div class="box">
<h2>Buyer</h2>
<p>{{ $workOrder->buyer->buyerProfile?->company_name ?? $workOrder->buyer->name }}</p>
</div>
<div class="box">
<h2>Provider</h2>
<p>{{ $workOrder->provider->providerProfile?->business_name ?? $workOrder->provider->name }}</p>
</div>
</section>
<section>
<h2>Schedule</h2>
<p>{{ $workOrder->scheduled_at?->format('M j, Y g:i A') ?? 'Not set' }} | {{ $workOrder->appointment_window ?: 'No window set' }}</p>
</section>
<section>
<h2>Agreed Terms</h2>
<p>{!! nl2br(e($workOrder->agreed_terms ?: 'No agreed terms recorded.')) !!}</p>
</section>
<section>
<h2>Checklist</h2>
<ul>
@forelse ($workOrder->checklistItems() as $item)
<li>{{ (bool) ($workOrder->checklistCompleted()[$item] ?? false) ? '[x]' : '[ ]' }} {{ $item }}</li>
@empty
<li>No checklist items recorded.</li>
@endforelse
</ul>
</section>
<section>
<h2>Required Evidence</h2>
<p>{!! nl2br(e($workOrder->required_evidence ?: 'No required evidence recorded.')) !!}</p>
@if (is_array($workOrder->evidence_rules) && $workOrder->evidence_rules)
<ul>
@foreach ($workOrder->evidence_rules as $rule)
<li>{{ $rule }}</li>
@endforeach
</ul>
@endif
</section>
<section>
<h2>Attachments</h2>
<ul>
@forelse ($workOrder->attachments as $attachment)
<li>{{ $attachment->original_name }} | {{ $attachment->kind }} | {{ $attachment->formattedSize() }}</li>
@empty
<li>No attachments recorded.</li>
@endforelse
</ul>
</section>
</body>
</html>
+168 -32
View File
@@ -1,64 +1,200 @@
@php
$progress = $workOrder->checklistProgress();
$completed = $workOrder->checklistCompleted();
$evidenceRules = is_array($workOrder->evidence_rules) ? $workOrder->evidence_rules : [];
@endphp
<x-app-layout> <x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Work Order: {{ $workOrder->jobPost->title }}</h2></x-slot> <x-slot name="header">
<div class="mx-auto max-w-6xl space-y-6 p-6"> <x-page-header
<section class="rounded border bg-white p-6"> title="Work Order: {{ $workOrder->jobPost->title }}"
<p class="text-sm text-gray-500">Status: {{ $workOrder->status }}</p> description="Operational record for the accepted quote, status trail, checklist, evidence, messages, reviews, and peer review."
<p class="mt-4 whitespace-pre-line">{{ $workOrder->agreed_terms }}</p> >
<h3 class="mt-6 font-semibold">Deliverables</h3> <a href="{{ route('work-orders.print', $workOrder) }}" target="_blank" class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800">Print packet</a>
<p class="mt-2 whitespace-pre-line">{{ $workOrder->deliverables_checklist }}</p> </x-page-header>
<x-attachments :attachments="$workOrder->attachments" /> </x-slot>
<x-rating-summary :ratings="$workOrder->ratings" />
<x-rating-form type="work_order" :id="$workOrder->id" category="work_order_outcome" mode="thumbs" /> <div class="mx-auto max-w-6xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<x-attachment-form type="work_order" :id="$workOrder->id" kind="work_order" /> <section class="tse-panel p-6">
<form method="POST" action="{{ route('work-orders.transition', $workOrder) }}" class="mt-6 space-y-4"> <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@csrf @method('PATCH') <div>
<label class="block text-sm font-medium">Update status</label> <x-badge tone="sky">{{ str_replace('_', ' ', $workOrder->status) }}</x-badge>
<select name="status" class="rounded-md border-gray-300"> <p class="mt-3 text-sm text-slate-600 dark:text-slate-400">
@foreach (\App\Models\WorkOrder::ALLOWED_TRANSITIONS[$workOrder->status] ?? [] as $status) Buyer: {{ $workOrder->buyer->buyerProfile?->company_name ?? $workOrder->buyer->name }}
<option value="{{ $status }}" @selected($workOrder->status === $status)>{{ str_replace('_', ' ', $status) }}</option> | Provider: {{ $workOrder->provider->providerProfile?->business_name ?? $workOrder->provider->name }}
</p>
</div>
<div class="text-sm text-slate-600 dark:text-slate-400 sm:text-right">
<p>Scheduled: {{ $workOrder->scheduled_at?->format('M j, Y g:i A') ?? 'Not set' }}</p>
<p>Window: {{ $workOrder->appointment_window ?: 'Not set' }}</p>
</div>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-3">
<x-stat-card label="Checklist" :value="$progress['done'].'/'.$progress['total']" description="Completed deliverables" />
<x-stat-card label="Attachments" :value="$workOrder->attachments->count()" description="Evidence and files" />
<x-stat-card label="Changes" :value="count($workOrder->changeRequests())" description="Requested scope changes" />
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[1.1fr_.9fr]">
<div>
<h3 class="font-semibold text-slate-950 dark:text-white">Agreed terms</h3>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-slate-700 dark:text-slate-300">{{ $workOrder->agreed_terms ?: 'No agreed terms recorded.' }}</p>
</div>
<div>
<h3 class="font-semibold text-slate-950 dark:text-white">Required evidence</h3>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-slate-700 dark:text-slate-300">{{ $workOrder->required_evidence ?: 'No specific evidence requirements recorded.' }}</p>
@if ($evidenceRules)
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($evidenceRules as $rule)
<x-badge tone="amber">{{ $rule }}</x-badge>
@endforeach @endforeach
</div>
@endif
</div>
</div>
</section>
@if (auth()->id() === $workOrder->buyer_id || auth()->user()->hasRole('admin'))
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Work-order setup</h3>
<form method="POST" action="{{ route('work-orders.details', $workOrder) }}" class="mt-4 grid gap-4">
@csrf
@method('PATCH')
<div class="grid gap-4 md:grid-cols-2">
<x-field name="scheduled_at" label="Scheduled date/time" type="datetime-local" :value="$workOrder->scheduled_at?->format('Y-m-d\\TH:i')" />
<x-field name="appointment_window" label="Appointment window" :value="$workOrder->appointment_window" />
</div>
<x-field name="agreed_terms" label="Agreed terms" :value="$workOrder->agreed_terms" textarea />
<x-field name="deliverables_checklist" label="Checklist items, one per line" :value="$workOrder->deliverables_checklist" textarea />
<x-field name="required_evidence" label="Required evidence" :value="$workOrder->required_evidence" textarea />
<div class="grid gap-3 md:grid-cols-3">
@foreach (['Arrival photo', 'Completion photo', 'Signed closeout'] as $rule)
<label class="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 dark:border-slate-800 dark:text-slate-300">
<input type="checkbox" name="evidence_rules[]" value="{{ $rule }}" @checked(in_array($rule, $evidenceRules, true)) class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
{{ $rule }}
</label>
@endforeach
</div>
<x-primary-button>Save setup</x-primary-button>
</form>
</section>
@endif
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Checklist and status</h3>
<form method="POST" action="{{ route('work-orders.transition', $workOrder) }}" class="mt-4 space-y-4">
@csrf
@method('PATCH')
<div class="grid gap-4 md:grid-cols-[.6fr_1.4fr]">
<div>
<label class="block text-sm font-medium text-slate-800 dark:text-slate-200">Update status</label>
<select name="status" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
@foreach (\App\Models\WorkOrder::ALLOWED_TRANSITIONS[$workOrder->status] ?? [] as $status)
<option value="{{ $status }}">{{ str_replace('_', ' ', $status) }}</option>
@endforeach
@if (empty(\App\Models\WorkOrder::ALLOWED_TRANSITIONS[$workOrder->status] ?? []))
<option value="{{ $workOrder->status }}">{{ str_replace('_', ' ', $workOrder->status) }}</option>
@endif
</select> </select>
</div>
<x-field name="completion_notes" label="Completion notes" :value="$workOrder->completion_notes" textarea /> <x-field name="completion_notes" label="Completion notes" :value="$workOrder->completion_notes" textarea />
</div>
<div class="grid gap-3 md:grid-cols-2">
@forelse ($workOrder->checklistItems() as $item)
<label class="flex items-start gap-3 rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
<input type="checkbox" name="checklist_completed[{{ $item }}]" value="1" @checked((bool) ($completed[$item] ?? false)) class="mt-1 rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
<span class="text-slate-700 dark:text-slate-300">{{ $item }}</span>
</label>
@empty
<p class="text-sm text-slate-600 dark:text-slate-400">No checklist items have been defined yet.</p>
@endforelse
</div>
<x-primary-button>Save status</x-primary-button> <x-primary-button>Save status</x-primary-button>
</form> </form>
</section> </section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Messages</h3> <section class="tse-panel p-6">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 class="font-semibold text-slate-950 dark:text-white">Evidence and files</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Attach photos, closeout forms, notes, screenshots, or other work evidence.</p>
</div>
<x-attachments :attachments="$workOrder->attachments" />
<x-attachment-form type="work_order" :id="$workOrder->id" kind="work_order" />
</section>
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Change requests</h3>
<form method="POST" action="{{ route('work-orders.change-requests', $workOrder) }}" class="mt-4 space-y-4">
@csrf
<x-field name="summary" label="Summary" />
<x-field name="details" label="Details" textarea />
<x-secondary-button type="submit">Record change request</x-secondary-button>
</form>
<div class="mt-4 space-y-3">
@forelse ($workOrder->changeRequests() as $requestRecord)
<div class="rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
<p class="font-semibold text-slate-950 dark:text-white">{{ $requestRecord['summary'] }}</p>
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ $requestRecord['requested_by_name'] ?? 'User' }} | {{ $requestRecord['status'] ?? 'open' }} | {{ $requestRecord['requested_at'] ?? '' }}</p>
@if (! empty($requestRecord['details']))
<p class="mt-2 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $requestRecord['details'] }}</p>
@endif
</div>
@empty
<p class="text-sm text-slate-600 dark:text-slate-400">No change requests yet.</p>
@endforelse
</div>
</section>
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Messages</h3>
<form method="POST" action="{{ route('work-order-messages.store', $workOrder) }}" class="mt-4 space-y-4"> <form method="POST" action="{{ route('work-order-messages.store', $workOrder) }}" class="mt-4 space-y-4">
@csrf @csrf
<x-field name="body" label="Message" textarea /> <x-field name="body" label="Message" textarea />
<x-primary-button>Send message</x-primary-button> <x-primary-button>Send message</x-primary-button>
</form> </form>
@foreach ($workOrder->messages as $message) @foreach ($workOrder->messages as $message)
<div class="mt-3 rounded bg-gray-50 p-3 text-sm"> <div class="mt-3 rounded-md bg-slate-50 p-3 text-sm dark:bg-slate-950">
<p class="font-semibold">{{ $message->user->name }} | {{ $message->created_at->diffForHumans() }}</p> <p class="font-semibold text-slate-950 dark:text-white">{{ $message->user->name }} | {{ $message->created_at->diffForHumans() }}</p>
<p class="whitespace-pre-line">{{ $message->body }}</p> <p class="whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $message->body }}</p>
</div> </div>
@endforeach @endforeach
</section> </section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Reviews</h3> <section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Reviews</h3>
<form method="POST" action="{{ route('reviews.store', $workOrder) }}" class="mt-4 space-y-4"> <form method="POST" action="{{ route('reviews.store', $workOrder) }}" class="mt-4 space-y-4">
@csrf @csrf
<div class="grid gap-4 md:grid-cols-3">
<x-field name="rating" label="Rating 1-5" type="number" /> <x-field name="rating" label="Rating 1-5" type="number" />
<x-field name="communication_rating" label="Communication 1-5" type="number" /> <x-field name="communication_rating" label="Communication 1-5" type="number" />
<x-field name="scope_accuracy_rating" label="Scope accuracy 1-5" type="number" /> <x-field name="scope_accuracy_rating" label="Scope accuracy 1-5" type="number" />
<x-field name="payment_reliability_rating" label="Payment reliability 1-5" type="number" /> <x-field name="payment_reliability_rating" label="Payment reliability 1-5" type="number" />
<x-field name="workmanship_rating" label="Workmanship 1-5" type="number" /> <x-field name="workmanship_rating" label="Workmanship 1-5" type="number" />
<x-field name="timeliness_rating" label="Timeliness 1-5" type="number" /> <x-field name="timeliness_rating" label="Timeliness 1-5" type="number" />
</div>
<x-field name="body" label="Review" textarea /> <x-field name="body" label="Review" textarea />
<x-primary-button>Save review</x-primary-button> <x-primary-button>Save review</x-primary-button>
</form> </form>
@foreach ($workOrder->reviews as $review) @foreach ($workOrder->reviews as $review)
<div class="mt-4 rounded border p-4 text-sm"> <div class="mt-4 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
<p class="font-semibold">{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5</p> <p class="font-semibold text-slate-950 dark:text-white">{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5</p>
<p class="mt-1 text-gray-600">Communication {{ $review->communication_rating ?? 'n/a' }} | Scope {{ $review->scope_accuracy_rating ?? 'n/a' }} | Payment {{ $review->payment_reliability_rating ?? 'n/a' }} | Workmanship {{ $review->workmanship_rating ?? 'n/a' }} | Timeliness {{ $review->timeliness_rating ?? 'n/a' }}</p> <p class="mt-1 text-slate-600 dark:text-slate-400">Communication {{ $review->communication_rating ?? 'n/a' }} | Scope {{ $review->scope_accuracy_rating ?? 'n/a' }} | Payment {{ $review->payment_reliability_rating ?? 'n/a' }} | Workmanship {{ $review->workmanship_rating ?? 'n/a' }} | Timeliness {{ $review->timeliness_rating ?? 'n/a' }}</p>
<p class="mt-2 whitespace-pre-line">{{ $review->body }}</p> <p class="mt-2 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $review->body }}</p>
</div> </div>
@endforeach @endforeach
</section> </section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Disputes / Peer Review</h3> <section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Community rating</h3>
<x-rating-summary :ratings="$workOrder->ratings" />
<x-rating-form type="work_order" :id="$workOrder->id" category="work_order_outcome" mode="thumbs" />
</section>
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Disputes / Peer Review</h3>
<form method="POST" action="{{ route('disputes.store', $workOrder) }}" class="mt-4 space-y-4"> <form method="POST" action="{{ route('disputes.store', $workOrder) }}" class="mt-4 space-y-4">
@csrf @csrf
<x-field name="summary" label="Summary" /> <x-field name="summary" label="Summary" />
@@ -67,8 +203,8 @@
<x-primary-button>Open dispute</x-primary-button> <x-primary-button>Open dispute</x-primary-button>
</form> </form>
@foreach ($workOrder->disputes as $dispute) @foreach ($workOrder->disputes as $dispute)
<a href="{{ route('disputes.show', $dispute) }}" class="mt-4 block rounded border p-4 text-sm"> <a href="{{ route('disputes.show', $dispute) }}" class="mt-4 block rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
<span class="font-semibold">{{ $dispute->summary }}</span> | {{ $dispute->status }} <span class="font-semibold text-slate-950 dark:text-white">{{ $dispute->summary }}</span> | {{ $dispute->status }}
</a> </a>
@endforeach @endforeach
</section> </section>
+3
View File
@@ -78,6 +78,9 @@ Route::middleware('auth')->group(function () {
Route::get('/work-orders', [WorkOrderController::class, 'index'])->name('work-orders.index'); Route::get('/work-orders', [WorkOrderController::class, 'index'])->name('work-orders.index');
Route::get('/work-orders/{workOrder}', [WorkOrderController::class, 'show'])->name('work-orders.show'); Route::get('/work-orders/{workOrder}', [WorkOrderController::class, 'show'])->name('work-orders.show');
Route::get('/work-orders/{workOrder}/print', [WorkOrderController::class, 'print'])->name('work-orders.print');
Route::patch('/work-orders/{workOrder}/details', [WorkOrderController::class, 'updateDetails'])->name('work-orders.details');
Route::post('/work-orders/{workOrder}/change-requests', [WorkOrderController::class, 'requestChange'])->name('work-orders.change-requests');
Route::patch('/work-orders/{workOrder}/transition', [WorkOrderController::class, 'transition'])->name('work-orders.transition'); Route::patch('/work-orders/{workOrder}/transition', [WorkOrderController::class, 'transition'])->name('work-orders.transition');
Route::post('/work-orders/{workOrder}/messages', [WorkOrderMessageController::class, 'store'])->name('work-order-messages.store'); Route::post('/work-orders/{workOrder}/messages', [WorkOrderMessageController::class, 'store'])->name('work-order-messages.store');
Route::post('/work-orders/{workOrder}/reviews', [ReviewController::class, 'store'])->name('reviews.store'); Route::post('/work-orders/{workOrder}/reviews', [ReviewController::class, 'store'])->name('reviews.store');
+109
View File
@@ -96,6 +96,50 @@ class ExchangeWorkflowTest extends TestCase
$this->assertDatabaseHas('disputes', ['work_order_id' => $workOrder->id, 'summary' => 'Scope changed']); $this->assertDatabaseHas('disputes', ['work_order_id' => $workOrder->id, 'summary' => 'Scope changed']);
} }
public function test_work_order_details_checklist_change_requests_and_print_view_work(): void
{
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
$this->actingAs($buyer)->patch("/work-orders/{$workOrder->id}/details", [
'scheduled_at' => '2026-07-12 09:00:00',
'appointment_window' => '9 AM - 11 AM',
'agreed_terms' => 'Two-hour minimum.',
'deliverables_checklist' => "Arrival photo\nCable test\nCloseout notes",
'required_evidence' => 'Photos and test results required.',
'evidence_rules' => ['Arrival photo', 'Completion photo'],
])->assertRedirect("/work-orders/{$workOrder->id}");
$workOrder->refresh();
$this->assertSame(['Arrival photo', 'Cable test', 'Closeout notes'], $workOrder->checklistItems());
$this->assertSame(['Arrival photo', 'Completion photo'], $workOrder->evidence_rules);
$this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [
'status' => 'en_route',
'checklist_completed' => [
'Arrival photo' => '1',
'Cable test' => '0',
],
])->assertRedirect("/work-orders/{$workOrder->id}");
$workOrder->refresh();
$this->assertTrue($workOrder->checklist_completed['Arrival photo']);
$this->assertFalse($workOrder->checklist_completed['Cable test']);
$this->actingAs($provider)->post("/work-orders/{$workOrder->id}/change-requests", [
'summary' => 'Add second terminal',
'details' => 'Buyer requested another POS terminal onsite.',
])->assertRedirect("/work-orders/{$workOrder->id}");
$this->assertDatabaseHas('work_orders', ['id' => $workOrder->id]);
$this->assertSame('Add second terminal', $workOrder->fresh()->changeRequests()[0]['summary']);
$this->actingAs($buyer)->get("/work-orders/{$workOrder->id}/print")
->assertOk()
->assertSee('Arrival photo')
->assertSee('Print');
}
public function test_external_profile_snapshot_can_be_saved(): void public function test_external_profile_snapshot_can_be_saved(): void
{ {
$provider = $this->userWithRole('provider'); $provider = $this->userWithRole('provider');
@@ -156,6 +200,41 @@ class ExchangeWorkflowTest extends TestCase
->assertDontSee('Beta Fiber Works'); ->assertDontSee('Beta Fiber Works');
} }
public function test_provider_profile_saves_structured_services_tools_certifications_and_visibility(): void
{
$provider = $this->userWithRole('provider');
$this->actingAs($provider)->put('/provider-profile', [
'business_name' => 'Structured Provider',
'bio' => 'Public bio.',
'services_text' => "Smart hands | entry\nNetwork troubleshooting | advanced",
'tool_inventory_text' => "Cable tester | network\nTone probe | cabling",
'certification_records_text' => "Low voltage license | Oklahoma\nA+ | CompTIA",
'profile_visibility' => [
'bio' => '1',
'services' => '1',
'tools' => '1',
'certifications' => '1',
'imports' => '1',
],
'private_notes' => 'Internal only.',
])->assertRedirect('/provider-profile');
$profile = $provider->providerProfile()->firstOrFail();
$this->assertSame('Smart hands', $profile->services[0]['name']);
$this->assertSame('network', $profile->tool_inventory[0]['category']);
$this->assertSame('CompTIA', $profile->certification_records[1]['issuer']);
$this->assertFalse($profile->profile_visibility['rate_card']);
$this->get("/providers/{$profile->id}")
->assertOk()
->assertSee('Smart hands')
->assertSee('Cable tester')
->assertSee('Low voltage license')
->assertDontSee('Internal only');
}
public function test_buyer_directory_can_filter_and_sort_profiles(): void public function test_buyer_directory_can_filter_and_sort_profiles(): void
{ {
$retailBuyer = $this->userWithRole('buyer'); $retailBuyer = $this->userWithRole('buyer');
@@ -196,6 +275,36 @@ class ExchangeWorkflowTest extends TestCase
->assertDontSee('Beta Warehouse Group'); ->assertDontSee('Beta Warehouse Group');
} }
public function test_buyer_profile_saves_structured_hiring_policies_locations_and_visibility(): void
{
$buyer = $this->userWithRole('buyer');
$this->actingAs($buyer)->put('/buyer-profile', [
'company_name' => 'Structured Buyer',
'description' => 'Buyer description.',
'hiring_policies_text' => "Direct ACH | Net 15 after approval\nCOI required | Before dispatch",
'locations_text' => "Tulsa office | Oklahoma\nDallas office | Texas",
'profile_visibility' => [
'description' => '1',
'locations' => '1',
'policies' => '1',
],
'private_notes' => 'Buyer internal note.',
])->assertRedirect('/buyer-profile');
$profile = $buyer->buyerProfile()->firstOrFail();
$this->assertSame('Direct ACH', $profile->hiring_policies[0]['name']);
$this->assertSame('Texas', $profile->locations[1]['region']);
$this->assertFalse($profile->profile_visibility['payment_terms']);
$this->get("/buyers/{$profile->id}")
->assertOk()
->assertSee('Direct ACH')
->assertSee('Tulsa office')
->assertDontSee('Buyer internal note');
}
public function test_invalid_work_order_transition_is_rejected(): void public function test_invalid_work_order_transition_is_rejected(): void
{ {
[, $provider, $workOrder] = $this->workOrderFixture(); [, $provider, $workOrder] = $this->workOrderFixture();