Add external import sources, work history, screening fields and Phase 1 docs
- New ExternalImportSource and ExternalWorkHistoryEntry models, controller and migrations - Screening fields on external profile imports - Admin and provider profile views, routes and workflow tests updated - Phase 1 validation, route inventory, gap report template and roadmap docs - Add the Google Sheets work-order parser tool (tools/google-sheets-wo-parser)
This commit is contained in:
@@ -116,6 +116,33 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Import source batches</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">Raw Field Nation, WorkMarket, and marketplace source files are private proof batches. They should be reviewed and redacted before any public summary is approved.</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
@forelse ($importSources as $source)
|
||||
<div class="rounded border bg-gray-50 p-4 text-sm">
|
||||
<p class="font-semibold">{{ $source->providerProfile->business_name }} | {{ $source->platform }} | {{ $source->sourceTypeLabel() }}</p>
|
||||
<p class="mt-1 text-gray-600">
|
||||
{{ $source->original_name }}
|
||||
| {{ number_format($source->size / 1024, 1) }} KB
|
||||
| {{ $source->parseStatusLabel() }}
|
||||
| {{ $source->reviewStatusLabel() }}
|
||||
| uploaded by {{ $source->uploadedBy?->name ?? 'unknown' }}
|
||||
</p>
|
||||
@if ($source->externalProfileImport)
|
||||
<p class="mt-1 text-gray-600">Linked summary: {{ $source->externalProfileImport->platform }} {{ $source->externalProfileImport->external_id ? '#'.$source->externalProfileImport->external_id : '' }}</p>
|
||||
@endif
|
||||
@if ($source->provider_notes)
|
||||
<p class="mt-2 whitespace-pre-line text-gray-700">{{ $source->provider_notes }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-600">No import source batches are waiting for review.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Moderation reports</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves.</p>
|
||||
|
||||
@@ -125,6 +125,119 @@
|
||||
<x-field name="notes" label="Private notes or summary" textarea />
|
||||
<x-primary-button>Save imported history</x-primary-button>
|
||||
</form>
|
||||
<section class="tse-panel space-y-4 p-6">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Private import source vault</h3>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Upload provider-owned Field Nation, WorkMarket, or similar source material here before creating a public summary. Raw files stay private by default for provider/admin review and should not be published directly.</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('provider-import-sources.store') }}" enctype="multipart/form-data" class="space-y-4 rounded-md border border-slate-200 p-4 dark:border-slate-800">
|
||||
@csrf
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label for="import_source_platform" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Platform</label>
|
||||
<select id="import_source_platform" name="platform" 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\ExternalImportSource::PLATFORMS as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source_type" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Source type</label>
|
||||
<select id="source_type" name="source_type" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||
@foreach (\App\Models\ExternalImportSource::SOURCE_TYPES as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="external_profile_import_id" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Attach to saved summary</label>
|
||||
<select id="external_profile_import_id" name="external_profile_import_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="">No summary yet</option>
|
||||
@foreach ($profile->externalImports as $import)
|
||||
<option value="{{ $import->id }}">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<x-field name="provider_notes" label="Private source notes" textarea help="Record where this file came from and whether it contains customer names, addresses, work-order IDs, or private buyer details." />
|
||||
<div>
|
||||
<label for="source_file" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Source file</label>
|
||||
<input id="source_file" name="source_file" type="file" required class="mt-1 block w-full rounded-md border border-slate-300 bg-white text-sm text-slate-950 shadow-sm file:mr-4 file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:file:bg-slate-800 dark:file:text-slate-200">
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">Allowed: images, PDF, text, CSV, JSON, HTML, and zip files. Default storage is private.</p>
|
||||
<x-input-error :messages="$errors->get('source_file')" class="mt-2" />
|
||||
</div>
|
||||
<x-primary-button>Upload private source</x-primary-button>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-950 dark:text-white">Uploaded import sources</h4>
|
||||
@forelse ($profile->externalImportSources->sortByDesc('uploaded_at') as $source)
|
||||
<div class="mt-3 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="font-semibold text-slate-950 dark:text-white">{{ $source->platform }} | {{ $source->sourceTypeLabel() }}</p>
|
||||
<x-badge tone="slate">{{ $source->parseStatusLabel() }}</x-badge>
|
||||
<x-badge tone="amber">{{ $source->reviewStatusLabel() }}</x-badge>
|
||||
</div>
|
||||
<p class="mt-2 text-slate-600 dark:text-slate-400">{{ $source->original_name }} | {{ number_format($source->size / 1024, 1) }} KB | {{ $source->uploaded_at?->format('M j, Y g:i A') }}</p>
|
||||
@if ($source->externalProfileImport)
|
||||
<p class="mt-1 text-slate-600 dark:text-slate-400">Linked summary: {{ $source->externalProfileImport->platform }} {{ $source->externalProfileImport->external_id ? '#'.$source->externalProfileImport->external_id : '' }}</p>
|
||||
@endif
|
||||
@if ($source->provider_notes)
|
||||
<p class="mt-2 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $source->provider_notes }}</p>
|
||||
@endif
|
||||
@if ($source->parse_warnings)
|
||||
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p class="font-semibold">Parser warnings</p>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
@foreach ($source->parse_warnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@if ($source->parsed_payload)
|
||||
<div class="mt-3 rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
|
||||
<p class="font-semibold text-slate-950 dark:text-white">Parsed draft</p>
|
||||
<dl class="mt-2 grid gap-2 md:grid-cols-2">
|
||||
@foreach ($source->parsed_payload as $field => $value)
|
||||
<div>
|
||||
<dt class="text-xs uppercase text-slate-500">{{ str_replace('_', ' ', $field) }}</dt>
|
||||
<dd class="text-slate-700 dark:text-slate-300">
|
||||
@if (is_array($value))
|
||||
{{ collect($value)->contains(fn ($item) => is_array($item)) ? json_encode($value) : implode(', ', $value) }}
|
||||
@else
|
||||
{{ $value }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</div>
|
||||
@endif
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<form method="POST" action="{{ route('provider-import-sources.parse', $source) }}">
|
||||
@csrf
|
||||
<x-secondary-button type="submit">Parse source</x-secondary-button>
|
||||
</form>
|
||||
@if ($source->parse_status === 'parsed')
|
||||
<form method="POST" action="{{ route('provider-import-sources.approve', $source) }}" class="flex flex-wrap items-center gap-2">
|
||||
@csrf
|
||||
<select name="visibility" class="rounded-md border-slate-300 bg-white text-sm text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||
@foreach (\App\Models\ExternalProfileImport::VISIBILITIES as $value => $label)
|
||||
<option value="{{ $value }}" @selected($value === 'private')>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="text" name="notes" placeholder="Optional private approval note" class="rounded-md border-slate-300 bg-white text-sm text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||
<x-primary-button>Approve draft</x-primary-button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">No raw import sources have been uploaded yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
<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)
|
||||
@@ -135,6 +248,29 @@
|
||||
<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->operational_metrics)
|
||||
<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->background_check_status || $import->drug_test_status)
|
||||
<div class="mt-3 grid gap-2 md:grid-cols-2">
|
||||
@if ($import->background_check_status)
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100">
|
||||
<p class="font-semibold">Background check</p>
|
||||
<p class="mt-1">{{ ucfirst($import->background_check_status) }}{{ $import->background_check_date ? ' on '.$import->background_check_date : '' }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($import->drug_test_status)
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100">
|
||||
<p class="font-semibold">Drug test</p>
|
||||
<p class="mt-1">{{ ucfirst($import->drug_test_status) }}{{ $import->drug_test_date ? ' on '.$import->drug_test_date : '' }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($import->work_categories)
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($import->work_categories as $category)
|
||||
@@ -142,6 +278,45 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if ($import->workHistoryEntries->isNotEmpty())
|
||||
<div class="mt-4 rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-950 dark:text-white">External work history</p>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">{{ $import->workHistoryEntries->count() }} private imported job rows. Customer names and work-order links stay private here.</p>
|
||||
</div>
|
||||
<x-badge tone="amber">Private proof</x-badge>
|
||||
</div>
|
||||
<div class="mt-3 overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="py-2 pr-4">Date</th>
|
||||
<th class="py-2 pr-4">Service</th>
|
||||
<th class="py-2 pr-4">Customer</th>
|
||||
<th class="py-2 pr-4">Type</th>
|
||||
<th class="py-2 pr-4">Location</th>
|
||||
<th class="py-2 pr-4">Rating</th>
|
||||
<th class="py-2 pr-4">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
@foreach ($import->workHistoryEntries->sortByDesc('performed_on')->take(8) as $entry)
|
||||
<tr>
|
||||
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->performed_on?->format('M j, Y') ?? 'n/a' }}</td>
|
||||
<td class="py-2 pr-4 text-slate-800 dark:text-slate-200">{{ \Illuminate\Support\Str::limit($entry->service_title, 72) }}</td>
|
||||
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->customer_name ?? 'n/a' }}</td>
|
||||
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->work_type ?? 'n/a' }}</td>
|
||||
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->location ?? 'n/a' }}</td>
|
||||
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->rating ?? 'n/a' }}</td>
|
||||
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->status ?? 'n/a' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<x-attachments :attachments="$import->attachments" />
|
||||
<x-attachment-form type="external_profile_import" :id="$import->id" kind="import_proof" />
|
||||
</div>
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
@php
|
||||
$average = $profile->average_stars ? round($profile->average_stars, 1) : null;
|
||||
$imports = $profile->externalImports;
|
||||
$visibleImports = $imports->filter->publiclyVisible();
|
||||
$importedWorkCount = $visibleImports->sum(fn ($import) => $import->workHistoryEntries->count());
|
||||
@endphp
|
||||
<a href="{{ route('providers.show', $profile) }}" class="tse-panel block p-5 transition hover:border-sky-300 hover:shadow-md dark:hover:border-sky-700">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -121,8 +123,11 @@
|
||||
@if ($profile->availability_notes)
|
||||
<x-badge tone="amber">Availability noted</x-badge>
|
||||
@endif
|
||||
@if ($imports->isNotEmpty())
|
||||
<x-badge tone="slate">{{ $imports->count() }} imported profile{{ $imports->count() === 1 ? '' : 's' }}</x-badge>
|
||||
@if ($visibleImports->isNotEmpty())
|
||||
<x-badge tone="slate">{{ $visibleImports->count() }} imported profile{{ $visibleImports->count() === 1 ? '' : 's' }}</x-badge>
|
||||
@endif
|
||||
@if ($importedWorkCount > 0)
|
||||
<x-badge tone="amber">{{ number_format($importedWorkCount) }} external jobs</x-badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -161,6 +161,16 @@
|
||||
| Backout: {{ $import->operational_metrics['backout_rate'] ?? 'n/a' }}%
|
||||
</p>
|
||||
@endif
|
||||
@if ($import->background_check_status || $import->drug_test_status)
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@if ($import->background_check_status)
|
||||
<x-badge tone="emerald">Background check {{ $import->background_check_status }}{{ $import->background_check_date ? ' '.$import->background_check_date : '' }}</x-badge>
|
||||
@endif
|
||||
@if ($import->drug_test_status)
|
||||
<x-badge tone="emerald">Drug test {{ $import->drug_test_status }}{{ $import->drug_test_date ? ' '.$import->drug_test_date : '' }}</x-badge>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($import->work_categories)
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@foreach ($import->work_categories as $category)
|
||||
@@ -175,6 +185,33 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if ($import->workHistoryEntries->isNotEmpty())
|
||||
<div class="mt-4 rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-950 dark:text-white">Recent imported work</p>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Showing a redacted sample from {{ number_format($import->workHistoryEntries->count()) }} imported external jobs. Customer names and work-order links are private.</p>
|
||||
</div>
|
||||
<x-badge tone="amber">Imported external history</x-badge>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-2 md:grid-cols-2">
|
||||
@foreach ($import->workHistoryEntries->sortByDesc('performed_on')->take(6) as $entry)
|
||||
<div class="rounded-md border border-slate-200 bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<p class="font-semibold text-slate-950 dark:text-white">{{ \Illuminate\Support\Str::limit($entry->service_title, 90) }}</p>
|
||||
<p class="mt-1 text-slate-600 dark:text-slate-400">
|
||||
{{ $entry->performed_on?->format('M j, Y') ?? 'Date private' }}
|
||||
@if ($entry->work_type)
|
||||
| {{ $entry->work_type }}
|
||||
@endif
|
||||
@if ($entry->location)
|
||||
| {{ $entry->location }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($import->canShowSelectedReviews() && $import->selected_reviews)
|
||||
<div class="mt-3 space-y-2">
|
||||
@foreach ($import->selected_reviews as $review)
|
||||
|
||||
Reference in New Issue
Block a user