Polish marketplace UX
This commit is contained in:
@@ -23,6 +23,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
|
||||
- Added Phase 11A mobile-safe API actions with versioned work-order endpoints, token ability checks, participant authorization, checklist/status/message/evidence/contact/running-late/schedule/dispute actions, mobile event audit records, optional geolocation evidence, API throttling, and baseline security headers.
|
||||
- Added Phase 12A/13A notification and admin governance controls with notification preference UI, event-level notification gating, moderation reports for profiles/jobs/attachments, reusable audit logs, and expanded admin triage panels.
|
||||
- Added Phase 14A deployment/scaling hardening with high-use database indexes, backup and health-check scripts, optional pre-update backups, and production notes for queue workers, scheduler, PHP-FPM, Redis, storage scaling, log rotation, and database scaling.
|
||||
- Added Phase 15A UX/accessibility polish with shared empty states and controls, labeled job filters, active filter badges, mobile job cards, dense work-order table, direct print access, mobile navigation accessibility, and improved work-order print packets.
|
||||
- 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.
|
||||
@@ -47,6 +48,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
|
||||
- `php artisan test` passed under Windows PHP with 55 tests and 285 assertions after Phase 11A mobile API and security hardening.
|
||||
- `php artisan test` passed under Windows PHP with 57 tests and 301 assertions after Phase 12A/13A notification preferences and admin governance.
|
||||
- `php artisan migrate:fresh --seed --env=testing`, `php artisan test` with 57 tests and 301 assertions, `npm run build`, and `bash scripts/health-check.sh` passed after Phase 14A deployment/scaling hardening.
|
||||
- `php artisan test` with 57 tests and 301 assertions and `npm run build` passed after Phase 15A UX/accessibility polish.
|
||||
- `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`.
|
||||
|
||||
@@ -759,12 +759,22 @@ Provider-protection rules:
|
||||
- Verbose buyer instructions should be collapsible and structured.
|
||||
- The system should guide buyers toward clear jobs instead of rewarding catch-all text dumps.
|
||||
|
||||
Implementation status:
|
||||
|
||||
- Completed first implementation pass.
|
||||
- Added shared Blade patterns for empty states, form controls, and secondary actions.
|
||||
- Refined the jobs board with labeled filters, active filter badges, a dense desktop table, and mobile card alternatives.
|
||||
- Refined provider and buyer directory filters with shared control styling and reusable empty states.
|
||||
- Refined the work-order list with a dense desktop table, mobile cards, checklist/status badges, and direct print access.
|
||||
- Added accessibility state/controls to the mobile navigation menu button.
|
||||
- Improved work-order print packets with a summary strip, generated timestamp, scope-safeguard notice, and print page-break handling.
|
||||
|
||||
## Recommended Immediate Sequence
|
||||
|
||||
1. Phase 15A: UX polish and accessibility pass.
|
||||
2. Cached rating/reputation aggregates after slow pages are measured.
|
||||
3. Future mobile/API expansion after native app requirements are clearer.
|
||||
4. Future email/push delivery implementation after the sender and push providers are explicitly selected.
|
||||
1. Cached rating/reputation aggregates after slow pages are measured.
|
||||
2. Future mobile/API expansion after native app requirements are clearer.
|
||||
3. Future email/push delivery implementation after the sender and push providers are explicitly selected.
|
||||
4. Deeper admin operations after real moderation and support workflows emerge.
|
||||
|
||||
## Key Design Commitments
|
||||
|
||||
|
||||
+14
-5
@@ -61,11 +61,11 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
||||
|
||||
## Phase Checkpoint
|
||||
|
||||
Phases 1-14A have been implemented locally through deployment/scaling hardening. The next active planning block is:
|
||||
Phases 1-15A have been implemented locally through UX/accessibility polish. The next active planning block is:
|
||||
|
||||
1. UX/accessibility polish.
|
||||
2. Cached reputation aggregates after real slow pages are measured.
|
||||
3. Future email/push delivery and native-app API expansion once providers are selected.
|
||||
1. Cached reputation aggregates after real slow pages are measured.
|
||||
2. Future email/push delivery and native-app API expansion once providers are selected.
|
||||
3. Deeper operations/admin controls after real moderation and support workflows emerge.
|
||||
|
||||
Work-order safeguards to carry into the next implementation passes:
|
||||
|
||||
@@ -166,6 +166,15 @@ Phase 14A deployment/scaling hardening completed:
|
||||
- Documented queue workers, scheduler, PHP-FPM expectations, Redis migration, backups, health checks, log rotation, storage scaling, and database scaling.
|
||||
- Kept cached reputation aggregates deferred until real traffic shows which pages need them.
|
||||
|
||||
Phase 15A UX/accessibility polish completed:
|
||||
|
||||
- Added shared form-control, secondary-action, and empty-state patterns.
|
||||
- Improved the jobs board with a page header, labeled filters, active filter badges, desktop table, and mobile card alternative.
|
||||
- Improved provider and buyer directory filters with shared control styling and reusable empty states.
|
||||
- Improved work-order listing with a dense desktop table, mobile cards, checklist/status badges, and direct print access.
|
||||
- Added accessible mobile-navigation button state and controls.
|
||||
- Improved work-order print packets with a summary strip, generated timestamp, scope-safeguard notice, and print page-break handling.
|
||||
|
||||
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.
|
||||
@@ -173,7 +182,7 @@ Available-work safeguards to carry into directory and job-list phases:
|
||||
- Risk badges should identify broad scope, missing contact backup, missing deliverables, missing required tools, compressed schedules, and unclear closeout requirements.
|
||||
- Keep the primary filter bar compact: work category, coverage/radius or remote mode, keyword search, reset, and saved/advanced filters.
|
||||
- Model work categories as nested taxonomy records with broad families and specific specialties.
|
||||
- Keep every filter and table action visibly labeled or accessible to screen readers.
|
||||
- Keep every filter and table action visibly labeled or accessible to screen readers. Shared Phase 15A patterns now cover the primary jobs, provider, buyer, and work-order listing pages.
|
||||
|
||||
The July 10, 2026 Field Nation product review is captured in `docs/PLATFORM_RESEARCH.md` and should inform Phases 5, 8, 9, and 10 without copying private third-party work-order data into this repository.
|
||||
|
||||
|
||||
@@ -28,4 +28,12 @@
|
||||
.tse-muted {
|
||||
@apply text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.tse-control {
|
||||
@apply 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;
|
||||
}
|
||||
|
||||
.tse-secondary-action {
|
||||
@apply inline-flex items-center 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 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,23 +17,23 @@
|
||||
<div class="grid gap-4 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<label for="q" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Search</label>
|
||||
<input id="q" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Company, headline, category, or region" 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">
|
||||
<input id="q" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Company, headline, category, or region" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Service category</label>
|
||||
<input id="category" name="category" value="{{ $filters['category'] ?? '' }}" placeholder="Retail, IT, 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">
|
||||
<input id="category" name="category" value="{{ $filters['category'] ?? '' }}" placeholder="Retail, IT, cabling" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label for="region" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Hiring region</label>
|
||||
<input id="region" name="region" value="{{ $filters['region'] ?? '' }}" placeholder="Oklahoma, national, Midwest" 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">
|
||||
<input id="region" name="region" value="{{ $filters['region'] ?? '' }}" placeholder="Oklahoma, national, Midwest" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label for="payment" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Payment terms</label>
|
||||
<input id="payment" name="payment" value="{{ $filters['payment'] ?? '' }}" placeholder="ACH, Net 15, direct" 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">
|
||||
<input id="payment" name="payment" value="{{ $filters['payment'] ?? '' }}" placeholder="ACH, Net 15, direct" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label for="sort" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Sort</label>
|
||||
<select id="sort" name="sort" 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">
|
||||
<select id="sort" name="sort" class="tse-control mt-1 block w-full">
|
||||
<option value="">Newest</option>
|
||||
<option value="name" @selected(($filters['sort'] ?? '') === 'name')>Name</option>
|
||||
<option value="rating" @selected(($filters['sort'] ?? '') === 'rating')>Community rating</option>
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<x-primary-button>Search</x-primary-button>
|
||||
<a href="{{ route('buyers.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 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800">Reset</a>
|
||||
<a href="{{ route('buyers.index') }}" class="tse-secondary-action">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -105,10 +105,7 @@
|
||||
@endif
|
||||
</a>
|
||||
@empty
|
||||
<div class="tse-panel p-8 text-center lg:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-950 dark:text-white">No buyers matched this search.</h3>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Try removing one filter or broadening the hiring region.</p>
|
||||
</div>
|
||||
<x-empty-state class="lg:col-span-2" title="No buyers matched this search." description="Try removing one filter or broadening the hiring region." />
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@props(['title', 'description' => null])
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'tse-panel p-8 text-center']) }}>
|
||||
<h3 class="text-base font-semibold text-slate-950 dark:text-white">{{ $title }}</h3>
|
||||
@if ($description)
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">{{ $description }}</p>
|
||||
@endif
|
||||
@if (trim($slot) !== '')
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,34 +1,61 @@
|
||||
<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="rounded border bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||
<x-slot name="header">
|
||||
<x-page-header
|
||||
title="Jobs"
|
||||
description="Browse open work with clear scope signals, technician level, support certification, and risk flags before quoting."
|
||||
>
|
||||
@auth
|
||||
@role('buyer')
|
||||
<a class="rounded-md bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-700" href="{{ route('jobs.create') }}">Post job</a>
|
||||
@endrole
|
||||
@endauth
|
||||
</x-page-header>
|
||||
</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-7xl space-y-5 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<form method="GET" action="{{ route('jobs.index') }}" class="tse-panel p-5">
|
||||
<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">
|
||||
<div class="lg:col-span-2">
|
||||
<label for="q" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Search</label>
|
||||
<input id="q" name="q" value="{{ request('q') }}" placeholder="Title, buyer, location, or scope" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label for="work_category_id" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Category</label>
|
||||
<select id="work_category_id" name="work_category_id" class="tse-control mt-1 block w-full">
|
||||
<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">
|
||||
</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="tse-control mt-1 block w-full">
|
||||
<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">
|
||||
</div>
|
||||
<div>
|
||||
<label for="scope_clarity" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Scope clarity</label>
|
||||
<select id="scope_clarity" name="scope_clarity" class="tse-control mt-1 block w-full">
|
||||
<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">
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Status</label>
|
||||
<select id="status" name="status" class="tse-control mt-1 block w-full">
|
||||
<option value="">Any status</option>
|
||||
@foreach (['open', 'assigned', 'closed', 'cancelled'] as $status)
|
||||
<option value="{{ $status }}" @selected(request('status') === $status)>{{ $status }}</option>
|
||||
<option value="{{ $status }}" @selected(request('status') === $status)>{{ str_replace('_', ' ', $status) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</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">
|
||||
@@ -43,11 +70,33 @@
|
||||
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>
|
||||
<a href="{{ route('jobs.index') }}" class="tse-secondary-action">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
|
||||
<div class="overflow-hidden rounded border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Showing {{ $jobs->firstItem() ?? 0 }}-{{ $jobs->lastItem() ?? 0 }} of {{ $jobs->total() }} jobs.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach (['q' => 'Search', 'scope_clarity' => 'Clarity', 'status' => 'Status'] as $key => $label)
|
||||
@if (request($key))
|
||||
<x-badge tone="sky">{{ $label }}: {{ str_replace('_', ' ', request($key)) }}</x-badge>
|
||||
@endif
|
||||
@endforeach
|
||||
@if (request()->boolean('support_certified'))
|
||||
<x-badge tone="emerald">Support certified</x-badge>
|
||||
@endif
|
||||
@if (request()->boolean('remote_only'))
|
||||
<x-badge tone="sky">Remote eligible</x-badge>
|
||||
@endif
|
||||
@if (request()->boolean('hide_risky'))
|
||||
<x-badge tone="amber">Risk hidden</x-badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden overflow-hidden rounded border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:block">
|
||||
<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>
|
||||
@@ -63,7 +112,7 @@
|
||||
@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>
|
||||
<a href="{{ route('jobs.show', $job) }}" class="font-semibold text-slate-950 hover:text-sky-700 dark:text-white dark:hover:text-sky-300">{{ $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>
|
||||
@@ -99,6 +148,30 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:hidden">
|
||||
@forelse ($jobs as $job)
|
||||
<a href="{{ route('jobs.show', $job) }}" class="tse-panel block p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">{{ $job->title }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">{{ $job->buyer->buyerProfile?->company_name ?? $job->buyer->name }} | {{ $job->location }}</p>
|
||||
</div>
|
||||
<x-badge tone="sky">{{ $job->technicianLevel()['short_name'] }}</x-badge>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-slate-600 dark:text-slate-400">{{ \Illuminate\Support\Str::limit($job->primary_objective ?: $job->scope, 180) }}</p>
|
||||
<div class="mt-4 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>
|
||||
<x-badge tone="slate">{{ $job->starts_at?->format('M j g:i A') ?? 'No start' }}</x-badge>
|
||||
<x-badge tone="slate">{{ $job->quotes_count }} quote{{ $job->quotes_count === 1 ? '' : 's' }}</x-badge>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<x-empty-state title="No jobs matched these filters." description="Try clearing a safety filter, changing the category, or broadening the search." />
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $jobs->links() }}
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<!-- Hamburger -->
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<x-theme-toggle />
|
||||
<button @click="open = ! open" class="ms-2 inline-flex items-center justify-center rounded-md p-2 text-slate-500 transition duration-150 ease-in-out hover:bg-slate-100 hover:text-slate-700 focus:bg-slate-100 focus:text-slate-700 focus:outline-none dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus:bg-slate-800 dark:focus:text-white">
|
||||
<button @click="open = ! open" type="button" class="ms-2 inline-flex items-center justify-center rounded-md p-2 text-slate-500 transition duration-150 ease-in-out hover:bg-slate-100 hover:text-slate-700 focus:bg-slate-100 focus:text-slate-700 focus:outline-none dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus:bg-slate-800 dark:focus:text-white" :aria-expanded="open.toString()" aria-controls="mobile-navigation" aria-label="Toggle navigation menu">
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Responsive Navigation Menu -->
|
||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||
<div id="mobile-navigation" :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
<div class="grid gap-4 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<label for="q" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Search</label>
|
||||
<input id="q" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Business, headline, skill, or area" 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">
|
||||
<input id="q" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Business, headline, skill, or area" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label for="service_area" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Service area</label>
|
||||
<input id="service_area" name="service_area" value="{{ $filters['service_area'] ?? '' }}" placeholder="Tulsa, Oklahoma, Midwest" 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">
|
||||
<input id="service_area" name="service_area" value="{{ $filters['service_area'] ?? '' }}" placeholder="Tulsa, Oklahoma, Midwest" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<input id="skill" name="skill" value="{{ $filters['skill'] ?? '' }}" placeholder="POS, network, cabling" class="tse-control mt-1 block w-full">
|
||||
</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">
|
||||
<select id="technician_level" name="technician_level" class="tse-control mt-1 block w-full">
|
||||
<option value="">Any level</option>
|
||||
@foreach ($technicianLevels as $level => $definition)
|
||||
<option value="{{ $level }}" @selected((int) ($filters['technician_level'] ?? 0) === $level)>{{ $definition['name'] }}</option>
|
||||
@@ -38,7 +38,7 @@
|
||||
</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">
|
||||
<select id="taxonomy_term_id" name="taxonomy_term_id" class="tse-control mt-1 block w-full">
|
||||
<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>
|
||||
@@ -47,11 +47,11 @@
|
||||
</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">
|
||||
<input id="insurance" name="insurance" value="{{ $filters['insurance'] ?? '' }}" placeholder="Insured, COI available" class="tse-control mt-1 block w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label for="sort" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Sort</label>
|
||||
<select id="sort" name="sort" 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">
|
||||
<select id="sort" name="sort" class="tse-control mt-1 block w-full">
|
||||
<option value="">Newest</option>
|
||||
<option value="name" @selected(($filters['sort'] ?? '') === 'name')>Name</option>
|
||||
<option value="rating" @selected(($filters['sort'] ?? '') === 'rating')>Community rating</option>
|
||||
@@ -65,7 +65,7 @@
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<x-primary-button>Search</x-primary-button>
|
||||
<a href="{{ route('providers.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 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800">Reset</a>
|
||||
<a href="{{ route('providers.index') }}" class="tse-secondary-action">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -138,10 +138,7 @@
|
||||
@endif
|
||||
</a>
|
||||
@empty
|
||||
<div class="tse-panel p-8 text-center lg:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-950 dark:text-white">No providers matched this search.</h3>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Try removing one filter or broadening the service area.</p>
|
||||
</div>
|
||||
<x-empty-state class="lg:col-span-2" title="No providers matched this search." description="Try removing one filter or broadening the service area." />
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,53 @@
|
||||
</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-7xl space-y-4 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="hidden overflow-hidden rounded border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:block">
|
||||
<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 text-slate-500 dark:bg-slate-950 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Work order</th>
|
||||
<th class="px-4 py-3">Buyer</th>
|
||||
<th class="px-4 py-3">Provider</th>
|
||||
<th class="px-4 py-3">Schedule</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
@forelse ($orders as $order)
|
||||
@php($progress = $order->checklistProgress())
|
||||
<tr class="align-top">
|
||||
<td class="px-4 py-4">
|
||||
<a href="{{ route('work-orders.show', $order) }}" class="font-semibold text-slate-950 hover:text-sky-700 dark:text-white dark:hover:text-sky-300">{{ $order->jobPost->title }}</a>
|
||||
<p class="mt-1 text-xs text-slate-500">#{{ $order->id }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-4">{{ $order->buyer->buyerProfile?->company_name ?? $order->buyer->name }}</td>
|
||||
<td class="px-4 py-4">{{ $order->provider->providerProfile?->business_name ?? $order->provider->name }}</td>
|
||||
<td class="px-4 py-4">
|
||||
<p>{{ $order->scheduled_at?->format('M j, Y g:i A') ?? 'No scheduled time' }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $order->appointment_window ?: 'No appointment window' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-badge tone="sky">{{ str_replace('_', ' ', $order->status) }}</x-badge>
|
||||
<x-badge tone="slate">Checklist {{ $progress['done'] }}/{{ $progress['total'] }}</x-badge>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('work-orders.show', $order) }}" class="text-sm font-semibold text-sky-700 hover:text-sky-900 dark:text-sky-300">Open</a>
|
||||
<a href="{{ route('work-orders.print', $order) }}" class="text-sm font-semibold text-sky-700 hover:text-sky-900 dark:text-sky-300">Print</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No work orders yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:hidden">
|
||||
@forelse ($orders as $order)
|
||||
@php($progress = $order->checklistProgress())
|
||||
<a href="{{ route('work-orders.show', $order) }}" class="tse-panel block p-5 transition hover:border-sky-300 hover:shadow-md dark:hover:border-sky-700">
|
||||
@@ -31,11 +78,9 @@
|
||||
</p>
|
||||
</a>
|
||||
@empty
|
||||
<div class="tse-panel p-8 text-center">
|
||||
<h3 class="text-base font-semibold text-slate-950 dark:text-white">No work orders yet.</h3>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Accepted quotes will appear here.</p>
|
||||
</div>
|
||||
<x-empty-state title="No work orders yet." description="Accepted quotes will appear here." />
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $orders->links() }}
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,28 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Order {{ $workOrder->id }} - {{ $workOrder->jobPost->title }}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #111827; margin: 32px; }
|
||||
h1, h2 { margin-bottom: 8px; }
|
||||
body { font-family: Arial, sans-serif; color: #111827; margin: 32px; line-height: 1.45; }
|
||||
h1, h2, h3 { margin-bottom: 8px; }
|
||||
h1 { font-size: 26px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 14px; }
|
||||
section { border-top: 1px solid #d1d5db; padding-top: 16px; margin-top: 20px; }
|
||||
.muted { color: #4b5563; }
|
||||
.small { font-size: 12px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.box { border: 1px solid #d1d5db; padding: 12px; border-radius: 6px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
|
||||
.summary div { border: 1px solid #d1d5db; border-radius: 6px; padding: 10px; }
|
||||
.label { display: block; color: #4b5563; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.value { display: block; margin-top: 3px; font-weight: 700; }
|
||||
.notice { background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 6px; padding: 12px; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin-bottom: 4px; }
|
||||
@media print {
|
||||
body { margin: 18mm; }
|
||||
button { display: none; }
|
||||
section { break-inside: avoid; }
|
||||
.page-break { break-before: page; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -22,6 +34,26 @@
|
||||
<button onclick="window.print()">Print</button>
|
||||
<h1>{{ $workOrder->jobPost->title }}</h1>
|
||||
<p class="muted">Work Order #{{ $workOrder->id }} | {{ str_replace('_', ' ', $workOrder->status) }}</p>
|
||||
<p class="muted small">Packet generated {{ now()->format('M j, Y g:i A') }}</p>
|
||||
|
||||
<div class="summary">
|
||||
<div>
|
||||
<span class="label">Schedule</span>
|
||||
<span class="value">{{ $workOrder->scheduled_at?->format('M j, Y g:i A') ?? 'Not set' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Window</span>
|
||||
<span class="value">{{ $workOrder->appointment_window ?: 'Not set' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Scope</span>
|
||||
<span class="value">{{ str_replace('_', ' ', $workOrder->scope_clarity_status) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Level</span>
|
||||
<span class="value">{{ $workOrder->technicianLevel()['short_name'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid">
|
||||
<div class="box">
|
||||
@@ -41,6 +73,7 @@
|
||||
|
||||
<section>
|
||||
<h2>Scope Safeguards</h2>
|
||||
<p class="notice small">Supplemental instructions are reference material only. They do not override the structured scope, excluded work, change-request path, or agreed terms in this packet.</p>
|
||||
<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)
|
||||
@@ -66,7 +99,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section class="page-break">
|
||||
<h2>Contact And Support</h2>
|
||||
<p class="muted">Certified: {{ $workOrder->contactSnapshotValue('contact_certified') ? 'Yes' : 'No' }}</p>
|
||||
<div class="grid">
|
||||
|
||||
Reference in New Issue
Block a user