Phase 8 and 9 marketplace matching

This commit is contained in:
Codex
2026-07-10 14:27:51 -05:00
parent 5009269907
commit 24f73084fa
21 changed files with 778 additions and 50 deletions
+2
View File
@@ -17,6 +17,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
- 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.
- Added Phase 8/9 marketplace matching with a dense available-work board, category/level/safety filters, technician level definitions, normalized taxonomy terms, provider tag assignment, and self-declared evidence-source tracking.
- 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.
@@ -35,6 +36,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`.
- `php artisan test` passed under Windows PHP with 48 tests and 228 assertions after Phase 8/9.
- `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`.
+43 -2
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\JobPost;
use App\Models\TaxonomyTerm;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -11,7 +12,8 @@ class JobPostController extends Controller
{
public function index(Request $request): View
{
$jobs = JobPost::with('buyer.buyerProfile', 'attachments', 'ratings.user');
$jobs = JobPost::with('buyer.buyerProfile', 'workCategory', 'workSpecialty', 'attachments', 'ratings.user')
->withCount('quotes');
if ($search = $request->string('q')->toString()) {
$jobs->where(fn ($query) => $query
@@ -26,8 +28,35 @@ class JobPostController extends Controller
$jobs->where('status', $status);
}
if ($category = $request->integer('work_category_id')) {
$jobs->where('work_category_id', $category);
}
if ($level = $request->integer('technician_level')) {
$jobs->where('required_technician_level', '<=', $level);
}
if ($scope = $request->string('scope_clarity')->toString()) {
$jobs->where('scope_clarity_status', $scope);
}
if ($request->boolean('support_certified')) {
$jobs->where('contact_certified', true);
}
if ($request->boolean('remote_only')) {
$jobs->where(fn ($query) => $query->where('remote_eligible', true)->orWhere('work_mode', 'remote'));
}
if ($request->boolean('hide_risky')) {
$jobs->where(fn ($query) => $query->whereNull('risk_flags')->orWhereJsonLength('risk_flags', 0));
}
return view('jobs.index', [
'jobs' => $jobs->latest()->paginate(20)->withQueryString(),
'categories' => TaxonomyTerm::where('type', 'work_category')->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
'technicianLevels' => config('technician-levels'),
'filters' => $request->only(['q', 'status', 'work_category_id', 'technician_level', 'scope_clarity', 'support_certified', 'remote_only', 'hide_risky']),
]);
}
@@ -35,7 +64,11 @@ class JobPostController extends Controller
{
abort_unless($request->user()->hasRole('buyer'), 403);
return view('jobs.create');
return view('jobs.create', [
'categories' => TaxonomyTerm::where('type', 'work_category')->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
'specialties' => TaxonomyTerm::where('type', 'work_specialty')->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
'technicianLevels' => config('technician-levels'),
]);
}
public function store(Request $request): RedirectResponse
@@ -45,6 +78,12 @@ class JobPostController extends Controller
$data = $request->validate([
'title' => ['required', 'string', 'max:255'],
'service_category' => ['nullable', 'string', 'max:255'],
'work_category_id' => ['nullable', 'integer', 'exists:taxonomy_terms,id'],
'work_specialty_id' => ['nullable', 'integer', 'exists:taxonomy_terms,id'],
'required_technician_level' => ['required', 'integer', 'min:1', 'max:5'],
'work_mode' => ['required', 'string', 'in:onsite,remote,hybrid'],
'pay_type' => ['nullable', 'string', 'in:fixed,hourly,blended,not_listed'],
'posted_terms_summary' => ['nullable', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'],
'starts_at' => ['nullable', 'date'],
'time_window' => ['nullable', 'string', 'max:255'],
@@ -110,6 +149,8 @@ class JobPostController extends Controller
return view('jobs.show', [
'job' => $job->load(
'buyer.buyerProfile',
'workCategory',
'workSpecialty',
'quotes.provider.providerProfile',
'quotes.revisions.user',
'workOrder',
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\ProviderProfile;
use App\Models\TaxonomyTerm;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -11,7 +12,7 @@ class ProviderProfileController extends Controller
{
public function index(Request $request): View
{
$profiles = ProviderProfile::with('user', 'externalImports', 'attachments', 'ratings.user')
$profiles = ProviderProfile::with('user', 'externalImports', 'taxonomyTerms', 'attachments', 'ratings.user')
->withAvg(['ratings as average_stars' => fn ($query) => $query->whereNotNull('stars')], 'stars')
->withCount('ratings');
@@ -32,6 +33,14 @@ class ProviderProfileController extends Controller
$profiles->where('skills', 'like', "%{$skill}%");
}
if ($level = $request->integer('technician_level')) {
$profiles->where('max_technician_level', '>=', $level);
}
if ($tag = $request->integer('taxonomy_term_id')) {
$profiles->whereHas('taxonomyTerms', fn ($query) => $query->whereKey($tag));
}
if ($insurance = $request->string('insurance')->toString()) {
$profiles->where('insurance_status', 'like', "%{$insurance}%");
}
@@ -48,7 +57,14 @@ class ProviderProfileController extends Controller
return view('providers.index', [
'profiles' => $profiles->paginate(20)->withQueryString(),
'filters' => $request->only(['q', 'service_area', 'skill', 'insurance', 'public_contact', 'sort']),
'filters' => $request->only(['q', 'service_area', 'skill', 'technician_level', 'taxonomy_term_id', 'insurance', 'public_contact', 'sort']),
'technicianLevels' => config('technician-levels'),
'taxonomyTerms' => TaxonomyTerm::whereIn('type', ['work_category', 'work_specialty', 'skill', 'tool', 'certification'])
->where('is_active', true)
->orderBy('type')
->orderBy('sort_order')
->orderBy('name')
->get(),
]);
}
@@ -57,7 +73,14 @@ class ProviderProfileController extends Controller
abort_unless($request->user()->hasRole('provider'), 403);
return view('providers.edit', [
'profile' => $request->user()->providerProfile?->load('attachments', 'externalImports.attachments'),
'profile' => $request->user()->providerProfile?->load('attachments', 'externalImports.attachments', 'taxonomyTerms'),
'technicianLevels' => config('technician-levels'),
'taxonomyTerms' => TaxonomyTerm::whereIn('type', ['work_category', 'work_specialty', 'skill', 'tool', 'certification'])
->where('is_active', true)
->orderBy('type')
->orderBy('sort_order')
->orderBy('name')
->get(),
]);
}
@@ -70,7 +93,10 @@ class ProviderProfileController extends Controller
'headline' => ['nullable', 'string', 'max:255'],
'bio' => ['nullable', 'string'],
'service_area' => ['nullable', 'string', 'max:255'],
'max_technician_level' => ['required', 'integer', 'min:1', 'max:5'],
'skills' => ['nullable', 'string'],
'taxonomy_terms' => ['nullable', 'array'],
'taxonomy_terms.*' => ['integer', 'exists:taxonomy_terms,id'],
'services_text' => ['nullable', 'string'],
'tools' => ['nullable', 'string'],
'tool_inventory_text' => ['nullable', 'string'],
@@ -97,18 +123,23 @@ class ProviderProfileController extends Controller
]);
unset($data['services_text'], $data['tool_inventory_text'], $data['certification_records_text']);
$request->user()->providerProfile()->updateOrCreate(
$profile = $request->user()->providerProfile()->updateOrCreate(
['user_id' => $request->user()->id],
$data
);
$sync = collect($data['taxonomy_terms'] ?? [])
->mapWithKeys(fn ($termId): array => [(int) $termId => ['evidence_source' => 'self_declared']])
->all();
$profile->taxonomyTerms()->sync($sync);
return redirect()->route('providers.edit')->with('status', 'Provider profile saved.');
}
public function show(ProviderProfile $provider): View
{
return view('providers.show', [
'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'),
'profile' => $provider->load('user', 'externalImports.attachments', 'taxonomyTerms', 'attachments', 'ratings.user'),
]);
}
+26 -1
View File
@@ -11,7 +11,8 @@ use Illuminate\Database\Eloquent\Model;
#[Fillable([
'buyer_id', 'title', 'status', 'service_category', 'location', 'starts_at',
'time_window', 'schedule_type', 'remote_eligible', 'scope', 'primary_objective',
'work_category_id', 'work_specialty_id', 'required_technician_level', 'work_mode',
'pay_type', 'posted_terms_summary', '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',
@@ -38,6 +39,7 @@ class JobPost extends Model
{
return [
'starts_at' => 'datetime',
'required_technician_level' => 'integer',
'remote_eligible' => 'boolean',
'risk_flags' => 'array',
'contact_certified' => 'boolean',
@@ -50,6 +52,16 @@ class JobPost extends Model
return $this->belongsTo(User::class, 'buyer_id');
}
public function workCategory(): BelongsTo
{
return $this->belongsTo(TaxonomyTerm::class, 'work_category_id');
}
public function workSpecialty(): BelongsTo
{
return $this->belongsTo(TaxonomyTerm::class, 'work_specialty_id');
}
public function quotes(): HasMany
{
return $this->hasMany(Quote::class);
@@ -95,6 +107,10 @@ class JobPost extends Model
'parking_access_notes' => $this->parking_access_notes,
'onsite_restrictions' => $this->onsite_restrictions,
'supplemental_instructions' => $this->supplemental_instructions,
'required_technician_level' => $this->required_technician_level,
'technician_level' => $this->technicianLevel()['name'],
'work_category' => $this->workCategory?->name,
'work_specialty' => $this->workSpecialty?->name,
];
}
@@ -152,6 +168,10 @@ class JobPost extends Model
$flags[] = 'compressed_schedule';
}
if ((int) $this->required_technician_level <= 1 && ($this->required_certifications || str_contains(strtolower((string) $this->primary_objective.' '.$this->included_work), 'troubleshoot'))) {
$flags[] = 'level_scope_mismatch';
}
return array_values(array_unique($flags));
}
@@ -173,4 +193,9 @@ class JobPost extends Model
return 'clear';
}
public function technicianLevel(): array
{
return config('technician-levels')[(int) $this->required_technician_level] ?? config('technician-levels')[1];
}
}
+15 -1
View File
@@ -4,13 +4,14 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'user_id', 'business_name', 'headline', 'bio', 'service_area', 'skills',
'services', 'tools', 'tool_inventory', 'certifications', 'certification_records',
'max_technician_level', 'services', 'tools', 'tool_inventory', 'certifications', 'certification_records',
'insurance_status', 'rate_card', 'travel_policy', 'availability_notes',
'website_url', 'phone', 'public_contact', 'profile_visibility', 'private_notes',
])]
@@ -24,6 +25,7 @@ class ProviderProfile extends Model
'certification_records' => 'array',
'profile_visibility' => 'array',
'public_contact' => 'boolean',
'max_technician_level' => 'integer',
];
}
@@ -47,6 +49,18 @@ class ProviderProfile extends Model
return $this->morphMany(Rating::class, 'rateable');
}
public function taxonomyTerms(): BelongsToMany
{
return $this->belongsToMany(TaxonomyTerm::class)
->withPivot('evidence_source')
->withTimestamps();
}
public function technicianLevel(): array
{
return config('technician-levels')[(int) $this->max_technician_level] ?? config('technician-levels')[1];
}
public function visible(string $field): bool
{
$visibility = $this->profile_visibility ?? [];
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['parent_id', 'type', 'name', 'slug', 'description', 'sort_order', 'is_active'])]
class TaxonomyTerm extends Model
{
public const TYPES = [
'work_category',
'work_specialty',
'skill',
'tool',
'certification',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function providerProfiles(): BelongsToMany
{
return $this->belongsToMany(ProviderProfile::class)
->withPivot('evidence_source')
->withTimestamps();
}
}
+7
View File
@@ -198,4 +198,11 @@ class WorkOrder extends Model
->whereIn('event_type', ['contact_failed', 'support_unavailable', 'site_contact_unavailable'])
->count();
}
public function technicianLevel(): array
{
$level = $this->scopeSnapshotValue('required_technician_level') ?? $this->jobPost?->required_technician_level ?? 1;
return config('technician-levels')[(int) $level] ?? config('technician-levels')[1];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
return [
1 => [
'name' => 'Level 1 - Smart hands',
'short_name' => 'Smart hands',
'description' => 'Follows clear remote direction, performs basic onsite tasks, takes photos, swaps labeled equipment, and reports what they see. Scope must be tightly defined and should not require independent diagnosis.',
'scope_rule' => 'Use for simple, clearly documented tasks with step-by-step instructions and available support.',
],
2 => [
'name' => 'Level 2 - Installer',
'short_name' => 'Installer',
'description' => 'Installs or replaces equipment independently, follows a defined plan, verifies operation, and completes normal closeout. Scope should identify the equipment, expected result, and test path.',
'scope_rule' => 'Use for planned installs, swaps, turn-ups, and closeouts where the desired end state is known.',
],
3 => [
'name' => 'Level 3 - Troubleshooter',
'short_name' => 'Troubleshooter',
'description' => 'Works semi-independently when the exact fault is unknown, gathers evidence, isolates likely causes, and attempts practical remediation within an agreed boundary.',
'scope_rule' => 'Use when the problem is not fully known, but the buyer can define systems involved, support path, and escalation limits.',
],
4 => [
'name' => 'Level 4 - Specialist',
'short_name' => 'Specialist',
'description' => 'Handles advanced or vendor-specific systems, complex integrations, certification-heavy tasks, or work where mistakes carry higher operational risk.',
'scope_rule' => 'Use when specialty knowledge, verified credentials, or deeper system ownership is required.',
],
5 => [
'name' => 'Level 5 - Project lead',
'short_name' => 'Project lead',
'description' => 'Coordinates multi-tech, multi-site, or high-ambiguity projects, manages onsite decisions, documents changes, and handles buyer/provider coordination.',
'scope_rule' => 'Use for leadership, field coordination, cutovers, escalations, or projects where onsite judgment is central.',
],
];
@@ -0,0 +1,65 @@
<?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('taxonomy_terms', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('taxonomy_terms')->cascadeOnDelete();
$table->string('type');
$table->string('name');
$table->string('slug');
$table->text('description')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['type', 'slug']);
$table->index(['type', 'is_active', 'sort_order']);
});
Schema::create('provider_profile_taxonomy_term', function (Blueprint $table) {
$table->id();
$table->foreignId('provider_profile_id')->constrained()->cascadeOnDelete();
$table->foreignId('taxonomy_term_id')->constrained()->cascadeOnDelete();
$table->string('evidence_source')->default('self_declared');
$table->timestamps();
$table->unique(['provider_profile_id', 'taxonomy_term_id']);
});
Schema::table('provider_profiles', function (Blueprint $table) {
$table->unsignedTinyInteger('max_technician_level')->default(1)->after('service_area');
});
Schema::table('job_posts', function (Blueprint $table) {
$table->foreignId('work_category_id')->nullable()->after('service_category')->constrained('taxonomy_terms')->nullOnDelete();
$table->foreignId('work_specialty_id')->nullable()->after('work_category_id')->constrained('taxonomy_terms')->nullOnDelete();
$table->unsignedTinyInteger('required_technician_level')->default(1)->after('work_specialty_id');
$table->string('work_mode')->default('onsite')->after('required_technician_level');
$table->string('pay_type')->nullable()->after('work_mode');
$table->string('posted_terms_summary')->nullable()->after('pay_type');
});
}
public function down(): void
{
Schema::table('job_posts', function (Blueprint $table) {
$table->dropConstrainedForeignId('work_category_id');
$table->dropConstrainedForeignId('work_specialty_id');
$table->dropColumn(['required_technician_level', 'work_mode', 'pay_type', 'posted_terms_summary']);
});
Schema::table('provider_profiles', function (Blueprint $table) {
$table->dropColumn('max_technician_level');
});
Schema::dropIfExists('provider_profile_taxonomy_term');
Schema::dropIfExists('taxonomy_terms');
}
};
+137
View File
@@ -4,6 +4,7 @@ namespace Database\Seeders;
use App\Models\User;
use App\Models\JobPost;
use App\Models\TaxonomyTerm;
use App\Models\WorkOrder;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -19,6 +20,8 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
$taxonomy = $this->seedTaxonomy();
$roles = ['admin', 'provider', 'buyer'];
foreach ($roles as $role) {
@@ -60,6 +63,7 @@ class DatabaseSeeder extends Seeder
'headline' => 'POS, networking, cabling, and onsite IT support',
'bio' => 'Demo provider profile for the technician-owned exchange.',
'service_area' => 'Central US with regional travel',
'max_technician_level' => 3,
'skills' => "POS\nNetworking\nStructured cabling\nPrinter support",
'services' => [
['name' => 'Smart hands dispatch', 'level' => 'experienced'],
@@ -92,6 +96,15 @@ class DatabaseSeeder extends Seeder
]
);
$providerProfile->taxonomyTerms()->syncWithoutDetaching([
$taxonomy['categories']['point-of-sale']->id => ['evidence_source' => 'self_declared'],
$taxonomy['categories']['server-networking']->id => ['evidence_source' => 'self_declared'],
$taxonomy['specialties']['pos']->id => ['evidence_source' => 'self_declared'],
$taxonomy['specialties']['wireless-networking']->id => ['evidence_source' => 'self_declared'],
$taxonomy['skills']['well-tooled-mobile']->id => ['evidence_source' => 'self_declared'],
$taxonomy['tools']['cable-tester']->id => ['evidence_source' => 'self_declared'],
]);
$providerProfile->externalImports()->updateOrCreate(
['platform' => 'Field Nation', 'external_id' => '172-630'],
[
@@ -140,14 +153,27 @@ class DatabaseSeeder extends Seeder
'buyer_id' => $buyer->id,
'status' => 'assigned',
'service_category' => 'POS',
'work_category_id' => $taxonomy['categories']['point-of-sale']->id,
'work_specialty_id' => $taxonomy['specialties']['pos']->id,
'required_technician_level' => 3,
'work_mode' => 'onsite',
'pay_type' => 'fixed',
'posted_terms_summary' => 'Two-hour minimum plus travel if needed',
'location' => 'Tulsa, OK',
'time_window' => 'Morning',
'scope' => 'Troubleshoot intermittent POS terminal connectivity.',
'primary_objective' => 'Restore intermittent POS terminal connectivity.',
'included_work' => 'Inspect lane cabling, network path, and terminal connectivity.',
'excluded_work' => 'No electrical work or ceiling cabling unless accepted as a change request.',
'closeout_conditions' => 'Buyer validates that the terminal remains online.',
'required_skills' => 'POS and basic networking',
'required_tools' => 'Laptop, patch cables, toner/probe',
'deliverables' => "Check in\nPhotos\nResolution notes",
'payment_terms' => 'Direct buyer/provider payment terms.',
'vendor_onboarding' => 'Buyer handles direct onboarding.',
'contact_certified' => true,
'scope_clarity_status' => 'clear',
'risk_flags' => [],
'visibility' => 'public',
]
);
@@ -174,6 +200,10 @@ class DatabaseSeeder extends Seeder
'deliverables_checklist' => $job->deliverables,
'scheduled_at' => $job->starts_at,
'appointment_window' => $job->time_window,
'scope_snapshot' => $job->scopeSnapshot(),
'contact_snapshot' => $job->contactSnapshot(),
'scope_clarity_status' => $job->scope_clarity_status,
'risk_flags' => $job->risk_flags,
'checklist_items' => ['Check in', 'Photos', 'Resolution notes'],
'required_evidence' => $job->deliverables,
'evidence_rules' => ['Arrival photo', 'Completion photo'],
@@ -196,4 +226,111 @@ class DatabaseSeeder extends Seeder
]
);
}
private function seedTaxonomy(): array
{
$categories = [
'access-alarms' => 'Access & Alarms',
'av-digital-signage' => 'A/V & Digital Signage',
'cameras' => 'Cameras',
'ev-equipment' => 'EV Equipment',
'fiber-cabling' => 'Fiber Cabling',
'kiosk-atm' => 'Kiosk / ATM',
'low-voltage-cabling' => 'Low Voltage Cabling',
'office-equipment' => 'Office Equipment',
'point-of-sale' => 'Point of Sale',
'retail-services' => 'Retail Services',
'server-networking' => 'Server & Networking',
'telecom' => 'Telecom',
'other-trades' => 'Other Trades',
];
$specialties = [
'pos' => ['Point of Sale', 'point-of-sale'],
'self-checkout' => ['Self-checkout', 'point-of-sale'],
'networking' => ['Networking', 'server-networking'],
'wireless-networking' => ['Wireless networking', 'server-networking'],
'server-storage' => ['Server/storage', 'server-networking'],
'low-voltage-runs' => ['Low voltage runs', 'low-voltage-cabling'],
'low-voltage-testing' => ['Low voltage testing', 'low-voltage-cabling'],
'fiber-testing' => ['Fiber testing', 'fiber-cabling'],
'pots' => ['POTS', 'telecom'],
'voip-sip' => ['VoIP/SIP', 'telecom'],
'digital-signage' => ['Digital signage', 'av-digital-signage'],
'ip-camera' => ['IP camera', 'cameras'],
'printer' => ['Printer', 'office-equipment'],
];
$skills = [
'well-tooled-mobile' => 'Well-tooled mobile',
'well-prepared' => 'Well-prepared',
'well-experienced' => 'Well-experienced',
'smart-hands-ready' => 'Smart-hands ready',
'independent-installer' => 'Independent installer',
'field-troubleshooter' => 'Field troubleshooter',
];
$tools = [
'laptop' => 'Laptop',
'cable-tester' => 'Cable tester',
'toner-probe' => 'Toner/probe',
'labeler' => 'Labeler',
'basic-hand-tools' => 'Basic hand tools',
];
$certifications = [
'low-voltage-license' => 'Low-voltage license',
'comptia-a-plus' => 'CompTIA A+',
'network-plus' => 'Network+',
'manufacturer-certification' => 'Manufacturer certification',
];
$categoryRecords = [];
foreach ($categories as $slug => $name) {
$categoryRecords[$slug] = TaxonomyTerm::updateOrCreate(
['type' => 'work_category', 'slug' => $slug],
['name' => $name, 'sort_order' => count($categoryRecords) + 1, 'is_active' => true]
);
}
$specialtyRecords = [];
foreach ($specialties as $slug => [$name, $parentSlug]) {
$specialtyRecords[$slug] = TaxonomyTerm::updateOrCreate(
['type' => 'work_specialty', 'slug' => $slug],
['name' => $name, 'parent_id' => $categoryRecords[$parentSlug]->id ?? null, 'sort_order' => count($specialtyRecords) + 1, 'is_active' => true]
);
}
$skillRecords = [];
foreach ($skills as $slug => $name) {
$skillRecords[$slug] = TaxonomyTerm::updateOrCreate(
['type' => 'skill', 'slug' => $slug],
['name' => $name, 'sort_order' => count($skillRecords) + 1, 'is_active' => true]
);
}
$toolRecords = [];
foreach ($tools as $slug => $name) {
$toolRecords[$slug] = TaxonomyTerm::updateOrCreate(
['type' => 'tool', 'slug' => $slug],
['name' => $name, 'sort_order' => count($toolRecords) + 1, 'is_active' => true]
);
}
$certificationRecords = [];
foreach ($certifications as $slug => $name) {
$certificationRecords[$slug] = TaxonomyTerm::updateOrCreate(
['type' => 'certification', 'slug' => $slug],
['name' => $name, 'sort_order' => count($certificationRecords) + 1, 'is_active' => true]
);
}
return [
'categories' => $categoryRecords,
'specialties' => $specialtyRecords,
'skills' => $skillRecords,
'tools' => $toolRecords,
'certifications' => $certificationRecords,
];
}
}
+31 -6
View File
@@ -32,7 +32,7 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa
- 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 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.
- Work-order detail now supports contact/support failure logging and first-class change requests, but schedule update requests, running-late records, post-work tag verification, 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.
@@ -310,6 +310,14 @@ Provider-protection rules:
- Jobs should not rank purely by buyer preference or low provider rate.
- Risk flags should be visible before a provider opens the work order.
Implementation status:
- Completed first implementation pass alongside Phase 9.
- Replaced the jobs list with a denser available-work board.
- Added filters for category, technician level, scope clarity, support certification, remote eligibility, and hide risky jobs.
- Added columns for buyer, location, category, specialty, technician level, schedule, work mode, pay type, terms summary, quote count, scope clarity, support certification, and risk badges.
- Verified with Windows `php artisan test`.
## Phase 9 - Taxonomy, Competency Tags, And Profile Evidence
Purpose:
@@ -397,6 +405,23 @@ Provider-protection rules:
- Buyers should not be able to demand advanced work while labeling it smart hands.
- Certification proof should be controlled by the provider and privacy-aware.
Implementation status:
- Completed first implementation pass alongside Phase 8.
- Added `taxonomy_terms` for categories, specialties, skills, tools, and certifications.
- Added provider/tag pivot records with evidence sources.
- Added starter seed taxonomy for field-service categories and common traits/tools/certifications.
- Added explicit technician level definitions:
- Level 1 smart hands,
- Level 2 installer,
- Level 3 troubleshooter,
- Level 4 specialist,
- Level 5 project lead.
- Added provider maximum technician level and self-declared tags in profile editing.
- Added buyer job technician-level selection and scope mismatch risk detection for smart-hands jobs that include troubleshooting or certification requirements.
- Added provider directory filters for technician level and taxonomy tag.
- Deferred buyer post-work tag endorsement/disagreement to the next verification pass.
## Phase 10 - Import Tooling And Marketplace History
Purpose:
@@ -682,11 +707,11 @@ Provider-protection rules:
## Recommended Immediate Sequence
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.
1. Post-work provider tag verification: buyer endorsement/disagreement with self-declared level and tags after completed work orders.
2. Phase 10A: guided imported-history wizard.
3. Phase 11A: mobile-safe API endpoints for onsite work-order actions.
4. Phase 12A: notification preference UI and event-channel controls.
5. Phase 13A: audit logs and moderation/operations queues.
## Key Design Commitments
+22 -3
View File
@@ -16,6 +16,9 @@ Provider Exchange is a Laravel application for a provider-centered field-service
- 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.
- Dense available-work board with filters for category, technician level, scope clarity, support certification, remote eligibility, and risk suppression.
- Normalized starter taxonomy for work categories, specialties, skills, tools, certifications, and provider tag evidence sources.
- Explicit technician level ladder from smart hands through project lead.
- 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.
@@ -46,18 +49,18 @@ Provider Exchange is a Laravel application for a provider-centered field-service
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.
10. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history.
11. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows.
12. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes.
13. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability.
## Phase Checkpoint
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:
Phases 1-9 have been implemented locally through the combined Phase 8/9 marketplace matching pass. The next active planning block is:
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.
10. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history.
Work-order safeguards to carry into the next implementation passes:
@@ -95,6 +98,22 @@ Phase 6/7 implementation completed:
- 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.
Phase 8/9 implementation completed:
- The available jobs page is now a dense marketplace board with compact filters for work category, technician level, scope clarity, support certification, remote eligibility, and hide-risky mode.
- Job rows expose buyer, location, category, specialty, requested technician level, schedule, pay type, posted terms summary, quote count, scope clarity, support certification, and risk badges.
- Technician levels are defined in `config/technician-levels.php`:
- Level 1 smart hands,
- Level 2 installer,
- Level 3 troubleshooter,
- Level 4 specialist,
- Level 5 project lead.
- Buyer job creation now requires a technician level and work mode, and can select normalized category/specialty plus pay type and posted terms summary.
- The app now has normalized taxonomy terms for work categories, specialties, skills, tools, and certifications.
- Provider profiles can declare a maximum technician level and self-declared taxonomy tags.
- Provider directory filters can match technician level and taxonomy tags.
- Taxonomy tag pivots track an evidence source, currently `self_declared`, with room for buyer endorsed, completed work, certification verified, and admin verified 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.
+45
View File
@@ -6,6 +6,41 @@
<section class="grid gap-4 md:grid-cols-2">
<x-field name="title" label="Title" />
<x-field name="service_category" label="Service category" />
<div>
<label for="work_category_id" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Work category</label>
<select id="work_category_id" name="work_category_id" 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">
<option value="">Choose category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</select>
</div>
<div>
<label for="work_specialty_id" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Specialty</label>
<select id="work_specialty_id" name="work_specialty_id" 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">
<option value="">Choose specialty</option>
@foreach ($specialties as $specialty)
<option value="{{ $specialty->id }}">{{ $specialty->name }}</option>
@endforeach
</select>
</div>
<div>
<label for="required_technician_level" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Technician level requested</label>
<select id="required_technician_level" name="required_technician_level" 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 ($technicianLevels as $level => $definition)
<option value="{{ $level }}">{{ $definition['name'] }}</option>
@endforeach
</select>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">The posted scope should match this level. Smart-hands work needs clear instructions and active support.</p>
</div>
<div>
<label for="work_mode" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Work mode</label>
<select id="work_mode" name="work_mode" 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">
<option value="onsite">Onsite</option>
<option value="remote">Remote</option>
<option value="hybrid">Hybrid</option>
</select>
</div>
<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" />
@@ -21,6 +56,16 @@
<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>
<div>
<label for="pay_type" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Pay type</label>
<select id="pay_type" name="pay_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">
<option value="not_listed">Not listed</option>
<option value="fixed">Fixed</option>
<option value="hourly">Hourly</option>
<option value="blended">Blended</option>
</select>
</div>
<x-field name="posted_terms_summary" label="Posted terms summary" />
</section>
<section class="space-y-4">
+96 -32
View File
@@ -1,40 +1,104 @@
<x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Jobs</h2></x-slot>
<div class="mx-auto max-w-7xl space-y-4 p-6">
<form method="GET" action="{{ route('jobs.index') }}" class="grid gap-3 rounded border bg-white p-4 md:grid-cols-3">
<input name="q" value="{{ request('q') }}" placeholder="Search jobs..." class="rounded-md border-gray-300">
<select name="status" class="rounded-md border-gray-300">
<option value="">Any status</option>
@foreach (['open', 'assigned', 'closed', 'cancelled'] as $status)
<option value="{{ $status }}" @selected(request('status') === $status)>{{ $status }}</option>
@endforeach
</select>
<x-primary-button>Search</x-primary-button>
<form method="GET" action="{{ route('jobs.index') }}" class="rounded border bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div class="grid gap-3 lg:grid-cols-6">
<input name="q" value="{{ request('q') }}" placeholder="Search jobs..." class="rounded-md border-gray-300 lg:col-span-2">
<select name="work_category_id" class="rounded-md border-gray-300">
<option value="">Any category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}" @selected((int) request('work_category_id') === $category->id)>{{ $category->name }}</option>
@endforeach
</select>
<select name="technician_level" class="rounded-md border-gray-300">
<option value="">Any tech level</option>
@foreach ($technicianLevels as $level => $definition)
<option value="{{ $level }}" @selected((int) request('technician_level') === $level)>Up to {{ $definition['short_name'] }}</option>
@endforeach
</select>
<select name="scope_clarity" class="rounded-md border-gray-300">
<option value="">Any clarity</option>
@foreach (\App\Models\JobPost::SCOPE_CLARITY_STATUSES as $value => $label)
<option value="{{ $value }}" @selected(request('scope_clarity') === $value)>{{ $label }}</option>
@endforeach
</select>
<select name="status" class="rounded-md border-gray-300">
<option value="">Any status</option>
@foreach (['open', 'assigned', 'closed', 'cancelled'] as $status)
<option value="{{ $status }}" @selected(request('status') === $status)>{{ $status }}</option>
@endforeach
</select>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="support_certified" value="1" @checked(request()->boolean('support_certified')) class="rounded border-slate-300 text-sky-600">
Support certified
</label>
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="remote_only" value="1" @checked(request()->boolean('remote_only')) class="rounded border-slate-300 text-sky-600">
Remote eligible
</label>
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="hide_risky" value="1" @checked(request()->boolean('hide_risky')) class="rounded border-slate-300 text-sky-600">
Hide risky jobs
</label>
<x-primary-button>Filter</x-primary-button>
<a href="{{ route('jobs.index') }}" class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">Reset</a>
</div>
</form>
@auth @role('buyer')<a class="rounded bg-indigo-600 px-4 py-2 text-white" href="{{ route('jobs.create') }}">Post job</a>@endrole @endauth
@foreach ($jobs as $job)
<a href="{{ route('jobs.show', $job) }}" class="block rounded border bg-white p-4 shadow-sm">
<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>
@endforeach
<div class="overflow-hidden rounded border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<table class="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500 dark:bg-slate-950 dark:text-slate-400">
<tr>
<th class="px-4 py-3">Job</th>
<th class="px-4 py-3">Category</th>
<th class="px-4 py-3">Level</th>
<th class="px-4 py-3">Schedule</th>
<th class="px-4 py-3">Terms</th>
<th class="px-4 py-3">Signals</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
@forelse ($jobs as $job)
<tr class="align-top">
<td class="px-4 py-4">
<a href="{{ route('jobs.show', $job) }}" class="font-semibold text-slate-950 hover:text-sky-700 dark:text-white">{{ $job->title }}</a>
<p class="mt-1 text-xs text-slate-500">{{ $job->buyer->buyerProfile?->company_name ?? $job->buyer->name }} | {{ $job->location }}</p>
<p class="mt-2 line-clamp-2 text-slate-600 dark:text-slate-400">{{ $job->primary_objective ?: $job->scope }}</p>
</td>
<td class="px-4 py-4">
<p>{{ $job->workCategory?->name ?? $job->service_category ?? 'Uncategorized' }}</p>
<p class="text-xs text-slate-500">{{ $job->workSpecialty?->name }}</p>
</td>
<td class="px-4 py-4">
<x-badge tone="sky">{{ $job->technicianLevel()['short_name'] }}</x-badge>
</td>
<td class="px-4 py-4">
<p>{{ $job->starts_at?->format('M j g:i A') ?? 'No start' }}</p>
<p class="text-xs text-slate-500">{{ $job->time_window ?: 'No window' }} | {{ str_replace('_', ' ', $job->work_mode ?: 'onsite') }}</p>
</td>
<td class="px-4 py-4">
<p>{{ str_replace('_', ' ', $job->pay_type ?: 'not listed') }}</p>
<p class="text-xs text-slate-500">{{ $job->posted_terms_summary ?: $job->payment_terms }}</p>
<p class="text-xs text-slate-500">{{ $job->quotes_count }} quote{{ $job->quotes_count === 1 ? '' : 's' }}</p>
</td>
<td class="px-4 py-4">
<div class="flex flex-wrap gap-2">
<x-badge tone="{{ $job->scope_clarity_status === 'clear' ? 'emerald' : 'amber' }}">{{ str_replace('_', ' ', $job->scope_clarity_status) }}</x-badge>
<x-badge tone="{{ $job->contact_certified ? 'emerald' : 'rose' }}">{{ $job->contact_certified ? 'Support certified' : 'No support cert' }}</x-badge>
@foreach (array_slice(is_array($job->risk_flags) ? $job->risk_flags : [], 0, 3) as $flag)
<x-badge tone="amber">{{ str_replace('_', ' ', $flag) }}</x-badge>
@endforeach
</div>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No jobs matched these filters.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $jobs->links() }}
</div>
</x-app-layout>
+7
View File
@@ -7,6 +7,10 @@
<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>
<x-badge tone="sky">{{ $job->technicianLevel()['name'] }}</x-badge>
@if ($job->workCategory)
<x-badge tone="slate">{{ $job->workCategory->name }}</x-badge>
@endif
@if ($job->contact_certified)
<x-badge tone="emerald">Support certified</x-badge>
@else
@@ -20,6 +24,7 @@
<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>
<p>{{ str_replace('_', ' ', $job->work_mode ?: 'onsite') }} | {{ str_replace('_', ' ', $job->pay_type ?: 'pay not listed') }}</p>
</div>
</div>
@@ -41,6 +46,8 @@
<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">Technician level</dt><dd class="whitespace-pre-line">{{ $job->technicianLevel()['description'] }}</dd></div>
<div><dt class="text-sm text-gray-500">Level scope rule</dt><dd class="whitespace-pre-line">{{ $job->technicianLevel()['scope_rule'] }}</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>
+22
View File
@@ -4,6 +4,7 @@
$certsText = collect($profile?->certification_records ?? [])->map(fn ($row) => trim(($row['name'] ?? '').' | '.($row['issuer'] ?? ''), ' |'))->implode("\n");
$visibility = $profile?->profile_visibility ?? [];
$visible = fn (string $field): bool => (bool) ($visibility[$field] ?? true);
$selectedTaxonomy = $profile?->taxonomyTerms?->pluck('id')->all() ?? [];
@endphp
<x-app-layout>
@@ -22,7 +23,28 @@
<x-field name="headline" label="Headline" :value="$profile?->headline" />
<x-field name="bio" label="Bio" :value="$profile?->bio" textarea />
<x-field name="service_area" label="Service area" :value="$profile?->service_area" />
<div>
<label for="max_technician_level" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Maximum technician level</label>
<select id="max_technician_level" name="max_technician_level" 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 ($technicianLevels as $level => $definition)
<option value="{{ $level }}" @selected((int) ($profile?->max_technician_level ?? 1) === $level)>{{ $definition['name'] }}</option>
@endforeach
</select>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">This is self-declared until native work history, buyer endorsements, certifications, or admin verification support it.</p>
</div>
<x-field name="skills" label="Skills summary" :value="$profile?->skills" textarea />
<div class="rounded-md border border-slate-200 p-4 dark:border-slate-800">
<h3 class="font-semibold text-slate-950 dark:text-white">Self-declared tags and evidence</h3>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Choose categories, specialties, skills, tools, and certifications that describe your work. Later passes can let buyers endorse or challenge these after completed work orders.</p>
<div class="mt-3 grid gap-2 md:grid-cols-2">
@foreach ($taxonomyTerms as $term)
<label class="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 dark:border-slate-800 dark:text-slate-300">
<input type="checkbox" name="taxonomy_terms[]" value="{{ $term->id }}" @checked(in_array($term->id, $selectedTaxonomy, true)) class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
<span>{{ $term->name }} <span class="text-xs text-slate-500">({{ str_replace('_', ' ', $term->type) }})</span></span>
</label>
@endforeach
</div>
</div>
<x-field name="services_text" label="Structured services" :value="$servicesText" textarea help="One per line. Use: service name | level, for example POS install | experienced." />
<x-field name="tools" label="Tools and equipment summary" :value="$profile?->tools" textarea />
<x-field name="tool_inventory_text" label="Tool inventory" :value="$toolsText" textarea help="One per line. Use: tool name | category, for example Cable tester | network." />
+29
View File
@@ -27,6 +27,24 @@
<label for="skill" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Skill</label>
<input id="skill" name="skill" value="{{ $filters['skill'] ?? '' }}" placeholder="POS, network, cabling" 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">
</div>
<div>
<label for="technician_level" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Technician level</label>
<select id="technician_level" name="technician_level" 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">
<option value="">Any level</option>
@foreach ($technicianLevels as $level => $definition)
<option value="{{ $level }}" @selected((int) ($filters['technician_level'] ?? 0) === $level)>{{ $definition['name'] }}</option>
@endforeach
</select>
</div>
<div>
<label for="taxonomy_term_id" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Tag</label>
<select id="taxonomy_term_id" name="taxonomy_term_id" 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">
<option value="">Any tag</option>
@foreach ($taxonomyTerms as $term)
<option value="{{ $term->id }}" @selected((int) ($filters['taxonomy_term_id'] ?? 0) === $term->id)>{{ $term->name }} ({{ str_replace('_', ' ', $term->type) }})</option>
@endforeach
</select>
</div>
<div>
<label for="insurance" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Insurance</label>
<input id="insurance" name="insurance" value="{{ $filters['insurance'] ?? '' }}" placeholder="Insured, COI available" 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">
@@ -65,6 +83,9 @@
@if (request()->boolean('public_contact'))
<x-badge tone="emerald">Public contact</x-badge>
@endif
@if (! empty($filters['technician_level']))
<x-badge tone="sky">Level: {{ $technicianLevels[(int) $filters['technician_level']]['short_name'] ?? $filters['technician_level'] }}</x-badge>
@endif
</div>
</div>
@@ -90,6 +111,7 @@
@if ($profile->service_area)
<x-badge tone="slate">{{ $profile->service_area }}</x-badge>
@endif
<x-badge tone="sky">{{ $profile->technicianLevel()['short_name'] }}</x-badge>
@if ($profile->public_contact)
<x-badge tone="emerald">Public contact</x-badge>
@endif
@@ -107,6 +129,13 @@
@if ($profile->skills)
<p class="mt-4 text-sm leading-6 text-slate-600 dark:text-slate-400">{{ \Illuminate\Support\Str::limit($profile->skills, 220) }}</p>
@endif
@if ($profile->taxonomyTerms->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($profile->taxonomyTerms->take(8) as $term)
<x-badge tone="slate">{{ $term->name }}</x-badge>
@endforeach
</div>
@endif
</a>
@empty
<div class="tse-panel p-8 text-center lg:col-span-2">
+16
View File
@@ -18,6 +18,12 @@
@if ($profile->insurance_status)
<x-badge tone="sky">{{ $profile->insurance_status }}</x-badge>
@endif
<x-badge tone="sky">{{ $profile->technicianLevel()['name'] }}</x-badge>
</div>
<div class="mt-4 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">{{ $profile->technicianLevel()['short_name'] }}</p>
<p class="mt-1">{{ $profile->technicianLevel()['description'] }}</p>
<p class="mt-2 text-xs">Level and tags are self-declared unless supported by buyer endorsements, completed TSE work, certification proof, or admin verification.</p>
</div>
@if ($profile->visible('bio'))
@@ -25,6 +31,16 @@
@endif
<dl class="mt-6 grid gap-4 md:grid-cols-2">
<div>
<dt class="text-sm text-slate-500 dark:text-slate-400">Tags and evidence</dt>
<dd class="mt-2 flex flex-wrap gap-2">
@forelse ($profile->taxonomyTerms as $term)
<x-badge tone="slate">{{ $term->name }}{{ $term->pivot?->evidence_source ? ' | '.str_replace('_', ' ', $term->pivot->evidence_source) : '' }}</x-badge>
@empty
<span class="text-sm text-slate-600 dark:text-slate-400">No taxonomy tags yet.</span>
@endforelse
</dd>
</div>
@if ($profile->visible('services'))
<div>
<dt class="text-sm text-slate-500 dark:text-slate-400">Services</dt>
@@ -42,6 +42,7 @@
<section>
<h2>Scope Safeguards</h2>
<p class="muted">Scope clarity: {{ str_replace('_', ' ', $workOrder->scope_clarity_status) }}</p>
<p class="muted">Technician level: {{ $workOrder->technicianLevel()['name'] }}</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
@@ -68,6 +68,7 @@
</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>
<x-badge tone="sky">{{ $workOrder->technicianLevel()['name'] }}</x-badge>
@if ($workOrder->contactSnapshotValue('contact_certified'))
<x-badge tone="emerald">Support certified</x-badge>
@else
+98
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\JobPost;
use App\Models\ProviderProfile;
use App\Models\SocialPost;
use App\Models\TaxonomyTerm;
use App\Models\User;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
@@ -26,6 +27,7 @@ class ExchangeWorkflowTest extends TestCase
$this->actingAs($provider)->put('/provider-profile', [
'business_name' => 'Provider LLC',
'service_area' => 'Oklahoma',
'max_technician_level' => 2,
])->assertRedirect('/provider-profile');
$this->actingAs($buyer)->put('/buyer-profile', [
@@ -235,6 +237,8 @@ class ExchangeWorkflowTest extends TestCase
$this->actingAs($buyer)->post('/jobs', [
'title' => 'Replace POS lane router',
'service_category' => 'Point of Sale',
'required_technician_level' => 3,
'work_mode' => 'onsite',
'location' => 'Tulsa, OK',
'starts_at' => now()->addDays(3)->format('Y-m-d H:i:s'),
'time_window' => '9 AM - 12 PM',
@@ -399,6 +403,7 @@ class ExchangeWorkflowTest extends TestCase
$this->actingAs($provider)->put('/provider-profile', [
'business_name' => 'Structured Provider',
'bio' => 'Public bio.',
'max_technician_level' => 3,
'services_text' => "Smart hands | entry\nNetwork troubleshooting | advanced",
'tool_inventory_text' => "Cable tester | network\nTone probe | cabling",
'certification_records_text' => "Low voltage license | Oklahoma\nA+ | CompTIA",
@@ -427,6 +432,48 @@ class ExchangeWorkflowTest extends TestCase
->assertDontSee('Internal only');
}
public function test_provider_taxonomy_tags_and_technician_level_can_filter_directory(): void
{
$provider = $this->userWithRole('provider');
$networkTag = TaxonomyTerm::create([
'type' => 'skill',
'name' => 'Field troubleshooter',
'slug' => 'field-troubleshooter',
]);
$this->actingAs($provider)->put('/provider-profile', [
'business_name' => 'Tagged Provider',
'service_area' => 'Tulsa',
'max_technician_level' => 3,
'skills' => 'Network and POS troubleshooting',
'taxonomy_terms' => [$networkTag->id],
'profile_visibility' => [
'bio' => '1',
'services' => '1',
'tools' => '1',
'certifications' => '1',
'rate_card' => '1',
'availability' => '1',
'imports' => '1',
],
])->assertRedirect('/provider-profile');
$profile = $provider->providerProfile()->firstOrFail();
$this->assertSame(3, $profile->max_technician_level);
$this->assertDatabaseHas('provider_profile_taxonomy_term', [
'provider_profile_id' => $profile->id,
'taxonomy_term_id' => $networkTag->id,
'evidence_source' => 'self_declared',
]);
$this->get('/providers?technician_level=3&taxonomy_term_id='.$networkTag->id)
->assertOk()
->assertSee('Tagged Provider')
->assertSee('Troubleshooter')
->assertSee('Field troubleshooter');
}
public function test_buyer_directory_can_filter_and_sort_profiles(): void
{
$retailBuyer = $this->userWithRole('buyer');
@@ -497,6 +544,57 @@ class ExchangeWorkflowTest extends TestCase
->assertDontSee('Buyer internal note');
}
public function test_available_work_board_filters_by_category_level_and_safety_signals(): void
{
$buyer = $this->userWithRole('buyer');
$category = TaxonomyTerm::create([
'type' => 'work_category',
'name' => 'Point of Sale',
'slug' => 'point-of-sale',
]);
JobPost::create([
'buyer_id' => $buyer->id,
'title' => 'Clear POS troubleshooting',
'status' => 'open',
'service_category' => 'POS',
'work_category_id' => $category->id,
'required_technician_level' => 3,
'work_mode' => 'onsite',
'location' => 'Tulsa, OK',
'scope' => 'Troubleshoot POS issue.',
'primary_objective' => 'Find POS connectivity fault.',
'included_work' => 'Test cabling and terminal connectivity.',
'excluded_work' => 'No electrical work.',
'deliverables' => 'Photos and notes',
'closeout_conditions' => 'Buyer validates terminal online.',
'contact_certified' => true,
'scope_clarity_status' => 'clear',
'risk_flags' => [],
'visibility' => 'public',
]);
JobPost::create([
'buyer_id' => $buyer->id,
'title' => 'Risky smart hands dump',
'status' => 'open',
'service_category' => 'Other',
'required_technician_level' => 1,
'work_mode' => 'onsite',
'location' => 'OKC, OK',
'scope' => 'Do everything onsite.',
'scope_clarity_status' => 'needs_review',
'risk_flags' => ['missing_scope_boundaries'],
'visibility' => 'public',
]);
$this->get('/jobs?work_category_id='.$category->id.'&technician_level=3&scope_clarity=clear&support_certified=1&hide_risky=1')
->assertOk()
->assertSee('Clear POS troubleshooting')
->assertSee('Troubleshooter')
->assertDontSee('Risky smart hands dump');
}
public function test_invalid_work_order_transition_is_rejected(): void
{
[, $provider, $workOrder] = $this->workOrderFixture();