Add guided imported history wizard

This commit is contained in:
Codex
2026-07-10 17:33:27 -05:00
parent 24f73084fa
commit 3b856b5e46
12 changed files with 371 additions and 29 deletions
+2
View File
@@ -18,6 +18,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
- 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 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 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. - 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.
- Added the guided imported-history wizard with structured work categories, imported endorsements, operational metrics, selected review excerpts, public visibility controls, proof attachments, and admin verification status.
- Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts. - Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts.
- Added provider and buyer profile management with service areas, profile details, and external profile snapshot records. - Added provider and buyer profile management with service areas, profile details, and external profile snapshot records.
- Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. - Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance.
@@ -37,6 +38,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
- `php artisan test` passed under WSL with 44 tests and 195 assertions after Phase 5. - `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 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. - `php artisan test` passed under Windows PHP with 48 tests and 228 assertions after Phase 8/9.
- `php artisan test` passed under Windows PHP with 49 tests and 239 assertions after the guided imported-history wizard.
- `npm run build` passed with a production Vite build. - `npm run build` passed with a production Vite build.
- `https://christit.com/tse/login` loads publicly. - `https://christit.com/tse/login` loads publicly.
- Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`. - Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`.
+6
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Attachment; use App\Models\Attachment;
use App\Models\Dispute; use App\Models\Dispute;
use App\Models\ExternalProfileImport;
use App\Models\JobPost; use App\Models\JobPost;
use App\Models\Review; use App\Models\Review;
use App\Models\User; use App\Models\User;
@@ -34,6 +35,11 @@ class AdminController extends Controller
->latest('reported_at') ->latest('reported_at')
->limit(10) ->limit(10)
->get(), ->get(),
'importedHistories' => ExternalProfileImport::with('providerProfile.user', 'attachments')
->whereIn('verification_status', ['provider_attested', 'needs_more_proof'])
->latest()
->limit(10)
->get(),
]); ]);
} }
} }
@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\ExternalProfileImport;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -17,17 +18,72 @@ class ExternalProfileImportController extends Controller
'platform' => ['required', 'string', 'max:255'], 'platform' => ['required', 'string', 'max:255'],
'external_id' => ['nullable', 'string', 'max:255'], 'external_id' => ['nullable', 'string', 'max:255'],
'profile_url' => ['nullable', 'url', 'max:255'], 'profile_url' => ['nullable', 'url', 'max:255'],
'visibility' => ['nullable', 'string', 'in:'.implode(',', array_keys(ExternalProfileImport::VISIBILITIES))],
'rating' => ['nullable', 'numeric', 'min:0', 'max:5'], 'rating' => ['nullable', 'numeric', 'min:0', 'max:5'],
'review_count' => ['nullable', 'integer', 'min:0'], 'review_count' => ['nullable', 'integer', 'min:0'],
'completed_jobs' => ['nullable', 'integer', 'min:0'], 'completed_jobs' => ['nullable', 'integer', 'min:0'],
'client_count' => ['nullable', 'integer', 'min:0'],
'on_time_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'backout_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'work_categories_text' => ['nullable', 'string'],
'endorsements' => ['nullable', 'array'],
'endorsements.*' => ['nullable', 'string', 'max:255'],
'selected_reviews_text' => ['nullable', 'string'],
'notes' => ['nullable', 'string'], 'notes' => ['nullable', 'string'],
]); ]);
$profile->externalImports()->create($data + [ $operationalMetrics = array_filter([
'client_count' => $data['client_count'] ?? null,
'on_time_rate' => $data['on_time_rate'] ?? null,
'backout_rate' => $data['backout_rate'] ?? null,
], fn ($value): bool => $value !== null && $value !== '');
$profile->externalImports()->create([
'platform' => $data['platform'],
'external_id' => $data['external_id'] ?? null,
'profile_url' => $data['profile_url'] ?? null,
'status' => 'manual', 'status' => 'manual',
'visibility' => $data['visibility'] ?? 'private',
'verification_status' => 'provider_attested',
'rating' => $data['rating'] ?? null,
'review_count' => $data['review_count'] ?? null,
'completed_jobs' => $data['completed_jobs'] ?? null,
'work_categories' => $this->linesToList($data['work_categories_text'] ?? ''),
'endorsements' => array_values(array_filter($data['endorsements'] ?? [])),
'operational_metrics' => $operationalMetrics,
'metrics' => $operationalMetrics,
'selected_reviews' => $this->linesToList($data['selected_reviews_text'] ?? ''),
'review_snapshots' => $this->linesToList($data['selected_reviews_text'] ?? ''),
'notes' => $data['notes'] ?? null,
'imported_at' => now(), 'imported_at' => now(),
]); ]);
return redirect()->route('providers.edit')->with('status', 'External profile snapshot saved.'); return redirect()->route('providers.edit')->with('status', 'External profile snapshot saved.');
} }
public function verify(Request $request, ExternalProfileImport $import): RedirectResponse
{
abort_unless($request->user()->hasRole('admin'), 403);
$data = $request->validate([
'verification_status' => ['required', 'string', 'in:admin_verified,needs_more_proof,unverified'],
]);
$import->update([
'verification_status' => $data['verification_status'],
'verified_by_id' => $request->user()->id,
'verified_at' => now(),
]);
return back()->with('status', 'Imported history verification updated.');
}
private function linesToList(string $text): array
{
return collect(preg_split('/\r\n|\r|\n/', $text))
->map(fn (string $line): string => trim($line, " \t\n\r\0\x0B-"))
->filter()
->values()
->all();
}
} }
+42 -1
View File
@@ -9,16 +9,37 @@ use Illuminate\Database\Eloquent\Model;
#[Fillable([ #[Fillable([
'provider_profile_id', 'platform', 'external_id', 'profile_url', 'status', 'provider_profile_id', 'platform', 'external_id', 'profile_url', 'status',
'rating', 'review_count', 'completed_jobs', 'metrics', 'review_snapshots', 'visibility', 'verification_status', 'verified_by_id', 'verified_at',
'rating', 'review_count', 'completed_jobs', 'work_categories', 'endorsements',
'operational_metrics', 'metrics', 'review_snapshots', 'selected_reviews',
'notes', 'imported_at', 'notes', 'imported_at',
])] ])]
class ExternalProfileImport extends Model class ExternalProfileImport extends Model
{ {
public const VISIBILITIES = [
'private' => 'Private only',
'summary' => 'Public summary only',
'selected_reviews' => 'Public selected reviews',
'proof_public' => 'Public proof attachments',
];
public const VERIFICATION_STATUSES = [
'unverified' => 'Unverified imported history',
'provider_attested' => 'Provider attested',
'admin_verified' => 'Admin verified',
'needs_more_proof' => 'Needs more proof',
];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'work_categories' => 'array',
'endorsements' => 'array',
'operational_metrics' => 'array',
'metrics' => 'array', 'metrics' => 'array',
'review_snapshots' => 'array', 'review_snapshots' => 'array',
'selected_reviews' => 'array',
'verified_at' => 'datetime',
'imported_at' => 'datetime', 'imported_at' => 'datetime',
]; ];
} }
@@ -28,8 +49,28 @@ class ExternalProfileImport extends Model
return $this->belongsTo(ProviderProfile::class); return $this->belongsTo(ProviderProfile::class);
} }
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_id');
}
public function attachments(): MorphMany public function attachments(): MorphMany
{ {
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function publiclyVisible(): bool
{
return $this->visibility !== 'private';
}
public function canShowSelectedReviews(): bool
{
return in_array($this->visibility, ['selected_reviews', 'proof_public'], true);
}
public function canShowProofAttachments(): bool
{
return $this->visibility === 'proof_public';
}
} }
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('external_profile_imports', function (Blueprint $table) {
$table->json('work_categories')->nullable()->after('completed_jobs');
$table->json('endorsements')->nullable()->after('work_categories');
$table->json('operational_metrics')->nullable()->after('endorsements');
$table->json('selected_reviews')->nullable()->after('review_snapshots');
$table->string('visibility')->default('private')->after('status');
$table->string('verification_status')->default('unverified')->after('visibility');
$table->foreignId('verified_by_id')->nullable()->after('verification_status')->constrained('users')->nullOnDelete();
$table->timestamp('verified_at')->nullable()->after('verified_by_id');
});
}
public function down(): void
{
Schema::table('external_profile_imports', function (Blueprint $table) {
$table->dropConstrainedForeignId('verified_by_id');
$table->dropColumn([
'work_categories',
'endorsements',
'operational_metrics',
'selected_reviews',
'visibility',
'verification_status',
'verified_at',
]);
});
}
};
+14 -4
View File
@@ -481,6 +481,16 @@ Provider-protection rules:
- Do not publish work-order IDs, addresses, buyer names, or private customer details by default. - Do not publish work-order IDs, addresses, buyer names, or private customer details by default.
- Imported history should help providers prove experience without exposing sensitive client data. - Imported history should help providers prove experience without exposing sensitive client data.
Implementation status:
- Completed guided manual import first pass.
- Added import visibility modes for private, summary-only, selected reviews, and public proof attachments.
- Added imported work categories, endorsements, operational metrics, selected review excerpts, and proof upload support.
- Added provider-attested/admin-verified/needs-more-proof verification status.
- Added admin imported-history verification queue.
- Kept imported history separate from native TSE reputation.
- Verified with Windows `php artisan test`.
## Phase 11 - Mobile-Safe Work-Order Actions And API Expansion ## Phase 11 - Mobile-Safe Work-Order Actions And API Expansion
Purpose: Purpose:
@@ -708,10 +718,10 @@ Provider-protection rules:
## Recommended Immediate Sequence ## Recommended Immediate Sequence
1. Post-work provider tag verification: buyer endorsement/disagreement with self-declared level and tags after completed work orders. 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. 2. Phase 11A: mobile-safe API endpoints for onsite work-order actions.
3. Phase 11A: mobile-safe API endpoints for onsite work-order actions. 3. Phase 12A: notification preference UI and event-channel controls.
4. Phase 12A: notification preference UI and event-channel controls. 4. Phase 13A: audit logs and moderation/operations queues.
5. Phase 13A: audit logs and moderation/operations queues. 5. Phase 14A: deployment/scaling hardening.
## Key Design Commitments ## Key Design Commitments
+14 -3
View File
@@ -19,6 +19,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service
- Dense available-work board with filters for category, technician level, scope clarity, support certification, remote eligibility, and risk suppression. - 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. - 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. - Explicit technician level ladder from smart hands through project lead.
- Guided imported-history records for Field Nation, WorkMarket, and similar platforms with privacy controls, selected review excerpts, metrics, proof attachments, and admin verification status.
- Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion. - Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion.
- Mutual reviews with category metrics. - Mutual reviews with category metrics.
- Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. - Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns.
@@ -56,11 +57,11 @@ Provider Exchange is a Laravel application for a provider-centered field-service
## Phase Checkpoint ## Phase Checkpoint
Phases 1-9 have been implemented locally through the combined Phase 8/9 marketplace matching pass. The next active planning block is: Phases 1-10A have been implemented locally through the guided imported-history 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. 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. 9. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history.
10. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history. 10. Notification preference UI and event-channel controls.
Work-order safeguards to carry into the next implementation passes: Work-order safeguards to carry into the next implementation passes:
@@ -114,6 +115,16 @@ Phase 8/9 implementation completed:
- Provider directory filters can match technician level and 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. - Taxonomy tag pivots track an evidence source, currently `self_declared`, with room for buyer endorsed, completed work, certification verified, and admin verified evidence.
Guided imported-history implementation completed:
- Provider profile editing now includes a guided manual import wizard for Field Nation, WorkMarket, and similar platforms.
- Imports can store platform ID, profile URL, rating, review count, completed jobs, client count, on-time rate, backout rate, work categories, imported endorsement categories, selected review excerpts, notes, and proof attachments.
- Imports have visibility modes: private only, public summary only, public selected reviews, and public proof attachments.
- Public provider profiles only display imported history according to the selected visibility.
- Imported history keeps a separate verification status: unverified, provider attested, admin verified, or needs more proof.
- Admin dashboard includes an imported-history verification queue.
- Imported history remains separate from native TSE reputation.
Available-work safeguards to carry into directory and job-list phases: 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. - 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.
+41
View File
@@ -74,5 +74,46 @@
@endforelse @endforelse
</div> </div>
</section> </section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Imported history verification</h3>
<p class="mt-1 text-sm text-gray-600">Imported marketplace history is provider-controlled until an admin verifies selected proof. Verification does not merge imported history into native TSE reputation.</p>
<div class="mt-4 space-y-4">
@forelse ($importedHistories as $import)
<div class="rounded border bg-gray-50 p-4 text-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="font-semibold">
{{ $import->providerProfile->business_name }} | {{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}
</p>
<p class="mt-1 text-gray-600">
{{ \App\Models\ExternalProfileImport::VERIFICATION_STATUSES[$import->verification_status] ?? $import->verification_status }}
| Rating {{ $import->rating ?? 'n/a' }}
| Reviews {{ $import->review_count ?? 'n/a' }}
| Completed {{ $import->completed_jobs ?? 'n/a' }}
| Proof files {{ $import->attachments->count() }}
</p>
@if ($import->work_categories)
<p class="mt-2 text-gray-700">Categories: {{ implode(', ', $import->work_categories) }}</p>
@endif
</div>
<form method="POST" action="{{ route('provider-imports.verify', $import) }}" class="grid min-w-72 gap-2">
@csrf
@method('PATCH')
<label class="text-xs font-semibold text-gray-600" for="verification_status_{{ $import->id }}">Verification status</label>
<select id="verification_status_{{ $import->id }}" name="verification_status" class="rounded-md border-gray-300 text-sm">
<option value="admin_verified">Admin verified</option>
<option value="needs_more_proof">Needs more proof</option>
<option value="unverified">Unverified</option>
</select>
<x-primary-button>Save verification</x-primary-button>
</form>
</div>
</div>
@empty
<p class="text-sm text-gray-600">No imported-history records are waiting for verification.</p>
@endforelse
</div>
</section>
</div> </div>
</x-app-layout> </x-app-layout>
+62 -9
View File
@@ -86,16 +86,69 @@
</section> </section>
<form method="POST" action="{{ route('provider-imports.store') }}" class="tse-panel space-y-4 p-6"> <form method="POST" action="{{ route('provider-imports.store') }}" class="tse-panel space-y-4 p-6">
@csrf @csrf
<h3 class="font-semibold text-slate-950 dark:text-white">External profile snapshot</h3> <div>
<x-field name="platform" label="Platform" value="Field Nation" /> <h3 class="font-semibold text-slate-950 dark:text-white">Guided imported-history wizard</h3>
<x-field name="external_id" label="External ID" value="172-630" /> <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Manually preserve earned history from Field Nation, WorkMarket, or similar platforms without scraping private systems. Keep client names, addresses, work-order IDs, and sensitive buyer details out of public fields.</p>
<x-field name="profile_url" label="Profile URL" /> </div>
<x-field name="rating" label="Rating" type="number" /> <div class="grid gap-4 md:grid-cols-3">
<x-field name="review_count" label="Review count" type="number" /> <x-field name="platform" label="Platform" value="Field Nation" />
<x-field name="completed_jobs" label="Completed jobs" type="number" /> <x-field name="external_id" label="External ID" value="172-630" />
<x-field name="notes" label="Notes or copied review summary" textarea /> <x-field name="profile_url" label="Profile URL" />
<x-primary-button>Save external snapshot</x-primary-button> <x-field name="rating" label="Rating" type="number" />
<x-field name="review_count" label="Review count" type="number" />
<x-field name="completed_jobs" label="Completed jobs" type="number" />
<x-field name="client_count" label="Buyer/client count" type="number" />
<x-field name="on_time_rate" label="On-time rate %" type="number" />
<x-field name="backout_rate" label="Backout rate %" type="number" />
</div>
<div>
<label for="visibility" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Public visibility</label>
<select id="visibility" name="visibility" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
@foreach (\App\Models\ExternalProfileImport::VISIBILITIES as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<x-field name="work_categories_text" label="Work categories, one per line" textarea help="Examples: POS installs, network troubleshooting, cabling, printers." />
<div class="rounded-md border border-slate-200 p-4 dark:border-slate-800">
<h4 class="font-semibold text-slate-950 dark:text-white">Imported endorsement categories</h4>
<div class="mt-3 grid gap-2 md:grid-cols-3">
@foreach (['communication', 'professionalism', 'problem_solving', 'preparedness', 'work_quality', 'responsiveness'] as $endorsement)
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" name="endorsements[]" value="{{ $endorsement }}" class="rounded border-slate-300 text-sky-600 shadow-sm focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950">
{{ str_replace('_', ' ', $endorsement) }}
</label>
@endforeach
</div>
</div>
<x-field name="selected_reviews_text" label="Selected review excerpts, one per line" textarea help="Only paste short excerpts you are comfortable showing according to the visibility setting." />
<x-field name="notes" label="Private notes or summary" textarea />
<x-primary-button>Save imported history</x-primary-button>
</form> </form>
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Saved imported-history records</h3>
@forelse ($profile->externalImports as $import)
<div class="mt-4 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-950 dark:text-white">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</p>
<x-badge tone="amber">{{ \App\Models\ExternalProfileImport::VERIFICATION_STATUSES[$import->verification_status] ?? $import->verification_status }}</x-badge>
<x-badge tone="slate">{{ \App\Models\ExternalProfileImport::VISIBILITIES[$import->visibility] ?? $import->visibility }}</x-badge>
</div>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Rating {{ $import->rating ?? 'n/a' }} | Reviews {{ $import->review_count ?? 'n/a' }} | Completed {{ $import->completed_jobs ?? 'n/a' }}</p>
@if ($import->work_categories)
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($import->work_categories as $category)
<x-badge tone="sky">{{ $category }}</x-badge>
@endforeach
</div>
@endif
<x-attachments :attachments="$import->attachments" />
<x-attachment-form type="external_profile_import" :id="$import->id" kind="import_proof" />
</div>
@empty
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">No imported-history records yet.</p>
@endforelse
</section>
@endif @endif
</div> </div>
</x-app-layout> </x-app-layout>
+43 -11
View File
@@ -94,20 +94,52 @@
@if ($profile->visible('imports')) @if ($profile->visible('imports'))
<section class="tse-panel p-6"> <section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Imported profile and review history</h3> <h3 class="font-semibold text-slate-950 dark:text-white">Imported profile and review history</h3>
@forelse ($profile->externalImports as $import) @php
$publicImports = $profile->externalImports->filter->publiclyVisible();
@endphp
@forelse ($publicImports as $import)
<div class="mt-4 rounded-md border border-slate-200 p-4 dark:border-slate-800"> <div class="mt-4 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-950 dark:text-white">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</p> <p class="font-semibold text-slate-950 dark:text-white">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</p>
<x-badge tone="amber">Imported history</x-badge> <x-badge tone="amber">Imported history</x-badge>
<x-badge tone="slate">{{ $import->status }}</x-badge> <x-badge tone="{{ $import->verification_status === 'admin_verified' ? 'emerald' : 'slate' }}">{{ \App\Models\ExternalProfileImport::VERIFICATION_STATUSES[$import->verification_status] ?? $import->verification_status }}</x-badge>
</div> </div>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Imported marketplace history is provider-controlled context. It is separate from native TSE reputation unless independently verified later.</p> <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Imported marketplace history is provider-controlled context. It is separate from native TSE reputation unless independently verified later.</p>
<p class="text-sm text-slate-600 dark:text-slate-400">Rating: {{ $import->rating ?? 'n/a' }} | Reviews: {{ $import->review_count ?? 'n/a' }} | Completed: {{ $import->completed_jobs ?? 'n/a' }}</p> <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Rating: {{ $import->rating ?? 'n/a' }} | Reviews: {{ $import->review_count ?? 'n/a' }} | Completed: {{ $import->completed_jobs ?? 'n/a' }}</p>
<p class="mt-2 whitespace-pre-line text-sm text-slate-700 dark:text-slate-300">{{ $import->notes }}</p> @if ($import->operational_metrics)
<x-attachments :attachments="$import->attachments" /> <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
Clients: {{ $import->operational_metrics['client_count'] ?? 'n/a' }}
| On-time: {{ $import->operational_metrics['on_time_rate'] ?? 'n/a' }}%
| Backout: {{ $import->operational_metrics['backout_rate'] ?? 'n/a' }}%
</p>
@endif
@if ($import->work_categories)
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($import->work_categories as $category)
<x-badge tone="sky">{{ $category }}</x-badge>
@endforeach
</div>
@endif
@if ($import->endorsements)
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($import->endorsements as $endorsement)
<x-badge tone="emerald">{{ str_replace('_', ' ', $endorsement) }}</x-badge>
@endforeach
</div>
@endif
@if ($import->canShowSelectedReviews() && $import->selected_reviews)
<div class="mt-3 space-y-2">
@foreach ($import->selected_reviews as $review)
<p class="rounded-md bg-slate-50 p-3 text-sm text-slate-700 dark:bg-slate-950 dark:text-slate-300">{{ $review }}</p>
@endforeach
</div>
@endif
@if ($import->canShowProofAttachments())
<x-attachments :attachments="$import->attachments" />
@endif
</div> </div>
@empty @empty
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">No external profile snapshots yet.</p> <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">No public imported-history summaries yet.</p>
@endforelse @endforelse
</section> </section>
@endif @endif
+1
View File
@@ -55,6 +55,7 @@ Route::middleware('auth')->group(function () {
Route::get('/provider-profile', [ProviderProfileController::class, 'edit'])->name('providers.edit'); Route::get('/provider-profile', [ProviderProfileController::class, 'edit'])->name('providers.edit');
Route::put('/provider-profile', [ProviderProfileController::class, 'update'])->name('providers.update'); Route::put('/provider-profile', [ProviderProfileController::class, 'update'])->name('providers.update');
Route::post('/provider-profile/imports', [ExternalProfileImportController::class, 'store'])->name('provider-imports.store'); Route::post('/provider-profile/imports', [ExternalProfileImportController::class, 'store'])->name('provider-imports.store');
Route::patch('/provider-profile/imports/{import}/verify', [ExternalProfileImportController::class, 'verify'])->name('provider-imports.verify');
Route::get('/buyer-profile', [BuyerProfileController::class, 'edit'])->name('buyers.edit'); Route::get('/buyer-profile', [BuyerProfileController::class, 'edit'])->name('buyers.edit');
Route::put('/buyer-profile', [BuyerProfileController::class, 'update'])->name('buyers.update'); Route::put('/buyer-profile', [BuyerProfileController::class, 'update'])->name('buyers.update');
+51
View File
@@ -344,14 +344,65 @@ class ExchangeWorkflowTest extends TestCase
$this->actingAs($provider)->post('/provider-profile/imports', [ $this->actingAs($provider)->post('/provider-profile/imports', [
'platform' => 'Field Nation', 'platform' => 'Field Nation',
'external_id' => '172-630', 'external_id' => '172-630',
'visibility' => 'summary',
'rating' => 4.9, 'rating' => 4.9,
'review_count' => 500, 'review_count' => 500,
'completed_jobs' => 1200, 'completed_jobs' => 1200,
'client_count' => 75,
'on_time_rate' => 98,
'backout_rate' => 1,
'work_categories_text' => "POS installs\nNetwork troubleshooting",
'endorsements' => ['communication', 'problem_solving'],
'selected_reviews_text' => "Reliable and prepared.\nSolved the issue quickly.",
])->assertRedirect('/provider-profile'); ])->assertRedirect('/provider-profile');
$this->assertDatabaseHas('external_profile_imports', [ $this->assertDatabaseHas('external_profile_imports', [
'platform' => 'Field Nation', 'platform' => 'Field Nation',
'external_id' => '172-630', 'external_id' => '172-630',
'visibility' => 'summary',
'verification_status' => 'provider_attested',
]);
$import = $provider->providerProfile->externalImports()->firstOrFail();
$this->assertSame(['POS installs', 'Network troubleshooting'], $import->work_categories);
$this->assertSame(['communication', 'problem_solving'], $import->endorsements);
$this->assertSame(75, $import->operational_metrics['client_count']);
}
public function test_imported_history_visibility_and_admin_verification_work(): void
{
$provider = $this->userWithRole('provider');
$admin = $this->userWithRole('admin');
$profile = $provider->providerProfile()->create([
'business_name' => 'Import Provider',
'profile_visibility' => ['imports' => true],
]);
$this->actingAs($provider)->post('/provider-profile/imports', [
'platform' => 'WorkMarket',
'visibility' => 'selected_reviews',
'rating' => 4.8,
'review_count' => 42,
'completed_jobs' => 140,
'selected_reviews_text' => 'Trusted for national rollouts.',
])->assertRedirect('/provider-profile');
$import = $profile->externalImports()->firstOrFail();
$this->get("/providers/{$profile->id}")
->assertOk()
->assertSee('WorkMarket')
->assertSee('Trusted for national rollouts.')
->assertSee('Provider attested');
$this->actingAs($admin)->patch("/provider-profile/imports/{$import->id}/verify", [
'verification_status' => 'admin_verified',
])->assertRedirect();
$this->assertDatabaseHas('external_profile_imports', [
'id' => $import->id,
'verification_status' => 'admin_verified',
'verified_by_id' => $admin->id,
]); ]);
} }