Phase 6 and 7 scope contact safeguards
This commit is contained in:
@@ -16,6 +16,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
|
||||
- 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.
|
||||
- Added Phase 5 reputation governance with explicit review category definitions, buyer/provider-specific review dimensions, reviewee responses, review report flow, admin moderation controls, moderation status tracking, a review edit window, and imported-history labeling.
|
||||
- Added Phase 6/7 scope and contact safeguards with structured job scope fields, scope clarity and risk badges, support/contact certification, work-order scope/contact snapshots, first-class change requests, contact/support event logging, reason-coded disputes, and print-packet support.
|
||||
- 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 social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance.
|
||||
@@ -33,6 +34,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
|
||||
|
||||
- `php artisan test` passed with 35 tests and 116 assertions.
|
||||
- `php artisan test` passed under WSL with 44 tests and 195 assertions after Phase 5.
|
||||
- `php artisan test` passed under Windows PHP with 46 tests and 216 assertions after enabling `pdo_sqlite`, `sqlite3`, `fileinfo`, and `gd` in `C:\tools\php85\php.ini`.
|
||||
- `npm run build` passed with a production Vite build.
|
||||
- `https://christit.com/tse/login` loads publicly.
|
||||
- Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`.
|
||||
|
||||
@@ -30,6 +30,7 @@ class DisputeController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'summary' => ['required', 'string', 'max:255'],
|
||||
'reason_code' => ['nullable', 'string', 'in:'.implode(',', array_keys(Dispute::REASON_CODES))],
|
||||
'claim' => ['required', 'string'],
|
||||
'evidence_notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
@@ -14,6 +14,7 @@ class DisputeVoteController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'recommendation' => ['required', 'string', 'in:provider,buyer,split,insufficient_evidence'],
|
||||
'reason_code' => ['nullable', 'string', 'in:'.implode(',', array_keys(Dispute::REASON_CODES))],
|
||||
'reason' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -48,16 +48,59 @@ class JobPostController extends Controller
|
||||
'location' => ['required', 'string', 'max:255'],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'time_window' => ['nullable', 'string', 'max:255'],
|
||||
'schedule_type' => ['nullable', 'string', 'max:255'],
|
||||
'remote_eligible' => ['nullable', 'boolean'],
|
||||
'scope' => ['required', 'string'],
|
||||
'primary_objective' => ['nullable', 'string'],
|
||||
'included_work' => ['nullable', 'string'],
|
||||
'excluded_work' => ['nullable', 'string'],
|
||||
'maximum_onsite_expectations' => ['nullable', 'string'],
|
||||
'expected_duration' => ['nullable', 'string', 'max:255'],
|
||||
'required_skills' => ['nullable', 'string'],
|
||||
'required_tools' => ['nullable', 'string'],
|
||||
'required_certifications' => ['nullable', 'string'],
|
||||
'required_safety_gear' => ['nullable', 'string'],
|
||||
'deliverables' => ['nullable', 'string'],
|
||||
'closeout_conditions' => ['nullable', 'string'],
|
||||
'buyer_provided_equipment' => ['nullable', 'string'],
|
||||
'provider_provided_equipment' => ['nullable', 'string'],
|
||||
'return_shipment_expectations' => ['nullable', 'string'],
|
||||
'parking_access_notes' => ['nullable', 'string'],
|
||||
'onsite_restrictions' => ['nullable', 'string'],
|
||||
'supplemental_instructions' => ['nullable', 'string'],
|
||||
'payment_terms' => ['nullable', 'string'],
|
||||
'vendor_onboarding' => ['nullable', 'string'],
|
||||
'primary_contact_name' => ['nullable', 'string', 'max:255'],
|
||||
'primary_contact_phone' => ['nullable', 'string', 'max:255'],
|
||||
'primary_contact_email' => ['nullable', 'email', 'max:255'],
|
||||
'backup_contact_name' => ['nullable', 'string', 'max:255'],
|
||||
'backup_contact_phone' => ['nullable', 'string', 'max:255'],
|
||||
'backup_contact_email' => ['nullable', 'email', 'max:255'],
|
||||
'dispatch_contact_name' => ['nullable', 'string', 'max:255'],
|
||||
'dispatch_contact_phone' => ['nullable', 'string', 'max:255'],
|
||||
'dispatch_contact_email' => ['nullable', 'email', 'max:255'],
|
||||
'technical_bridge' => ['nullable', 'string', 'max:255'],
|
||||
'escalation_contact' => ['nullable', 'string', 'max:255'],
|
||||
'support_channel' => ['nullable', 'string', 'max:255'],
|
||||
'support_expected_response_time' => ['nullable', 'string', 'max:255'],
|
||||
'support_availability_window' => ['nullable', 'string', 'max:255'],
|
||||
'contact_certified' => ['nullable', 'accepted'],
|
||||
'visibility' => ['required', 'string', 'in:public,members'],
|
||||
]);
|
||||
|
||||
$job = $request->user()->jobPosts()->create($data);
|
||||
$job = new JobPost($data);
|
||||
$job->buyer_id = $request->user()->id;
|
||||
$job->remote_eligible = $request->boolean('remote_eligible');
|
||||
$job->contact_certified = $request->boolean('contact_certified');
|
||||
|
||||
if ($job->contact_certified) {
|
||||
$job->contact_certified_by_id = $request->user()->id;
|
||||
$job->contact_certified_at = now();
|
||||
}
|
||||
|
||||
$job->risk_flags = $job->computeRiskFlags();
|
||||
$job->scope_clarity_status = $job->computedScopeClarityStatus();
|
||||
$job->save();
|
||||
|
||||
return redirect()->route('jobs.show', $job)->with('status', 'Job posted.');
|
||||
}
|
||||
|
||||
@@ -123,6 +123,10 @@ class QuoteController extends Controller
|
||||
'deliverables_checklist' => $quote->jobPost->deliverables,
|
||||
'scheduled_at' => $quote->jobPost->starts_at,
|
||||
'appointment_window' => $quote->jobPost->time_window,
|
||||
'scope_snapshot' => $quote->jobPost->scopeSnapshot(),
|
||||
'contact_snapshot' => $quote->jobPost->contactSnapshot(),
|
||||
'scope_clarity_status' => $quote->jobPost->scope_clarity_status,
|
||||
'risk_flags' => $quote->jobPost->risk_flags,
|
||||
'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()
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\WorkOrder;
|
||||
use App\Models\WorkOrderChangeRequest;
|
||||
use App\Models\WorkOrderContactEvent;
|
||||
use App\Notifications\ExchangeEventNotification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -34,6 +36,8 @@ class WorkOrderController extends Controller
|
||||
'buyer.buyerProfile',
|
||||
'provider.providerProfile',
|
||||
'acceptedQuote',
|
||||
'structuredChangeRequests.requester',
|
||||
'contactEvents.user',
|
||||
'reviews.reviewer',
|
||||
'reviews.reviewee',
|
||||
'disputes.openedBy',
|
||||
@@ -54,6 +58,8 @@ class WorkOrderController extends Controller
|
||||
'buyer.buyerProfile',
|
||||
'provider.providerProfile',
|
||||
'acceptedQuote',
|
||||
'structuredChangeRequests.requester',
|
||||
'contactEvents.user',
|
||||
'attachments',
|
||||
),
|
||||
]);
|
||||
@@ -162,19 +168,22 @@ class WorkOrderController extends Controller
|
||||
$data = $request->validate([
|
||||
'summary' => ['required', 'string', 'max:255'],
|
||||
'details' => ['nullable', 'string'],
|
||||
'reason_code' => ['required', 'string', 'in:'.implode(',', array_keys(WorkOrderChangeRequest::REASON_CODES))],
|
||||
'scope_impact' => ['nullable', 'string'],
|
||||
'schedule_impact' => ['nullable', 'string'],
|
||||
'terms_impact' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$requests = $workOrder->changeRequests();
|
||||
$requests[] = [
|
||||
$changeRequest = $workOrder->structuredChangeRequests()->create([
|
||||
'summary' => $data['summary'],
|
||||
'details' => $data['details'] ?? null,
|
||||
'requested_by_id' => $request->user()->id,
|
||||
'requested_by_name' => $request->user()->name,
|
||||
'reason_code' => $data['reason_code'],
|
||||
'scope_impact' => $data['scope_impact'] ?? null,
|
||||
'schedule_impact' => $data['schedule_impact'] ?? null,
|
||||
'terms_impact' => $data['terms_impact'] ?? null,
|
||||
'requester_id' => $request->user()->id,
|
||||
'status' => 'open',
|
||||
'requested_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$workOrder->update(['change_requests' => $requests]);
|
||||
]);
|
||||
|
||||
$recipient = $request->user()->id === $workOrder->buyer_id
|
||||
? $workOrder->provider
|
||||
@@ -190,6 +199,55 @@ class WorkOrderController extends Controller
|
||||
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request recorded.');
|
||||
}
|
||||
|
||||
public function resolveChangeRequest(Request $request, WorkOrder $workOrder, WorkOrderChangeRequest $changeRequest): RedirectResponse
|
||||
{
|
||||
$this->authorizeParticipant($request, $workOrder);
|
||||
abort_unless($changeRequest->work_order_id === $workOrder->id, 404);
|
||||
abort_unless($request->user()->id !== $changeRequest->requester_id || $request->user()->hasRole('admin'), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'status' => ['required', 'string', 'in:accepted,declined,withdrawn'],
|
||||
'resolution_notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$changeRequest->update([
|
||||
'status' => $data['status'],
|
||||
'resolution_notes' => $data['resolution_notes'] ?? null,
|
||||
'responder_id' => $request->user()->id,
|
||||
'responded_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request updated.');
|
||||
}
|
||||
|
||||
public function logContactEvent(Request $request, WorkOrder $workOrder): RedirectResponse
|
||||
{
|
||||
$this->authorizeParticipant($request, $workOrder);
|
||||
abort_unless($request->user()->id === $workOrder->provider_id || $request->user()->hasRole('admin'), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'event_type' => ['required', 'string', 'in:'.implode(',', array_keys(WorkOrderContactEvent::EVENT_TYPES))],
|
||||
'attempted_channel' => ['nullable', 'string', 'max:255'],
|
||||
'attempted_at' => ['nullable', 'date'],
|
||||
'result' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$event = $workOrder->contactEvents()->create($data + [
|
||||
'user_id' => $request->user()->id,
|
||||
'attempted_at' => $data['attempted_at'] ?? now(),
|
||||
]);
|
||||
|
||||
$workOrder->buyer->notify(new ExchangeEventNotification(
|
||||
WorkOrderContactEvent::EVENT_TYPES[$event->event_type] ?? 'Contact event logged',
|
||||
$request->user()->name.' logged '.str_replace('_', ' ', $event->event_type).' on '.$workOrder->jobPost->title.'.',
|
||||
route('work-orders.show', $workOrder),
|
||||
'contact_event'
|
||||
));
|
||||
|
||||
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Contact/support event logged.');
|
||||
}
|
||||
|
||||
private function authorizeParticipant(Request $request, WorkOrder $workOrder): void
|
||||
{
|
||||
abort_unless(
|
||||
|
||||
+13
-1
@@ -9,11 +9,23 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable([
|
||||
'work_order_id', 'opened_by_id', 'status', 'summary', 'claim', 'response',
|
||||
'work_order_id', 'opened_by_id', 'status', 'summary', 'reason_code', 'claim', 'response',
|
||||
'evidence_notes', 'recommended_resolution', 'peer_votes', 'resolved_at',
|
||||
])]
|
||||
class Dispute extends Model
|
||||
{
|
||||
public const REASON_CODES = [
|
||||
'scope_expansion' => 'Scope expansion',
|
||||
'unreachable_contact' => 'Unreachable contact',
|
||||
'support_unavailable' => 'Support unavailable',
|
||||
'missing_deliverable' => 'Missing deliverable',
|
||||
'payment_issue' => 'Payment issue',
|
||||
'schedule_issue' => 'Schedule issue',
|
||||
'quality_issue' => 'Quality issue',
|
||||
'insufficient_evidence' => 'Insufficient evidence',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['dispute_id', 'user_id', 'recommendation', 'reason'])]
|
||||
#[Fillable(['dispute_id', 'user_id', 'recommendation', 'reason_code', 'reason'])]
|
||||
class DisputeVote extends Model
|
||||
{
|
||||
public function dispute(): BelongsTo
|
||||
|
||||
+123
-2
@@ -11,15 +11,37 @@ 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',
|
||||
'time_window', 'schedule_type', 'remote_eligible', 'scope', 'primary_objective',
|
||||
'included_work', 'excluded_work', 'maximum_onsite_expectations', 'expected_duration',
|
||||
'required_skills', 'required_tools', 'required_certifications', 'required_safety_gear',
|
||||
'deliverables', 'closeout_conditions', 'buyer_provided_equipment',
|
||||
'provider_provided_equipment', 'return_shipment_expectations', 'parking_access_notes',
|
||||
'onsite_restrictions', 'supplemental_instructions', 'scope_clarity_status',
|
||||
'risk_flags', 'payment_terms', 'vendor_onboarding', 'primary_contact_name',
|
||||
'primary_contact_phone', 'primary_contact_email', 'backup_contact_name',
|
||||
'backup_contact_phone', 'backup_contact_email', 'dispatch_contact_name',
|
||||
'dispatch_contact_phone', 'dispatch_contact_email', 'technical_bridge',
|
||||
'escalation_contact', 'support_channel', 'support_expected_response_time',
|
||||
'support_availability_window', 'contact_certified', 'contact_certified_by_id',
|
||||
'contact_certified_at', 'visibility',
|
||||
])]
|
||||
class JobPost extends Model
|
||||
{
|
||||
public const SCOPE_CLARITY_STATUSES = [
|
||||
'clear' => 'Clear',
|
||||
'needs_review' => 'Needs review',
|
||||
'broad_scope' => 'Broad scope',
|
||||
'missing_support' => 'Missing support',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'starts_at' => 'datetime',
|
||||
'remote_eligible' => 'boolean',
|
||||
'risk_flags' => 'array',
|
||||
'contact_certified' => 'boolean',
|
||||
'contact_certified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,4 +74,103 @@ class JobPost extends Model
|
||||
{
|
||||
return $this->morphMany(Rating::class, 'rateable');
|
||||
}
|
||||
|
||||
public function scopeSnapshot(): array
|
||||
{
|
||||
return [
|
||||
'primary_objective' => $this->primary_objective,
|
||||
'included_work' => $this->included_work,
|
||||
'excluded_work' => $this->excluded_work,
|
||||
'maximum_onsite_expectations' => $this->maximum_onsite_expectations,
|
||||
'expected_duration' => $this->expected_duration,
|
||||
'required_skills' => $this->required_skills,
|
||||
'required_tools' => $this->required_tools,
|
||||
'required_certifications' => $this->required_certifications,
|
||||
'required_safety_gear' => $this->required_safety_gear,
|
||||
'deliverables' => $this->deliverables,
|
||||
'closeout_conditions' => $this->closeout_conditions,
|
||||
'buyer_provided_equipment' => $this->buyer_provided_equipment,
|
||||
'provider_provided_equipment' => $this->provider_provided_equipment,
|
||||
'return_shipment_expectations' => $this->return_shipment_expectations,
|
||||
'parking_access_notes' => $this->parking_access_notes,
|
||||
'onsite_restrictions' => $this->onsite_restrictions,
|
||||
'supplemental_instructions' => $this->supplemental_instructions,
|
||||
];
|
||||
}
|
||||
|
||||
public function contactSnapshot(): array
|
||||
{
|
||||
return [
|
||||
'primary_contact_name' => $this->primary_contact_name,
|
||||
'primary_contact_phone' => $this->primary_contact_phone,
|
||||
'primary_contact_email' => $this->primary_contact_email,
|
||||
'backup_contact_name' => $this->backup_contact_name,
|
||||
'backup_contact_phone' => $this->backup_contact_phone,
|
||||
'backup_contact_email' => $this->backup_contact_email,
|
||||
'dispatch_contact_name' => $this->dispatch_contact_name,
|
||||
'dispatch_contact_phone' => $this->dispatch_contact_phone,
|
||||
'dispatch_contact_email' => $this->dispatch_contact_email,
|
||||
'technical_bridge' => $this->technical_bridge,
|
||||
'escalation_contact' => $this->escalation_contact,
|
||||
'support_channel' => $this->support_channel,
|
||||
'support_expected_response_time' => $this->support_expected_response_time,
|
||||
'support_availability_window' => $this->support_availability_window,
|
||||
'contact_certified' => $this->contact_certified,
|
||||
'contact_certified_at' => $this->contact_certified_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
public function computeRiskFlags(): array
|
||||
{
|
||||
$flags = [];
|
||||
|
||||
if (! $this->primary_objective || ! $this->included_work || ! $this->excluded_work) {
|
||||
$flags[] = 'missing_scope_boundaries';
|
||||
}
|
||||
|
||||
if (! $this->deliverables || ! $this->closeout_conditions) {
|
||||
$flags[] = 'unclear_closeout';
|
||||
}
|
||||
|
||||
if (! $this->backup_contact_phone && ! $this->backup_contact_email) {
|
||||
$flags[] = 'missing_contact_backup';
|
||||
}
|
||||
|
||||
if (! $this->contact_certified) {
|
||||
$flags[] = 'support_not_certified';
|
||||
}
|
||||
|
||||
if (strlen((string) $this->supplemental_instructions) > 2000) {
|
||||
$flags[] = 'long_supplemental_instructions';
|
||||
}
|
||||
|
||||
if ($this->return_shipment_expectations) {
|
||||
$flags[] = 'return_shipment_required';
|
||||
}
|
||||
|
||||
if ($this->starts_at && $this->starts_at->isBefore(now()->addDay())) {
|
||||
$flags[] = 'compressed_schedule';
|
||||
}
|
||||
|
||||
return array_values(array_unique($flags));
|
||||
}
|
||||
|
||||
public function computedScopeClarityStatus(): string
|
||||
{
|
||||
$flags = $this->computeRiskFlags();
|
||||
|
||||
if (in_array('missing_scope_boundaries', $flags, true) || in_array('unclear_closeout', $flags, true)) {
|
||||
return 'needs_review';
|
||||
}
|
||||
|
||||
if (in_array('support_not_certified', $flags, true) || in_array('missing_contact_backup', $flags, true)) {
|
||||
return 'missing_support';
|
||||
}
|
||||
|
||||
if (in_array('long_supplemental_instructions', $flags, true)) {
|
||||
return 'broad_scope';
|
||||
}
|
||||
|
||||
return 'clear';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'job_post_id', 'buyer_id', 'provider_id', 'accepted_quote_id', 'status',
|
||||
'status_history', 'agreed_terms', 'deliverables_checklist', 'scheduled_at',
|
||||
'appointment_window', 'checklist_items', 'checklist_completed', 'required_evidence',
|
||||
'evidence_rules', 'change_requests', 'completion_notes',
|
||||
'evidence_rules', 'scope_snapshot', 'contact_snapshot', 'scope_clarity_status',
|
||||
'risk_flags', 'change_requests', 'completion_notes',
|
||||
'en_route_at', 'on_site_at', 'started_at', 'completed_at', 'approved_at', 'closed_at',
|
||||
])]
|
||||
class WorkOrder extends Model
|
||||
@@ -42,6 +43,9 @@ class WorkOrder extends Model
|
||||
'checklist_items' => 'array',
|
||||
'checklist_completed' => 'array',
|
||||
'evidence_rules' => 'array',
|
||||
'scope_snapshot' => 'array',
|
||||
'contact_snapshot' => 'array',
|
||||
'risk_flags' => 'array',
|
||||
'change_requests' => 'array',
|
||||
'en_route_at' => 'datetime',
|
||||
'on_site_at' => 'datetime',
|
||||
@@ -97,6 +101,16 @@ class WorkOrder extends Model
|
||||
return $this->morphMany(Rating::class, 'rateable');
|
||||
}
|
||||
|
||||
public function structuredChangeRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkOrderChangeRequest::class);
|
||||
}
|
||||
|
||||
public function contactEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkOrderContactEvent::class);
|
||||
}
|
||||
|
||||
public function canTransitionTo(string $status): bool
|
||||
{
|
||||
return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
|
||||
@@ -134,6 +148,54 @@ class WorkOrder extends Model
|
||||
|
||||
public function changeRequests(): array
|
||||
{
|
||||
$structured = $this->relationLoaded('structuredChangeRequests')
|
||||
? $this->structuredChangeRequests
|
||||
: $this->structuredChangeRequests()->latest()->get();
|
||||
|
||||
if ($structured->isNotEmpty()) {
|
||||
return $structured
|
||||
->map(fn (WorkOrderChangeRequest $request): array => [
|
||||
'id' => $request->id,
|
||||
'summary' => $request->summary,
|
||||
'details' => $request->details,
|
||||
'reason_code' => $request->reason_code,
|
||||
'reason_label' => WorkOrderChangeRequest::REASON_CODES[$request->reason_code] ?? $request->reason_code,
|
||||
'scope_impact' => $request->scope_impact,
|
||||
'schedule_impact' => $request->schedule_impact,
|
||||
'terms_impact' => $request->terms_impact,
|
||||
'requested_by_id' => $request->requester_id,
|
||||
'requested_by_name' => $request->requester?->name,
|
||||
'status' => $request->status,
|
||||
'requested_at' => $request->created_at?->toIso8601String(),
|
||||
'resolution_notes' => $request->resolution_notes,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return is_array($this->change_requests) ? array_values($this->change_requests) : [];
|
||||
}
|
||||
|
||||
public function scopeSnapshotValue(string $key): ?string
|
||||
{
|
||||
$snapshot = is_array($this->scope_snapshot) ? $this->scope_snapshot : [];
|
||||
|
||||
return $snapshot[$key] ?? $this->jobPost?->{$key};
|
||||
}
|
||||
|
||||
public function contactSnapshotValue(string $key): ?string
|
||||
{
|
||||
$snapshot = is_array($this->contact_snapshot) ? $this->contact_snapshot : [];
|
||||
|
||||
return $snapshot[$key] ?? $this->jobPost?->{$key};
|
||||
}
|
||||
|
||||
public function contactFailureCount(): int
|
||||
{
|
||||
$events = $this->relationLoaded('contactEvents') ? $this->contactEvents : $this->contactEvents()->get();
|
||||
|
||||
return $events
|
||||
->whereIn('event_type', ['contact_failed', 'support_unavailable', 'site_contact_unavailable'])
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'work_order_id',
|
||||
'requester_id',
|
||||
'responder_id',
|
||||
'reason_code',
|
||||
'summary',
|
||||
'details',
|
||||
'scope_impact',
|
||||
'schedule_impact',
|
||||
'terms_impact',
|
||||
'status',
|
||||
'resolution_notes',
|
||||
'responded_at',
|
||||
])]
|
||||
class WorkOrderChangeRequest extends Model
|
||||
{
|
||||
public const REASON_CODES = [
|
||||
'scope_expansion' => 'Scope expansion',
|
||||
'missing_scope' => 'Missing or ambiguous scope',
|
||||
'schedule_change' => 'Schedule change',
|
||||
'site_condition' => 'Unexpected site condition',
|
||||
'buyer_request' => 'Buyer-requested change',
|
||||
'provider_request' => 'Provider-requested change',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
public const STATUSES = ['open', 'accepted', 'declined', 'withdrawn'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'responded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function workOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrder::class);
|
||||
}
|
||||
|
||||
public function requester(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requester_id');
|
||||
}
|
||||
|
||||
public function responder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'responder_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'work_order_id',
|
||||
'user_id',
|
||||
'event_type',
|
||||
'attempted_channel',
|
||||
'attempted_at',
|
||||
'result',
|
||||
'notes',
|
||||
])]
|
||||
class WorkOrderContactEvent extends Model
|
||||
{
|
||||
public const EVENT_TYPES = [
|
||||
'contact_failed' => 'Contact failed',
|
||||
'support_unavailable' => 'Support unavailable',
|
||||
'site_contact_unavailable' => 'Site contact unavailable',
|
||||
'contact_reached' => 'Contact reached',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attempted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function workOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrder::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?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('job_posts', function (Blueprint $table) {
|
||||
$table->text('primary_objective')->nullable()->after('scope');
|
||||
$table->text('included_work')->nullable()->after('primary_objective');
|
||||
$table->text('excluded_work')->nullable()->after('included_work');
|
||||
$table->text('maximum_onsite_expectations')->nullable()->after('excluded_work');
|
||||
$table->string('expected_duration')->nullable()->after('maximum_onsite_expectations');
|
||||
$table->string('schedule_type')->nullable()->after('time_window');
|
||||
$table->boolean('remote_eligible')->default(false)->after('schedule_type');
|
||||
$table->text('required_certifications')->nullable()->after('required_tools');
|
||||
$table->text('required_safety_gear')->nullable()->after('required_certifications');
|
||||
$table->text('closeout_conditions')->nullable()->after('deliverables');
|
||||
$table->text('buyer_provided_equipment')->nullable()->after('closeout_conditions');
|
||||
$table->text('provider_provided_equipment')->nullable()->after('buyer_provided_equipment');
|
||||
$table->text('return_shipment_expectations')->nullable()->after('provider_provided_equipment');
|
||||
$table->text('parking_access_notes')->nullable()->after('return_shipment_expectations');
|
||||
$table->text('onsite_restrictions')->nullable()->after('parking_access_notes');
|
||||
$table->text('supplemental_instructions')->nullable()->after('onsite_restrictions');
|
||||
$table->string('scope_clarity_status')->default('needs_review')->after('supplemental_instructions');
|
||||
$table->json('risk_flags')->nullable()->after('scope_clarity_status');
|
||||
$table->string('primary_contact_name')->nullable()->after('vendor_onboarding');
|
||||
$table->string('primary_contact_phone')->nullable()->after('primary_contact_name');
|
||||
$table->string('primary_contact_email')->nullable()->after('primary_contact_phone');
|
||||
$table->string('backup_contact_name')->nullable()->after('primary_contact_email');
|
||||
$table->string('backup_contact_phone')->nullable()->after('backup_contact_name');
|
||||
$table->string('backup_contact_email')->nullable()->after('backup_contact_phone');
|
||||
$table->string('dispatch_contact_name')->nullable()->after('backup_contact_email');
|
||||
$table->string('dispatch_contact_phone')->nullable()->after('dispatch_contact_name');
|
||||
$table->string('dispatch_contact_email')->nullable()->after('dispatch_contact_phone');
|
||||
$table->string('technical_bridge')->nullable()->after('dispatch_contact_email');
|
||||
$table->string('escalation_contact')->nullable()->after('technical_bridge');
|
||||
$table->string('support_channel')->nullable()->after('escalation_contact');
|
||||
$table->string('support_expected_response_time')->nullable()->after('support_channel');
|
||||
$table->string('support_availability_window')->nullable()->after('support_expected_response_time');
|
||||
$table->boolean('contact_certified')->default(false)->after('support_availability_window');
|
||||
$table->foreignId('contact_certified_by_id')->nullable()->after('contact_certified')->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('contact_certified_at')->nullable()->after('contact_certified_by_id');
|
||||
});
|
||||
|
||||
Schema::table('work_orders', function (Blueprint $table) {
|
||||
$table->json('scope_snapshot')->nullable()->after('required_evidence');
|
||||
$table->json('contact_snapshot')->nullable()->after('scope_snapshot');
|
||||
$table->string('scope_clarity_status')->default('needs_review')->after('contact_snapshot');
|
||||
$table->json('risk_flags')->nullable()->after('scope_clarity_status');
|
||||
});
|
||||
|
||||
Schema::table('disputes', function (Blueprint $table) {
|
||||
$table->string('reason_code')->nullable()->after('summary');
|
||||
});
|
||||
|
||||
Schema::table('dispute_votes', function (Blueprint $table) {
|
||||
$table->string('reason_code')->nullable()->after('recommendation');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('dispute_votes', function (Blueprint $table) {
|
||||
$table->dropColumn('reason_code');
|
||||
});
|
||||
|
||||
Schema::table('disputes', function (Blueprint $table) {
|
||||
$table->dropColumn('reason_code');
|
||||
});
|
||||
|
||||
Schema::table('work_orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['scope_snapshot', 'contact_snapshot', 'scope_clarity_status', 'risk_flags']);
|
||||
});
|
||||
|
||||
Schema::table('job_posts', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('contact_certified_by_id');
|
||||
$table->dropColumn([
|
||||
'primary_objective',
|
||||
'included_work',
|
||||
'excluded_work',
|
||||
'maximum_onsite_expectations',
|
||||
'expected_duration',
|
||||
'schedule_type',
|
||||
'remote_eligible',
|
||||
'required_certifications',
|
||||
'required_safety_gear',
|
||||
'closeout_conditions',
|
||||
'buyer_provided_equipment',
|
||||
'provider_provided_equipment',
|
||||
'return_shipment_expectations',
|
||||
'parking_access_notes',
|
||||
'onsite_restrictions',
|
||||
'supplemental_instructions',
|
||||
'scope_clarity_status',
|
||||
'risk_flags',
|
||||
'primary_contact_name',
|
||||
'primary_contact_phone',
|
||||
'primary_contact_email',
|
||||
'backup_contact_name',
|
||||
'backup_contact_phone',
|
||||
'backup_contact_email',
|
||||
'dispatch_contact_name',
|
||||
'dispatch_contact_phone',
|
||||
'dispatch_contact_email',
|
||||
'technical_bridge',
|
||||
'escalation_contact',
|
||||
'support_channel',
|
||||
'support_expected_response_time',
|
||||
'support_availability_window',
|
||||
'contact_certified',
|
||||
'contact_certified_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('work_order_change_requests', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('work_order_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('requester_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignId('responder_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('reason_code')->default('scope_expansion');
|
||||
$table->string('summary');
|
||||
$table->text('details')->nullable();
|
||||
$table->text('scope_impact')->nullable();
|
||||
$table->text('schedule_impact')->nullable();
|
||||
$table->text('terms_impact')->nullable();
|
||||
$table->string('status')->default('open');
|
||||
$table->text('resolution_notes')->nullable();
|
||||
$table->timestamp('responded_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['work_order_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('work_order_change_requests');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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::create('work_order_contact_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('work_order_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('event_type')->default('contact_failed');
|
||||
$table->string('attempted_channel')->nullable();
|
||||
$table->timestamp('attempted_at')->nullable();
|
||||
$table->string('result')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['work_order_id', 'event_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('work_order_contact_events');
|
||||
}
|
||||
};
|
||||
+5
-2
@@ -18,6 +18,8 @@ This document records the explicit runtime requirements and server changes for P
|
||||
- `openssl`
|
||||
- `pdo`
|
||||
- `pdo_sqlite` for SQLite test deployments and the default automated test suite
|
||||
- `sqlite3` for SQLite-backed local tests
|
||||
- `gd` for generated image upload fixtures and image handling
|
||||
- `session`
|
||||
- `tokenizer`
|
||||
- `xml`
|
||||
@@ -37,8 +39,9 @@ This document records the explicit runtime requirements and server changes for P
|
||||
## Local Test Requirements
|
||||
|
||||
- The default PHPUnit configuration uses an in-memory SQLite database, so whichever PHP runtime executes `php artisan test` must include `pdo_sqlite` and `sqlite3`.
|
||||
- On Windows, `php -m` should list both `PDO` and `pdo_sqlite`; otherwise the feature tests fail before reaching app code with `could not find driver`.
|
||||
- WSL Ubuntu is acceptable for local verification when Windows PHP is missing SQLite support, as long as WSL PHP has `pdo_sqlite` and `sqlite3`.
|
||||
- Upload tests also require `fileinfo` for MIME detection and `gd` for generated image fixtures.
|
||||
- On Windows, `php -m` should list `PDO`, `pdo_sqlite`, `sqlite3`, `fileinfo`, and `gd`; otherwise the feature tests fail before reaching app code or upload assertions.
|
||||
- WSL Ubuntu is acceptable for local verification when Windows PHP is missing extension support, as long as WSL PHP has the same extensions.
|
||||
|
||||
## Recommended Production Runtime
|
||||
|
||||
|
||||
+34
-18
@@ -1,6 +1,6 @@
|
||||
# Forward Phase Plan
|
||||
|
||||
This plan starts from the current Provider Exchange codebase as of commit `926455b` and reconciles the previous Phase 5-10 plan with the Field Nation product research captured on July 10, 2026.
|
||||
This plan starts from the current Provider Exchange codebase and reconciles the previous Phase 5-10 plan with the Field Nation product research captured on July 10, 2026.
|
||||
|
||||
The goal is not to clone Field Nation. The goal is to keep the useful operational patterns while designing against the exact provider-hostile problems we observed: opaque scoring, catch-all scope language, unreachable buyer contacts, weak filtering safeguards, and work-order ambiguity.
|
||||
|
||||
@@ -14,9 +14,9 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa
|
||||
- Buyer profiles with public/private section visibility, company description, service categories, hiring regions, structured hiring policies, locations, vendor onboarding, payment terms, website, contact email, public contact toggle, private notes, attachments, and ratings.
|
||||
- Public provider and buyer directories with basic keyword filters, text-field filters, public-contact filter, sort by newest/name/rating, result counts, filter badges, and card views.
|
||||
- Community feed posts and comments.
|
||||
- Buyer job posts with title, status, service category, location, start time, time window, scope, required skills, required tools, deliverables, payment terms, vendor onboarding, visibility, comments, attachments, quotes, ratings, and accepted work-order creation.
|
||||
- Buyer job posts with title, status, service category, location, start time, time window, scope, structured scope fields, requirements, closeout rules, support/contact certification, risk flags, payment terms, vendor onboarding, visibility, comments, attachments, quotes, ratings, and accepted work-order creation.
|
||||
- Provider quote submission, quote revision history, quote decline flow, and buyer quote acceptance.
|
||||
- Work orders created from accepted quotes with buyer/provider participants, status transitions, timestamps, agreed terms, checklist items, checklist completion state, evidence requirements, evidence rules, change-request JSON records, completion notes, messages, attachments, print packet, reviews, disputes, and ratings.
|
||||
- Work orders created from accepted quotes with buyer/provider participants, status transitions, timestamps, agreed terms, checklist items, checklist completion state, evidence requirements, evidence rules, scope/contact snapshots, first-class change requests, contact/support events, completion notes, messages, attachments, print packet, reviews, disputes, and ratings.
|
||||
- Work-order status flow: assigned, en route, onsite, in progress, completed, buyer approved, disputed, closed, cancelled.
|
||||
- Mutual work-order reviews with five-star overall rating and category ratings for communication, scope accuracy, payment reliability, workmanship, and timeliness.
|
||||
- Universal ratings for provider profiles, buyer profiles, job posts, work orders, and disputes.
|
||||
@@ -29,13 +29,11 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa
|
||||
|
||||
### Current Weak Spots In Code
|
||||
|
||||
- Job posting still centers on a single free-form `scope` field, which allows the exact catch-all work-order behavior we want to prevent.
|
||||
- Job posts do not yet have explicit fields for primary objective, included work, excluded work, maximum onsite expectation, schedule type, support/contact certification, escalation path, risk flags, or scope-clarity status.
|
||||
- Job posting now has structured scope and support certification fields, but the creation UI should be refined into reusable templates and stronger validation before real marketplace use.
|
||||
- Provider and buyer directories use text-based filters instead of normalized category, skill, coverage, remote, rating, and reliability filters.
|
||||
- Work-order lists are card-based and lack the dense assigned/available-work table that worked well in Field Nation.
|
||||
- Work-order detail has good sections, but contact availability, support failure logging, schedule update requests, running-late records, and report-problem records are not first-class workflow events yet.
|
||||
- Change requests exist only as JSON on the work order, with no status lifecycle, approval/denial path, price/time impact, or dispute linkage.
|
||||
- Disputes have votes, but reason codes are free text and do not yet include structured categories such as scope expansion, unreachable contact, missing deliverable, payment issue, or buyer support failure.
|
||||
- Work-order detail now supports contact/support failure logging and first-class change requests, but schedule update requests, running-late records, and general report-problem records still need a dedicated workflow.
|
||||
- Disputes and votes now have reason codes, but quorum rules, visibility rules, and deeper evidence timelines still need a future pass.
|
||||
- Reviews exist, but there is no review response, report flow, edit window, moderation queue, category definitions page, or imported/native reputation distinction in the UI.
|
||||
- Universal ratings are flexible, but category names are arbitrary strings and not governed by a taxonomy.
|
||||
- External profile imports store summary numbers and notes, but not structured work-category history, imported endorsement categories, imported review snapshots with privacy controls, or imported-vs-native display rules.
|
||||
@@ -171,6 +169,18 @@ Provider-protection rules:
|
||||
- Undefined scope should not become provider fault.
|
||||
- Dispute reasons should distinguish poor provider performance from buyer-created ambiguity.
|
||||
|
||||
Implementation status:
|
||||
|
||||
- Completed first implementation pass.
|
||||
- Added structured job scope fields and support/contact certification fields.
|
||||
- Added computed scope clarity and risk flags on jobs and work orders.
|
||||
- Snapshotted scope and contact commitments when a quote becomes a work order.
|
||||
- Converted change requests to first-class records with reason and impact fields while preserving legacy JSON fallback.
|
||||
- Added contact/support event records for contact failed, support unavailable, site contact unavailable, and contact reached.
|
||||
- Added print packet visibility for scope safeguards, support contacts, change requests, and contact/support events.
|
||||
- Added reason codes to disputes and peer votes.
|
||||
- Verified with Windows `php artisan test`.
|
||||
|
||||
## Phase 7 - Contact Accountability And Support Availability
|
||||
|
||||
Purpose:
|
||||
@@ -223,6 +233,17 @@ Provider-protection rules:
|
||||
- If contacts fail, that should be evidence, not a buried message.
|
||||
- Buyer reliability should include support availability, not only payment.
|
||||
|
||||
Implementation status:
|
||||
|
||||
- Completed first implementation pass alongside Phase 6.
|
||||
- Job creation now captures primary, backup, dispatch, bridge, escalation, support channel, response time, support window, and certification.
|
||||
- Work orders preserve the accepted job contact snapshot.
|
||||
- Providers can log contact/support events as evidence.
|
||||
- Contact issue counts show on the work-order detail.
|
||||
- Print packets include contact/support coverage and logged contact events.
|
||||
- Dispute reason codes include unreachable contact and support unavailable.
|
||||
- Verified with Windows `php artisan test`.
|
||||
|
||||
## Phase 8 - Available Work Board And Filtering System
|
||||
|
||||
Purpose:
|
||||
@@ -661,16 +682,11 @@ Provider-protection rules:
|
||||
|
||||
## Recommended Immediate Sequence
|
||||
|
||||
1. Phase 5A: review category definitions, review response, edit window, and report flow.
|
||||
2. Phase 5B: moderation queue for reported reviews and rating governance docs in the UI.
|
||||
3. Phase 6A: structured job scope fields and anti-catch-all guardrails.
|
||||
4. Phase 6B: risk badges and scope clarity indicators on job list/detail.
|
||||
5. Phase 7A: contact/support certification fields on job posts and work orders.
|
||||
6. Phase 7B: provider contact-failure/support-unavailable event logging.
|
||||
7. Phase 8A: dense available-work board with compact filters.
|
||||
8. Phase 9A: taxonomy tables and starter work-category seeds.
|
||||
9. Phase 10A: guided imported-history wizard.
|
||||
10. Phase 11A: mobile-safe API endpoints for onsite work-order actions.
|
||||
1. Phase 8A: dense available-work board with compact filters.
|
||||
2. Phase 8B: saved searches, risk filter chips, and provider default filters.
|
||||
3. Phase 9A: taxonomy tables and starter work-category seeds.
|
||||
4. Phase 10A: guided imported-history wizard.
|
||||
5. Phase 11A: mobile-safe API endpoints for onsite work-order actions.
|
||||
|
||||
## Key Design Commitments
|
||||
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ The preferred working loop for this project is:
|
||||
## Important Notes
|
||||
|
||||
- The installer is a first-pass Ubuntu/Nginx helper, not a universal hosting panel installer.
|
||||
- Local and CI test runners need PHP SQLite support: `pdo_sqlite` and `sqlite3`.
|
||||
- Local and CI test runners need PHP SQLite and upload-test support: `pdo_sqlite`, `sqlite3`, `fileinfo`, and `gd`.
|
||||
- Public attachment serving needs `php artisan storage:link`; the checked-in update script runs this automatically.
|
||||
- Tune upload policy with `TSE_ATTACHMENT_DISK`, `TSE_ATTACHMENT_ROOT`, `TSE_ATTACHMENT_MAX_KB`, and `TSE_ATTACHMENT_MIME_TYPES`.
|
||||
- Review `.env` before production use.
|
||||
|
||||
+17
-5
@@ -13,6 +13,9 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
||||
- Quote revisions, quote decline flow, and quote acceptance.
|
||||
- Work-order creation from accepted quotes.
|
||||
- Work-order status transitions, messages, attachments, deliverables, and completion notes.
|
||||
- Structured job scope, scope clarity status, risk badges, and anti-catch-all supplemental instruction guardrails.
|
||||
- Buyer contact/support certification, work-order contact snapshots, and provider contact/support failure logging.
|
||||
- First-class work-order change requests with reason codes, impact fields, status lifecycle, and print-packet visibility.
|
||||
- Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion.
|
||||
- Mutual reviews with category metrics.
|
||||
- Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns.
|
||||
@@ -39,8 +42,8 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
||||
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.
|
||||
5. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views.
|
||||
6. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools.
|
||||
7. Notifications: channel preferences, email templates, and event-specific subscription controls.
|
||||
6. Scope clarity and work-order safeguards: structured job scope, risk badges, anti-catch-all rules, first-class change requests, and reason-coded dispute paths.
|
||||
7. Contact accountability and support availability: certified contacts, support windows, event logs for failed support/contact attempts, and contact reliability evidence.
|
||||
8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions.
|
||||
9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history.
|
||||
10. Competency tags and levels: provider/buyer tag taxonomies, smart-hands entry lane, specialty tags such as network, POTS, POS, AV, cabling, installer, and troubleshooter, plus earned level badges based on completed work and reputation signals.
|
||||
@@ -50,10 +53,8 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
||||
|
||||
## Phase Checkpoint
|
||||
|
||||
Phases 1-5 have been implemented locally. Phases 1-4 have been shipped through the local -> GitHub -> ChristIT update workflow; Phase 5 is ready for the next ship/update pass. The next active planning block is:
|
||||
Phases 1-7 have been implemented locally. Phases 1-5 have been shipped through the local -> GitHub -> ChristIT update workflow. Phase 6/7 is ready for the next ship/update pass. The next active planning block is:
|
||||
|
||||
6. Dispute workflow: better evidence timelines, reason-coded votes, scope-expansion and unreachable-contact reasons, quorum/visibility rules, and admin/community moderation tools.
|
||||
7. Notifications: channel preferences, email templates, and event-specific subscription controls.
|
||||
8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, contact/support failure logging, and mobile-safe work-order actions.
|
||||
9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history.
|
||||
10. Competency tags and levels: provider/buyer tag taxonomies, smart-hands entry lane, specialty tags such as network, POTS, POS, AV, cabling, installer, and troubleshooter, plus earned level badges based on completed work and reputation signals.
|
||||
@@ -83,6 +84,17 @@ Phase 5 implementation completed:
|
||||
- Review edits are limited by a configurable edit window.
|
||||
- Imported marketplace history is labeled separately from native TSE reputation.
|
||||
|
||||
Phase 6/7 implementation completed:
|
||||
|
||||
- Job posts now include structured scope fields for primary objective, included work, excluded work, maximum onsite expectations, duration, requirements, closeout conditions, equipment expectations, return shipment, access notes, restrictions, and supplemental instructions.
|
||||
- Supplemental instructions are explicitly treated as reference material that cannot override structured scope boundaries.
|
||||
- Job cards, job detail, and work orders show scope clarity, support certification, and computed risk badges.
|
||||
- Accepted quotes snapshot the job scope and contact/support commitments into the work order.
|
||||
- Change requests are first-class records with reason code, scope impact, schedule impact, terms impact, status, responder, and resolution notes.
|
||||
- Providers can log contact failed, support unavailable, site contact unavailable, or contact reached events with channel, time, result, and notes.
|
||||
- Work-order print packets include scope safeguards, contact/support coverage, change requests, and contact/support events.
|
||||
- Disputes and peer votes now support reason codes such as scope expansion, unreachable contact, support unavailable, payment issue, and insufficient evidence.
|
||||
|
||||
Available-work safeguards to carry into directory and job-list phases:
|
||||
|
||||
- Available jobs should expose pay type, provider terms, schedule type, work type, rough location, buyer reliability, support/contact certification, scope clarity status, and request/quote count.
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<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>
|
||||
@if ($dispute->reason_code)
|
||||
<p class="mt-2 text-sm font-semibold text-slate-700">Reason: {{ \App\Models\Dispute::REASON_CODES[$dispute->reason_code] ?? str_replace('_', ' ', $dispute->reason_code) }}</p>
|
||||
@endif
|
||||
<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>
|
||||
@@ -38,12 +41,21 @@
|
||||
<option value="split">Split resolution</option>
|
||||
<option value="insufficient_evidence">Insufficient evidence</option>
|
||||
</select>
|
||||
<label class="block text-sm font-medium">Primary reason</label>
|
||||
<select name="reason_code" class="rounded-md border-gray-300">
|
||||
@foreach (\App\Models\Dispute::REASON_CODES as $code => $label)
|
||||
<option value="{{ $code }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-field name="reason" label="Reason" textarea />
|
||||
<x-primary-button>Save vote</x-primary-button>
|
||||
</form>
|
||||
@foreach ($dispute->votes as $vote)
|
||||
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
|
||||
<p class="font-semibold">{{ $vote->user->name }} | {{ str_replace('_', ' ', $vote->recommendation) }}</p>
|
||||
@if ($vote->reason_code)
|
||||
<p class="text-gray-500">{{ \App\Models\Dispute::REASON_CODES[$vote->reason_code] ?? str_replace('_', ' ', $vote->reason_code) }}</p>
|
||||
@endif
|
||||
<p class="whitespace-pre-line">{{ $vote->reason }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -1,19 +1,92 @@
|
||||
<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">
|
||||
<form method="POST" action="{{ route('jobs.store') }}" class="space-y-6 rounded border bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
@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 />
|
||||
<section class="grid gap-4 md:grid-cols-2">
|
||||
<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" />
|
||||
<div>
|
||||
<label for="schedule_type" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Schedule type</label>
|
||||
<select id="schedule_type" name="schedule_type" 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 (['hard_start' => 'Hard start', 'flex_window' => 'Flexible window', 'appointment' => 'Appointment', 'remote' => 'Remote'] as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<label class="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="remote_eligible" value="1" class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
|
||||
Remote eligible
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Structured scope</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">These fields define the job. Supplemental instructions cannot override these boundaries.</p>
|
||||
</div>
|
||||
<x-field name="primary_objective" label="Primary objective" textarea />
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<x-field name="included_work" label="Included work" textarea />
|
||||
<x-field name="excluded_work" label="Excluded work" textarea />
|
||||
<x-field name="maximum_onsite_expectations" label="Maximum onsite expectations" textarea />
|
||||
<x-field name="expected_duration" label="Expected duration" />
|
||||
</div>
|
||||
<x-field name="scope" label="Legacy summary scope" textarea help="Short summary for compatibility with the first prototype views." />
|
||||
<x-field name="supplemental_instructions" label="Supplemental instructions" textarea help="Long instructions are preserved, but they are supplemental and do not create undefined onsite obligations." />
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Requirements and closeout</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<x-field name="required_skills" label="Required skills" textarea />
|
||||
<x-field name="required_tools" label="Required tools" textarea />
|
||||
<x-field name="required_certifications" label="Required certifications" textarea />
|
||||
<x-field name="required_safety_gear" label="Required safety gear" textarea />
|
||||
<x-field name="deliverables" label="Deliverables checklist" textarea />
|
||||
<x-field name="closeout_conditions" label="Closeout conditions" textarea />
|
||||
<x-field name="buyer_provided_equipment" label="Buyer-provided equipment" textarea />
|
||||
<x-field name="provider_provided_equipment" label="Provider-provided equipment" textarea />
|
||||
<x-field name="return_shipment_expectations" label="Return shipment expectations" textarea />
|
||||
<x-field name="parking_access_notes" label="Parking and access notes" textarea />
|
||||
<x-field name="onsite_restrictions" label="Onsite restrictions" textarea />
|
||||
<x-field name="vendor_onboarding" label="Vendor onboarding" textarea />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Contact and support availability</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Certify real support coverage for the scheduled work window before dispatch.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<x-field name="primary_contact_name" label="Primary contact name" />
|
||||
<x-field name="primary_contact_phone" label="Primary contact phone" />
|
||||
<x-field name="primary_contact_email" label="Primary contact email" type="email" />
|
||||
<x-field name="backup_contact_name" label="Backup contact name" />
|
||||
<x-field name="backup_contact_phone" label="Backup contact phone" />
|
||||
<x-field name="backup_contact_email" label="Backup contact email" type="email" />
|
||||
<x-field name="dispatch_contact_name" label="Dispatch contact name" />
|
||||
<x-field name="dispatch_contact_phone" label="Dispatch contact phone" />
|
||||
<x-field name="dispatch_contact_email" label="Dispatch contact email" type="email" />
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<x-field name="technical_bridge" label="Technical bridge or meeting link" />
|
||||
<x-field name="escalation_contact" label="Escalation contact" />
|
||||
<x-field name="support_channel" label="Support channel" />
|
||||
<x-field name="support_expected_response_time" label="Expected response time" />
|
||||
<x-field name="support_availability_window" label="Support availability window" />
|
||||
<label class="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="contact_certified" value="1" class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
|
||||
I certify these contacts and support channels will be available during the work window.
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -14,8 +14,23 @@
|
||||
@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>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">{{ $job->title }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-badge tone="{{ $job->scope_clarity_status === 'clear' ? 'emerald' : 'amber' }}">Scope {{ str_replace('_', ' ', $job->scope_clarity_status) }}</x-badge>
|
||||
<x-badge tone="{{ $job->contact_certified ? 'emerald' : 'rose' }}">{{ $job->contact_certified ? 'Support certified' : 'Support not certified' }}</x-badge>
|
||||
</div>
|
||||
</div>
|
||||
@if (is_array($job->risk_flags) && $job->risk_flags)
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($job->risk_flags as $flag)
|
||||
<x-badge tone="amber">{{ str_replace('_', ' ', $flag) }}</x-badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<p class="mt-2 line-clamp-2 text-sm">{{ $job->scope }}</p>
|
||||
<x-rating-summary :ratings="$job->ratings" />
|
||||
</a>
|
||||
|
||||
@@ -1,15 +1,81 @@
|
||||
<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>
|
||||
<section class="rounded border bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<x-badge tone="{{ $job->scope_clarity_status === 'clear' ? 'emerald' : 'amber' }}">Scope {{ str_replace('_', ' ', $job->scope_clarity_status) }}</x-badge>
|
||||
@if ($job->contact_certified)
|
||||
<x-badge tone="emerald">Support certified</x-badge>
|
||||
@else
|
||||
<x-badge tone="rose">Support not certified</x-badge>
|
||||
@endif
|
||||
@if ($job->remote_eligible)
|
||||
<x-badge tone="sky">Remote eligible</x-badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-600 dark:text-slate-400 sm:text-right">
|
||||
<p>{{ $job->starts_at?->format('M j, Y g:i A') ?? 'No start set' }}</p>
|
||||
<p>{{ $job->time_window ?: 'No window set' }} | {{ str_replace('_', ' ', $job->schedule_type ?: 'schedule not set') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (is_array($job->risk_flags) && $job->risk_flags)
|
||||
<div class="mt-4 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p class="font-semibold">Risk flags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($job->risk_flags as $flag)
|
||||
<x-badge tone="amber">{{ str_replace('_', ' ', $flag) }}</x-badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-md border border-sky-200 bg-sky-50 p-4 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100">
|
||||
<p class="font-semibold">Anti-catch-all rule</p>
|
||||
<p class="mt-1">The structured scope fields define this job. Supplemental instructions are preserved for reference, but they do not override included work, excluded work, closeout conditions, or change-request requirements.</p>
|
||||
</div>
|
||||
|
||||
<dl class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div><dt class="text-sm text-gray-500">Primary objective</dt><dd class="whitespace-pre-line">{{ $job->primary_objective ?: $job->scope }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Expected duration</dt><dd class="whitespace-pre-line">{{ $job->expected_duration ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Included work</dt><dd class="whitespace-pre-line">{{ $job->included_work ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Excluded work</dt><dd class="whitespace-pre-line">{{ $job->excluded_work ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Maximum onsite expectations</dt><dd class="whitespace-pre-line">{{ $job->maximum_onsite_expectations ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Closeout conditions</dt><dd class="whitespace-pre-line">{{ $job->closeout_conditions ?: 'Not specified' }}</dd></div>
|
||||
<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">Required certifications</dt><dd class="whitespace-pre-line">{{ $job->required_certifications ?: 'None listed' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Safety gear</dt><dd class="whitespace-pre-line">{{ $job->required_safety_gear ?: 'None listed' }}</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>
|
||||
<div><dt class="text-sm text-gray-500">Buyer-provided equipment</dt><dd class="whitespace-pre-line">{{ $job->buyer_provided_equipment ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Provider-provided equipment</dt><dd class="whitespace-pre-line">{{ $job->provider_provided_equipment ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Return shipment</dt><dd class="whitespace-pre-line">{{ $job->return_shipment_expectations ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Parking/access</dt><dd class="whitespace-pre-line">{{ $job->parking_access_notes ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Onsite restrictions</dt><dd class="whitespace-pre-line">{{ $job->onsite_restrictions ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Vendor onboarding</dt><dd class="whitespace-pre-line">{{ $job->vendor_onboarding ?: 'Not specified' }}</dd></div>
|
||||
</dl>
|
||||
@if ($job->supplemental_instructions)
|
||||
<details class="mt-6 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
|
||||
<summary class="cursor-pointer font-semibold text-slate-950 dark:text-white">Supplemental instructions</summary>
|
||||
<p class="mt-3 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $job->supplemental_instructions }}</p>
|
||||
</details>
|
||||
@endif
|
||||
<section class="mt-6 rounded-md border border-slate-200 p-4 dark:border-slate-800">
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Contact and support coverage</h3>
|
||||
<dl class="mt-3 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><dt class="text-gray-500">Primary contact</dt><dd>{{ $job->primary_contact_name ?: 'Not set' }}<br>{{ $job->primary_contact_phone }} {{ $job->primary_contact_email }}</dd></div>
|
||||
<div><dt class="text-gray-500">Backup contact</dt><dd>{{ $job->backup_contact_name ?: 'Not set' }}<br>{{ $job->backup_contact_phone }} {{ $job->backup_contact_email }}</dd></div>
|
||||
<div><dt class="text-gray-500">Dispatch contact</dt><dd>{{ $job->dispatch_contact_name ?: 'Not set' }}<br>{{ $job->dispatch_contact_phone }} {{ $job->dispatch_contact_email }}</dd></div>
|
||||
<div><dt class="text-gray-500">Technical bridge</dt><dd>{{ $job->technical_bridge ?: 'Not set' }}</dd></div>
|
||||
<div><dt class="text-gray-500">Escalation</dt><dd>{{ $job->escalation_contact ?: 'Not set' }}</dd></div>
|
||||
<div><dt class="text-gray-500">Support availability</dt><dd>{{ $job->support_availability_window ?: 'Not set' }}<br>{{ $job->support_channel }} {{ $job->support_expected_response_time }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<x-attachments :attachments="$job->attachments" />
|
||||
<x-rating-summary :ratings="$job->ratings" />
|
||||
<x-rating-form type="job_post" :id="$job->id" category="job_quality" />
|
||||
|
||||
@@ -39,6 +39,55 @@
|
||||
<p>{{ $workOrder->scheduled_at?->format('M j, Y g:i A') ?? 'Not set' }} | {{ $workOrder->appointment_window ?: 'No window set' }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Scope Safeguards</h2>
|
||||
<p class="muted">Scope clarity: {{ str_replace('_', ' ', $workOrder->scope_clarity_status) }}</p>
|
||||
@if (is_array($workOrder->risk_flags) && $workOrder->risk_flags)
|
||||
<p class="muted">Risk flags: {{ collect($workOrder->risk_flags)->map(fn ($flag) => str_replace('_', ' ', $flag))->implode(', ') }}</p>
|
||||
@endif
|
||||
<div class="grid">
|
||||
<div class="box">
|
||||
<h3>Primary Objective</h3>
|
||||
<p>{!! nl2br(e($workOrder->scopeSnapshotValue('primary_objective') ?: $workOrder->jobPost->scope)) !!}</p>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Included Work</h3>
|
||||
<p>{!! nl2br(e($workOrder->scopeSnapshotValue('included_work') ?: 'Not specified')) !!}</p>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Excluded Work</h3>
|
||||
<p>{!! nl2br(e($workOrder->scopeSnapshotValue('excluded_work') ?: 'Not specified')) !!}</p>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Closeout Conditions</h3>
|
||||
<p>{!! nl2br(e($workOrder->scopeSnapshotValue('closeout_conditions') ?: 'Not specified')) !!}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Contact And Support</h2>
|
||||
<p class="muted">Certified: {{ $workOrder->contactSnapshotValue('contact_certified') ? 'Yes' : 'No' }}</p>
|
||||
<div class="grid">
|
||||
<div class="box">
|
||||
<h3>Primary</h3>
|
||||
<p>{{ $workOrder->contactSnapshotValue('primary_contact_name') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('primary_contact_phone') }} {{ $workOrder->contactSnapshotValue('primary_contact_email') }}</p>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Backup</h3>
|
||||
<p>{{ $workOrder->contactSnapshotValue('backup_contact_name') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('backup_contact_phone') }} {{ $workOrder->contactSnapshotValue('backup_contact_email') }}</p>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Dispatch</h3>
|
||||
<p>{{ $workOrder->contactSnapshotValue('dispatch_contact_name') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('dispatch_contact_phone') }} {{ $workOrder->contactSnapshotValue('dispatch_contact_email') }}</p>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Escalation</h3>
|
||||
<p>{{ $workOrder->contactSnapshotValue('escalation_contact') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('support_channel') }} {{ $workOrder->contactSnapshotValue('support_expected_response_time') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Agreed Terms</h2>
|
||||
<p>{!! nl2br(e($workOrder->agreed_terms ?: 'No agreed terms recorded.')) !!}</p>
|
||||
@@ -67,6 +116,28 @@
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Change Requests</h2>
|
||||
<ul>
|
||||
@forelse ($workOrder->changeRequests() as $changeRequest)
|
||||
<li>{{ $changeRequest['summary'] }} | {{ $changeRequest['reason_label'] ?? 'Change' }} | {{ $changeRequest['status'] ?? 'open' }}</li>
|
||||
@empty
|
||||
<li>No change requests recorded.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Contact / Support Events</h2>
|
||||
<ul>
|
||||
@forelse ($workOrder->contactEvents as $event)
|
||||
<li>{{ \App\Models\WorkOrderContactEvent::EVENT_TYPES[$event->event_type] ?? str_replace('_', ' ', $event->event_type) }} | {{ $event->attempted_at?->format('M j, Y g:i A') ?? $event->created_at->format('M j, Y g:i A') }} | {{ $event->result ?: 'No result' }}</li>
|
||||
@empty
|
||||
<li>No contact or support failures recorded.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Attachments</h2>
|
||||
<ul>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
$progress = $workOrder->checklistProgress();
|
||||
$completed = $workOrder->checklistCompleted();
|
||||
$evidenceRules = is_array($workOrder->evidence_rules) ? $workOrder->evidence_rules : [];
|
||||
$riskFlags = is_array($workOrder->risk_flags) ? $workOrder->risk_flags : [];
|
||||
$changeReasonCodes = \App\Models\WorkOrderChangeRequest::REASON_CODES;
|
||||
$contactEventTypes = \App\Models\WorkOrderContactEvent::EVENT_TYPES;
|
||||
$disputeReasonCodes = \App\Models\Dispute::REASON_CODES;
|
||||
@endphp
|
||||
|
||||
<x-app-layout>
|
||||
@@ -34,6 +38,7 @@
|
||||
<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" />
|
||||
<x-stat-card label="Contact issues" :value="$workOrder->contactFailureCount()" description="Logged support failures" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[1.1fr_.9fr]">
|
||||
@@ -55,6 +60,49 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tse-panel p-6">
|
||||
<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">Scope and support safeguards</h3>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">Structured scope governs the work order. Supplemental instructions cannot create undefined onsite obligations.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-badge tone="{{ $workOrder->scope_clarity_status === 'clear' ? 'emerald' : 'amber' }}">Scope {{ str_replace('_', ' ', $workOrder->scope_clarity_status) }}</x-badge>
|
||||
@if ($workOrder->contactSnapshotValue('contact_certified'))
|
||||
<x-badge tone="emerald">Support certified</x-badge>
|
||||
@else
|
||||
<x-badge tone="rose">Support not certified</x-badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($riskFlags)
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach ($riskFlags as $flag)
|
||||
<x-badge tone="amber">{{ str_replace('_', ' ', $flag) }}</x-badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<dl class="mt-6 grid gap-4 text-sm md:grid-cols-2">
|
||||
<div><dt class="text-slate-500">Primary objective</dt><dd class="whitespace-pre-line text-slate-800 dark:text-slate-200">{{ $workOrder->scopeSnapshotValue('primary_objective') ?: $workOrder->jobPost->scope }}</dd></div>
|
||||
<div><dt class="text-slate-500">Expected duration</dt><dd class="whitespace-pre-line text-slate-800 dark:text-slate-200">{{ $workOrder->scopeSnapshotValue('expected_duration') ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Included work</dt><dd class="whitespace-pre-line text-slate-800 dark:text-slate-200">{{ $workOrder->scopeSnapshotValue('included_work') ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Excluded work</dt><dd class="whitespace-pre-line text-slate-800 dark:text-slate-200">{{ $workOrder->scopeSnapshotValue('excluded_work') ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Maximum onsite expectations</dt><dd class="whitespace-pre-line text-slate-800 dark:text-slate-200">{{ $workOrder->scopeSnapshotValue('maximum_onsite_expectations') ?: 'Not specified' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Closeout conditions</dt><dd class="whitespace-pre-line text-slate-800 dark:text-slate-200">{{ $workOrder->scopeSnapshotValue('closeout_conditions') ?: 'Not specified' }}</dd></div>
|
||||
</dl>
|
||||
<div class="mt-6 rounded-md border border-slate-200 p-4 dark:border-slate-800">
|
||||
<h4 class="font-semibold text-slate-950 dark:text-white">Certified contact path</h4>
|
||||
<dl class="mt-3 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><dt class="text-slate-500">Primary</dt><dd>{{ $workOrder->contactSnapshotValue('primary_contact_name') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('primary_contact_phone') }} {{ $workOrder->contactSnapshotValue('primary_contact_email') }}</dd></div>
|
||||
<div><dt class="text-slate-500">Backup</dt><dd>{{ $workOrder->contactSnapshotValue('backup_contact_name') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('backup_contact_phone') }} {{ $workOrder->contactSnapshotValue('backup_contact_email') }}</dd></div>
|
||||
<div><dt class="text-slate-500">Dispatch</dt><dd>{{ $workOrder->contactSnapshotValue('dispatch_contact_name') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('dispatch_contact_phone') }} {{ $workOrder->contactSnapshotValue('dispatch_contact_email') }}</dd></div>
|
||||
<div><dt class="text-slate-500">Technical bridge</dt><dd>{{ $workOrder->contactSnapshotValue('technical_bridge') ?: 'Not set' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Escalation</dt><dd>{{ $workOrder->contactSnapshotValue('escalation_contact') ?: 'Not set' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Support window</dt><dd>{{ $workOrder->contactSnapshotValue('support_availability_window') ?: 'Not set' }}<br>{{ $workOrder->contactSnapshotValue('support_channel') }} {{ $workOrder->contactSnapshotValue('support_expected_response_time') }}</dd></div>
|
||||
</dl>
|
||||
</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>
|
||||
@@ -129,18 +177,49 @@
|
||||
<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
|
||||
<div>
|
||||
<label for="reason_code" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Reason</label>
|
||||
<select id="reason_code" name="reason_code" 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 ($changeReasonCodes as $code => $label)
|
||||
<option value="{{ $code }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<x-field name="summary" label="Summary" />
|
||||
<x-field name="details" label="Details" textarea />
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<x-field name="scope_impact" label="Scope impact" textarea />
|
||||
<x-field name="schedule_impact" label="Schedule impact" textarea />
|
||||
<x-field name="terms_impact" label="Terms impact" textarea />
|
||||
</div>
|
||||
<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>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ $requestRecord['requested_by_name'] ?? 'User' }} | {{ $requestRecord['reason_label'] ?? 'Change' }} | {{ $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 class="mt-3 grid gap-3 text-xs md:grid-cols-3">
|
||||
<p><span class="font-semibold">Scope:</span> {{ $requestRecord['scope_impact'] ?? 'Not specified' }}</p>
|
||||
<p><span class="font-semibold">Schedule:</span> {{ $requestRecord['schedule_impact'] ?? 'Not specified' }}</p>
|
||||
<p><span class="font-semibold">Terms:</span> {{ $requestRecord['terms_impact'] ?? 'Not specified' }}</p>
|
||||
</div>
|
||||
@if (! empty($requestRecord['id']) && ($requestRecord['status'] ?? 'open') === 'open' && (auth()->id() !== ($requestRecord['requested_by_id'] ?? null) || auth()->user()->hasRole('admin')))
|
||||
<form method="POST" action="{{ route('work-orders.change-requests.resolve', [$workOrder, $requestRecord['id']]) }}" class="mt-3 grid gap-3 border-t border-slate-200 pt-3 dark:border-slate-800 md:grid-cols-[.6fr_1.4fr_auto]">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<select name="status" class="rounded-md border-slate-300 bg-white text-sm 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">
|
||||
<option value="accepted">Accept</option>
|
||||
<option value="declined">Decline</option>
|
||||
<option value="withdrawn">Withdraw</option>
|
||||
</select>
|
||||
<input name="resolution_notes" class="rounded-md border-slate-300 bg-white text-sm 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" placeholder="Resolution notes">
|
||||
<x-secondary-button type="submit">Update</x-secondary-button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">No change requests yet.</p>
|
||||
@@ -148,6 +227,43 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tse-panel p-6">
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Contact and support event log</h3>
|
||||
@if (auth()->id() === $workOrder->provider_id || auth()->user()->hasRole('admin'))
|
||||
<form method="POST" action="{{ route('work-orders.contact-events.store', $workOrder) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="event_type" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Event type</label>
|
||||
<select id="event_type" name="event_type" 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 ($contactEventTypes as $code => $label)
|
||||
<option value="{{ $code }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<x-field name="attempted_channel" label="Attempted channel" />
|
||||
<x-field name="attempted_at" label="Attempted at" type="datetime-local" />
|
||||
<x-field name="result" label="Result" />
|
||||
</div>
|
||||
<x-field name="notes" label="Notes" textarea />
|
||||
<x-secondary-button type="submit">Log contact/support event</x-secondary-button>
|
||||
</form>
|
||||
@endif
|
||||
<div class="mt-4 space-y-3">
|
||||
@forelse ($workOrder->contactEvents as $event)
|
||||
<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">{{ $contactEventTypes[$event->event_type] ?? str_replace('_', ' ', $event->event_type) }}</p>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ $event->user->name }} | {{ $event->attempted_at?->format('M j, Y g:i A') ?? $event->created_at->format('M j, Y g:i A') }} | {{ $event->attempted_channel ?: 'No channel' }} | {{ $event->result ?: 'No result' }}</p>
|
||||
@if ($event->notes)
|
||||
<p class="mt-2 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $event->notes }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">No contact/support events have been logged.</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">
|
||||
@@ -213,9 +329,11 @@
|
||||
<x-field name="rating" label="Overall rating 1-5" type="number" />
|
||||
</div>
|
||||
@foreach ($reviewFields as $field => $label)
|
||||
@php($definitionKey = $fieldDefinitionKeys[$field] ?? null)
|
||||
@php
|
||||
$definitionKey = $fieldDefinitionKeys[$field] ?? null;
|
||||
@endphp
|
||||
<div>
|
||||
<x-field name="{{ $field }}" label="{{ $label }} 1-5" type="number" />
|
||||
<x-field :name="$field" :label="$label.' 1-5'" type="number" />
|
||||
@if ($definitionKey && ! empty($definitions[$definitionKey]))
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ $definitions[$definitionKey] }}</p>
|
||||
@endif
|
||||
@@ -308,6 +426,14 @@
|
||||
<form method="POST" action="{{ route('disputes.store', $workOrder) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<x-field name="summary" label="Summary" />
|
||||
<div>
|
||||
<label for="dispute_reason_code" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Reason code</label>
|
||||
<select id="dispute_reason_code" name="reason_code" 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 ($disputeReasonCodes as $code => $label)
|
||||
<option value="{{ $code }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<x-field name="claim" label="Claim" textarea />
|
||||
<x-field name="evidence_notes" label="Evidence notes" textarea />
|
||||
<x-primary-button>Open dispute</x-primary-button>
|
||||
|
||||
@@ -81,6 +81,8 @@ Route::middleware('auth')->group(function () {
|
||||
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}/change-requests/{changeRequest}', [WorkOrderController::class, 'resolveChangeRequest'])->name('work-orders.change-requests.resolve');
|
||||
Route::post('/work-orders/{workOrder}/contact-events', [WorkOrderController::class, 'logContactEvent'])->name('work-orders.contact-events.store');
|
||||
Route::patch('/work-orders/{workOrder}/transition', [WorkOrderController::class, 'transition'])->name('work-orders.transition');
|
||||
Route::post('/work-orders/{workOrder}/messages', [WorkOrderMessageController::class, 'store'])->name('work-order-messages.store');
|
||||
Route::post('/work-orders/{workOrder}/reviews', [ReviewController::class, 'store'])->name('reviews.store');
|
||||
|
||||
@@ -206,11 +206,19 @@ class ExchangeWorkflowTest extends TestCase
|
||||
$this->assertFalse($workOrder->checklist_completed['Cable test']);
|
||||
|
||||
$this->actingAs($provider)->post("/work-orders/{$workOrder->id}/change-requests", [
|
||||
'reason_code' => 'scope_expansion',
|
||||
'summary' => 'Add second terminal',
|
||||
'details' => 'Buyer requested another POS terminal onsite.',
|
||||
'scope_impact' => 'Install and test one additional terminal.',
|
||||
'schedule_impact' => 'Adds one hour.',
|
||||
'terms_impact' => 'Requires added labor approval.',
|
||||
])->assertRedirect("/work-orders/{$workOrder->id}");
|
||||
|
||||
$this->assertDatabaseHas('work_orders', ['id' => $workOrder->id]);
|
||||
$this->assertDatabaseHas('work_order_change_requests', [
|
||||
'work_order_id' => $workOrder->id,
|
||||
'reason_code' => 'scope_expansion',
|
||||
'summary' => 'Add second terminal',
|
||||
]);
|
||||
$this->assertSame('Add second terminal', $workOrder->fresh()->changeRequests()[0]['summary']);
|
||||
|
||||
$this->actingAs($buyer)->get("/work-orders/{$workOrder->id}/print")
|
||||
@@ -219,6 +227,111 @@ class ExchangeWorkflowTest extends TestCase
|
||||
->assertSee('Print');
|
||||
}
|
||||
|
||||
public function test_scope_and_contact_safeguards_flow_into_work_orders(): void
|
||||
{
|
||||
$provider = $this->userWithRole('provider');
|
||||
$buyer = $this->userWithRole('buyer');
|
||||
|
||||
$this->actingAs($buyer)->post('/jobs', [
|
||||
'title' => 'Replace POS lane router',
|
||||
'service_category' => 'Point of Sale',
|
||||
'location' => 'Tulsa, OK',
|
||||
'starts_at' => now()->addDays(3)->format('Y-m-d H:i:s'),
|
||||
'time_window' => '9 AM - 12 PM',
|
||||
'schedule_type' => 'flex_window',
|
||||
'scope' => 'Replace the router and confirm lane connectivity.',
|
||||
'primary_objective' => 'Restore POS lane network connectivity.',
|
||||
'included_work' => 'Replace router, connect cables, verify connectivity.',
|
||||
'excluded_work' => 'No ceiling cabling or electrical work.',
|
||||
'maximum_onsite_expectations' => 'One router replacement and basic test.',
|
||||
'expected_duration' => 'Two hours',
|
||||
'required_skills' => 'POS networking',
|
||||
'required_tools' => 'Laptop and cable tester',
|
||||
'deliverables' => "Arrival photo\nCompletion photo",
|
||||
'closeout_conditions' => 'Buyer validates lanes online before closeout.',
|
||||
'payment_terms' => 'Direct ACH',
|
||||
'primary_contact_name' => 'Store Manager',
|
||||
'primary_contact_phone' => '555-0100',
|
||||
'backup_contact_name' => 'Dispatch',
|
||||
'backup_contact_phone' => '555-0101',
|
||||
'support_channel' => 'Phone',
|
||||
'support_expected_response_time' => '15 minutes',
|
||||
'support_availability_window' => 'Full appointment window',
|
||||
'contact_certified' => '1',
|
||||
'visibility' => 'public',
|
||||
])->assertRedirect();
|
||||
|
||||
$job = JobPost::where('title', 'Replace POS lane router')->firstOrFail();
|
||||
|
||||
$this->assertSame('clear', $job->scope_clarity_status);
|
||||
$this->assertTrue($job->contact_certified);
|
||||
$this->assertSame([], $job->risk_flags);
|
||||
|
||||
$this->actingAs($provider)->post("/jobs/{$job->id}/quotes", [
|
||||
'requested_amount' => 250,
|
||||
'rate_summary' => 'Flat project rate',
|
||||
'terms' => 'Scope exactly as posted.',
|
||||
])->assertRedirect("/jobs/{$job->id}");
|
||||
|
||||
$quote = $job->quotes()->firstOrFail();
|
||||
|
||||
$this->actingAs($buyer)->post("/quotes/{$quote->id}/accept")
|
||||
->assertRedirect();
|
||||
|
||||
$workOrder = $job->workOrder()->firstOrFail();
|
||||
|
||||
$this->assertSame('Restore POS lane network connectivity.', $workOrder->scope_snapshot['primary_objective']);
|
||||
$this->assertSame('Store Manager', $workOrder->contact_snapshot['primary_contact_name']);
|
||||
$this->assertSame('clear', $workOrder->scope_clarity_status);
|
||||
|
||||
$this->actingAs($provider)->post("/work-orders/{$workOrder->id}/contact-events", [
|
||||
'event_type' => 'support_unavailable',
|
||||
'attempted_channel' => 'Phone',
|
||||
'result' => 'No answer after two calls',
|
||||
'notes' => 'Support bridge did not answer during the certified window.',
|
||||
])->assertRedirect("/work-orders/{$workOrder->id}");
|
||||
|
||||
$this->assertDatabaseHas('work_order_contact_events', [
|
||||
'work_order_id' => $workOrder->id,
|
||||
'event_type' => 'support_unavailable',
|
||||
'result' => 'No answer after two calls',
|
||||
]);
|
||||
|
||||
$this->actingAs($provider)->get("/work-orders/{$workOrder->id}")
|
||||
->assertOk()
|
||||
->assertSee('Support unavailable')
|
||||
->assertSee('Restore POS lane network connectivity.');
|
||||
}
|
||||
|
||||
public function test_disputes_and_votes_accept_reason_codes(): void
|
||||
{
|
||||
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
|
||||
|
||||
$this->actingAs($provider)->post("/work-orders/{$workOrder->id}/disputes", [
|
||||
'summary' => 'Could not reach site contact',
|
||||
'reason_code' => 'unreachable_contact',
|
||||
'claim' => 'The listed contact did not answer.',
|
||||
'evidence_notes' => 'Two calls and one text attempted.',
|
||||
])->assertRedirect();
|
||||
|
||||
$dispute = $workOrder->disputes()->firstOrFail();
|
||||
|
||||
$this->actingAs($buyer)->post("/disputes/{$dispute->id}/votes", [
|
||||
'recommendation' => 'provider',
|
||||
'reason_code' => 'unreachable_contact',
|
||||
'reason' => 'The provider documented the contact failure.',
|
||||
])->assertRedirect("/disputes/{$dispute->id}");
|
||||
|
||||
$this->assertDatabaseHas('disputes', [
|
||||
'id' => $dispute->id,
|
||||
'reason_code' => 'unreachable_contact',
|
||||
]);
|
||||
$this->assertDatabaseHas('dispute_votes', [
|
||||
'dispute_id' => $dispute->id,
|
||||
'reason_code' => 'unreachable_contact',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_external_profile_snapshot_can_be_saved(): void
|
||||
{
|
||||
$provider = $this->userWithRole('provider');
|
||||
|
||||
Reference in New Issue
Block a user