job application flow fixed

This commit is contained in:
mohanmd 2026-07-06 16:39:23 +05:30
parent 88e4f3fdb4
commit 93ffc13af5
10 changed files with 599 additions and 66 deletions

View File

@ -21,7 +21,7 @@ public function listJobs(Request $request)
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$query = JobPost::where('status', 'active')
$query = JobPost::whereIn('status', ['active', 'closed'])
->with(['employer.employerProfile', 'skills']);
if ($worker) {
@ -42,9 +42,85 @@ public function listJobs(Request $request)
}
}
$jobs = $query->latest()
->get()
->map(function ($job) {
$workerApplications = [];
if ($worker) {
$workerApplications = JobApplication::where('worker_id', $worker->id)
->pluck('status', 'job_id')
->toArray();
}
// Apply filters
// Filter by job_type / jobType
if ($request->has('job_type') && $request->input('job_type') !== '') {
$query->where('job_type', $request->input('job_type'));
}
if ($request->has('jobType') && $request->input('jobType') !== '') {
$query->where('job_type', $request->input('jobType'));
}
// Filter by skills
if ($request->has('skills') && $request->input('skills') !== '') {
$skills = $request->input('skills');
if (is_string($skills)) {
$skills = array_filter(array_map('trim', explode(',', $skills)));
}
if (!empty($skills)) {
$query->whereHas('skills', function ($q) use ($skills) {
$q->where(function($q2) use ($skills) {
$q2->whereIn('skills.id', $skills)
->orWhereIn('skills.name', $skills);
});
});
}
}
// Filter by salary
if ($request->has('salary_min') && $request->input('salary_min') !== '') {
$query->where('salary', '>=', (float) $request->input('salary_min'));
}
if ($request->has('salary_max') && $request->input('salary_max') !== '') {
$query->where('salary', '<=', (float) $request->input('salary_max'));
}
if ($request->has('salary') && $request->input('salary') !== '') {
$query->where('salary', '>=', (float) $request->input('salary'));
}
if ($request->has('search') && $request->input('search') !== '') {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('location', 'like', "%{$search}%");
});
}
// Apply Sorting
if ($request->has('sortBy') && $request->input('sortBy') !== '') {
$sortBy = strtoupper($request->input('sortBy'));
if ($sortBy === 'LATEST FIRST') {
$query->latest();
} elseif ($sortBy === 'OLDEST FIRST') {
$query->oldest();
} elseif ($sortBy === 'PRICE: LOW TO HIGH') {
$query->orderBy('salary', 'asc');
} elseif ($sortBy === 'PRICE: HIGH TO LOW') {
$query->orderBy('salary', 'desc');
} else {
$query->latest();
}
} else {
$query->latest();
}
$jobs = $query->get()
->map(function ($job) use ($worker, $workerApplications) {
$status = 'new';
if ($worker && isset($workerApplications[$job->id])) {
$status = $workerApplications[$job->id];
} elseif ($job->status === 'closed') {
$status = 'closed';
}
return [
'id' => $job->id,
'title' => $job->title,
@ -60,6 +136,7 @@ public function listJobs(Request $request)
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
'posted_by' => $job->employer->name ?? 'Private Sponsor',
'posted_at' => $job->created_at->toIso8601String(),
'status' => $status,
];
});
@ -77,7 +154,7 @@ public function listJobs(Request $request)
*/
public function jobDetails($id)
{
$job = JobPost::where('status', 'active')
$job = JobPost::whereIn('status', ['active', 'closed'])
->with(['employer.employerProfile', 'skills'])
->find($id);
@ -88,6 +165,21 @@ public function jobDetails($id)
], 404);
}
$worker = request()->attributes->get('worker');
$status = 'new';
if ($worker) {
$application = JobApplication::where('worker_id', $worker->id)
->where('job_id', $job->id)
->first();
if ($application) {
$status = $application->status;
} elseif ($job->status === 'closed') {
$status = 'closed';
}
} elseif ($job->status === 'closed') {
$status = 'closed';
}
return response()->json([
'success' => true,
'data' => [
@ -106,6 +198,7 @@ public function jobDetails($id)
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
'posted_by' => $job->employer->name ?? 'Private Sponsor',
'posted_at' => $job->created_at->toIso8601String(),
'status' => $status,
]
]
]);
@ -187,6 +280,7 @@ public function appliedJobs(Request $request)
return [
'application_id' => $app->id,
'status' => $app->status,
'employer_status' => $app->employer_status,
'applied_at' => $app->created_at->toIso8601String(),
'job' => $job ? [
'id' => $job->id,
@ -660,7 +754,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
}
$validator = Validator::make($request->all(), [
'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,hire_requested',
'status' => 'required|string|in:applied,review,reviewing,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,hire_requested',
'notes' => 'nullable|string',
]);
@ -687,6 +781,8 @@ public function employerUpdateApplicationStatus(Request $request, $id)
$dbStatus = strtolower($request->status);
if ($dbStatus === 'hired') {
$dbStatus = 'hire_requested';
} elseif ($dbStatus === 'review' || $dbStatus === 'reviewing') {
$dbStatus = 'applied';
}
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
@ -715,8 +811,16 @@ public function employerUpdateApplicationStatus(Request $request, $id)
'notes' => $notes,
];
$empStatus = strtolower($request->status);
if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') {
$empStatus = 'review';
} elseif ($empStatus === 'hire_requested') {
$empStatus = 'hired';
}
$updateData = [
'status' => $dbStatus,
'employer_status' => $empStatus,
'status_history' => $history,
];
@ -785,6 +889,7 @@ public function appliedJobDetail(Request $request, $id)
'application' => [
'id' => $application->id,
'status' => $application->status,
'employer_status' => $application->employer_status,
'status_history' => $application->status_history ?: [],
'applied_at' => $application->created_at->toIso8601String(),
'job' => $job ? [

View File

@ -54,12 +54,12 @@ public function index(Request $request)
if (!$w) return null;
// Map DB status to UI friendly status
$status = 'Reviewing';
$status = 'Review';
$dbStatusLower = strtolower($app->status ?? '');
if ($dbStatusLower === 'hired') $status = 'Hired';
elseif ($dbStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Offer Sent';
elseif ($dbStatusLower === 'applied') $status = 'Reviewing';
elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Shortlisted';
elseif ($dbStatusLower === 'applied' || $dbStatusLower === 'review' || $dbStatusLower === 'reviewing') $status = 'Review';
else $status = ucfirst($app->status);
return [
@ -316,7 +316,7 @@ private function checkJobAccess($user)
public function updateStatus(Request $request, $id)
{
$request->validate([
'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent,hire_requested,hire_requested',
'status' => 'required|string|in:Applied,Review,review,Reviewing,reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent,hire_requested',
'notes' => 'nullable|string',
]);
@ -381,7 +381,7 @@ public function updateStatus(Request $request, $id)
$dbStatus = 'interview_scheduled';
} elseif ($statusLower === 'selected') {
$dbStatus = 'selected';
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied') {
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied' || $statusLower === 'review') {
$dbStatus = 'applied';
}
@ -408,8 +408,16 @@ public function updateStatus(Request $request, $id)
'notes' => $request->input('notes'),
];
$empStatus = strtolower($request->status);
if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') {
$empStatus = 'review';
} elseif ($empStatus === 'hire_requested') {
$empStatus = 'hired';
}
$updateData = [
'status' => $dbStatus,
'employer_status' => $empStatus,
'status_history' => $history,
];

View File

@ -197,10 +197,59 @@ public function show($id)
];
})->toArray();
$application = \App\Models\JobApplication::where('worker_id', $activeConv->worker_id)
->whereHas('jobPost', function($q) use ($user) {
$q->where('employer_id', $user->id);
})
->latest('id')
->first();
if (!$application) {
$jobPost = \App\Models\JobPost::where('employer_id', $user->id)->latest('id')->first();
if (!$jobPost) {
$jobPost = \App\Models\JobPost::create([
'employer_id' => $user->id,
'title' => 'General Helper',
'location' => 'Dubai',
'salary' => 2000,
'job_type' => 'full-time',
'status' => 'open',
'start_date' => now()->format('Y-m-d'),
'description' => 'General domestic helper position.',
'requirements' => 'General helper requirements.',
]);
}
$application = \App\Models\JobApplication::create([
'job_id' => $jobPost->id,
'worker_id' => $activeConv->worker_id,
'status' => 'applied',
'employer_status' => 'review',
'status_history' => [
[
'status' => 'applied',
'timestamp' => now()->toIso8601String(),
'notes' => 'Application created via chat conversation.',
]
],
]);
}
$applicationData = null;
if ($application) {
$applicationData = [
'application_id' => $application->id,
'status' => $application->status,
'notes' => $application->notes,
'status_history' => $application->status_history ?: [],
'name' => $activeConv->worker->name ?? 'Candidate',
];
}
return Inertia::render('Employer/Messages/Show', [
'conversations' => $conversations,
'conversation' => $conversationData,
'initialMessages' => $initialMessages,
'application' => $applicationData,
]);
}

View File

@ -13,6 +13,7 @@ class JobApplication extends Model
'job_id',
'worker_id',
'status',
'employer_status',
'notes',
'status_history',
'joining_confirmed_at',

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('job_applications', function (Blueprint $table) {
$table->string('employer_status')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('job_applications', function (Blueprint $table) {
$table->dropColumn('employer_status');
});
}
};

View File

@ -4138,6 +4138,56 @@
],
"summary": "List Available Jobs",
"description": "Returns a list of all active job postings available for workers to discover and apply.",
"parameters": [
{
"name": "job_type",
"in": "query",
"schema": {
"type": "string"
},
"description": "Direct filter by exact job type (e.g., 'Full Time', 'Part Time', 'Contract')."
},
{
"name": "skills",
"in": "query",
"schema": {
"type": "string"
},
"description": "Comma-separated list of skill names or IDs to filter by (e.g. 'Cooking,Cleaning')."
},
{
"name": "salary_min",
"in": "query",
"schema": {
"type": "number"
},
"description": "Minimum salary filter."
},
{
"name": "salary_max",
"in": "query",
"schema": {
"type": "number"
},
"description": "Maximum salary filter."
},
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
},
"description": "General search query for title, description, or location."
},
{
"name": "sortBy",
"in": "query",
"schema": {
"type": "string"
},
"description": "Sorting order (e.g. 'LATEST FIRST', 'OLDEST FIRST', 'PRICE: LOW TO HIGH', 'PRICE: HIGH TO LOW')."
}
],
"security": [
{
"bearerAuth": []

View File

@ -37,7 +37,18 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
const openStatusModal = (app, statusPreset = '') => {
setSelectedApp(app);
setNewStatus(statusPreset || app.status || 'Applied');
let resolvedStatus = statusPreset || app.status || 'Applied';
const lowerStatus = resolvedStatus.toLowerCase();
if (lowerStatus === 'hire_requested' || lowerStatus === 'hired') {
resolvedStatus = 'Hired';
} else if (lowerStatus === 'rejected') {
resolvedStatus = 'Rejected';
} else if (lowerStatus === 'shortlisted') {
resolvedStatus = 'Shortlisted';
} else {
resolvedStatus = 'Applied';
}
setNewStatus(resolvedStatus);
setNotes(app.notes || '');
setShowModal(true);
};
@ -197,7 +208,8 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
applicant.status?.toLowerCase() === 'rejected' ? 'bg-rose-100 text-rose-700' :
'bg-blue-50 text-[#185FA5]'
}`} onClick={() => openStatusModal(applicant)}>
{applicant.status?.toLowerCase() === 'hire_requested' ? 'Pending Confirmation' : applicant.status}
{applicant.status?.toLowerCase() === 'hire_requested' ? 'Pending Confirmation' :
applicant.status?.toLowerCase() === 'applied' ? 'Review' : applicant.status}
</span>
<div className="flex items-center space-x-1 text-[10px] font-bold text-slate-400">
<Link href={`/employer/workers/${applicant.id}`} className="hover:text-[#185FA5] flex items-center underline underline-offset-2">
@ -275,13 +287,10 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
onChange={(e) => setNewStatus(e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
>
<option value="Applied">Applied (Reviewing)</option>
<option value="Shortlisted">Shortlisted (Save Candidate)</option>
<option value="Contacted">Contacted</option>
<option value="Interview Scheduled">Interview Scheduled</option>
<option value="Selected">Selected</option>
<option value="Applied">Review</option>
<option value="Shortlisted">Shortlisted</option>
<option value="Rejected">Rejected</option>
<option value="Hired">Hired (Request Confirmation)</option>
<option value="Hired">Hired</option>
</select>
</div>

View File

@ -32,13 +32,16 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
export default function Show({ conversation, initialMessages, conversations = [] }) {
export default function Show({ conversation, initialMessages, conversations = [], application = null }) {
const { t } = useTranslation();
const [messages, setMessages] = useState(initialMessages || []);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [showHireConfirmModal, setShowHireConfirmModal] = useState(false);
const [selectedApp, setSelectedApp] = useState(null);
const [newStatus, setNewStatus] = useState('');
const [notes, setNotes] = useState('');
const [showModal, setShowModal] = useState(false);
const [showAttachmentModal, setShowAttachmentModal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState([]);
const [isRecording, setIsRecording] = useState(false);
@ -189,14 +192,39 @@ export default function Show({ conversation, initialMessages, conversations = []
}
};
const handleMarkHired = () => {
router.post(`/employer/workers/${conversation.worker_id}/mark-hired`, {}, {
const openStatusModal = (app, statusPreset = '') => {
setSelectedApp(app);
let resolvedStatus = statusPreset || app.status || 'Applied';
const lowerStatus = resolvedStatus.toLowerCase();
if (lowerStatus === 'hire_requested' || lowerStatus === 'hired') {
resolvedStatus = 'Hired';
} else if (lowerStatus === 'rejected') {
resolvedStatus = 'Rejected';
} else if (lowerStatus === 'shortlisted') {
resolvedStatus = 'Shortlisted';
} else {
resolvedStatus = 'Applied';
}
setNewStatus(resolvedStatus);
setNotes(app.notes || '');
setShowModal(true);
};
const handleSaveStatus = () => {
if (!selectedApp) return;
router.post(`/employer/candidates/${selectedApp.application_id}/status`, {
status: newStatus,
notes: notes
}, {
preserveScroll: true,
onSuccess: () => {
toast.success(t('hire_request_sent', 'Hiring request sent successfully!'), {
description: t('hire_request_sent_desc', 'Awaiting worker confirmation of the hiring offer.')
});
router.reload();
toast.success(t('status_updated_success', 'Applicant status updated successfully.'));
setShowModal(false);
setSelectedApp(null);
},
onError: (errors) => {
toast.error(t('status_updated_error', 'Failed to update status: ' + (errors.error || errors.status || 'Error occurred')));
}
});
};
@ -315,25 +343,15 @@ export default function Show({ conversation, initialMessages, conversations = []
</div>
<div className="flex items-center space-x-2">
{conversation.worker_status === 'pending_confirmation' ? (
<span className="px-3.5 py-2 bg-amber-50 text-amber-700 rounded-xl text-xs font-black uppercase tracking-wider border border-amber-200 flex items-center space-x-1.5">
<Clock className="w-4 h-4 text-amber-600 animate-pulse" />
<span>{t('pending_confirmation', 'Pending Confirmation')}</span>
</span>
) : conversation.worker_status !== 'hired' && conversation.worker_status !== 'hidden' ? (
{application ? (
<button
type="button"
onClick={() => setShowHireConfirmModal(true)}
onClick={() => openStatusModal(application)}
className="px-3.5 py-2 bg-emerald-600 text-white hover:bg-emerald-700 rounded-xl transition-all shadow-md flex items-center space-x-1.5 text-xs font-black uppercase tracking-wider border border-emerald-700 cursor-pointer"
>
<CheckCircle2 className="w-4 h-4 text-white" />
<span>{t('mark_hired_action', 'Mark Hired')}</span>
<span>{t('update_status', 'Update Status')}</span>
</button>
) : conversation.worker_status === 'hired' ? (
<span className="px-3.5 py-2 bg-slate-100 text-slate-500 rounded-xl text-xs font-black uppercase tracking-wider border border-slate-200 flex items-center space-x-1.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span>{t('hired', 'Hired')}</span>
</span>
) : null}
</div>
</div>
@ -537,38 +555,90 @@ export default function Show({ conversation, initialMessages, conversations = []
</div>
)}
{/* Hired Confirmation Modal */}
{showHireConfirmModal && (
{/* Status Update Modal */}
{showModal && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in text-center space-y-4 border border-slate-200 shadow-2xl">
<div className="mx-auto w-16 h-16 rounded-full bg-blue-50 border border-blue-100 flex items-center justify-center text-blue-600">
<CheckCircle2 className="w-8 h-8" />
</div>
<div className="space-y-2">
<h4 className="font-extrabold text-lg text-slate-900">{t('confirm_hire_title', 'Confirm Hiring')}</h4>
<p className="text-xs text-slate-600 leading-normal px-2">
{t('confirm_hire_desc', 'Verify this worker with your needs and make sure to hire this worker. If hired, you can view this worker in the Hired Workers page.')}
</p>
</div>
<div className="flex space-x-3 pt-2 text-xs font-bold">
<div className="bg-white w-full max-w-lg rounded-[28px] shadow-2xl border border-slate-200 overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200 text-left">
{/* Modal Header */}
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="font-black text-slate-900 text-base">Update Application Status</h3>
<p className="text-[10px] font-bold text-[#185FA5] uppercase tracking-widest mt-0.5">{selectedApp?.name}</p>
</div>
<button
type="button"
onClick={() => setShowHireConfirmModal(false)}
className="flex-1 py-3 border border-slate-200 hover:bg-slate-50 rounded-xl transition-colors"
onClick={() => { setShowModal(false); setSelectedApp(null); }}
className="p-2 hover:bg-slate-50 text-slate-400 hover:text-slate-950 rounded-full transition-colors cursor-pointer"
>
{t('cancel', 'Cancel')}
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-6 space-y-5">
{selectedApp?.status?.toLowerCase() === 'hire_requested' && (
<div className="p-3 bg-amber-50 rounded-xl border border-amber-100 text-[10px] text-amber-700 font-bold">
A hiring offer is pending. Waiting for worker confirmation.
</div>
)}
{/* Status Selector */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all cursor-pointer"
>
<option value="Applied">Review</option>
<option value="Shortlisted">Shortlisted</option>
<option value="Rejected">Rejected</option>
<option value="Hired">Hired</option>
</select>
</div>
{/* Notes Textarea */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Internal Employer Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any internal feedback, interview times, or notes about this candidate..."
rows={4}
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-medium text-slate-900 placeholder-slate-400 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all resize-none"
/>
</div>
{/* Status History Audit Trail */}
{selectedApp?.status_history && selectedApp.status_history.length > 0 && (
<div className="space-y-2 pt-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Status Audit Log</label>
<div className="max-h-24 overflow-y-auto space-y-2 pr-1 border border-slate-100 rounded-xl p-2 bg-slate-50/40">
{selectedApp.status_history.map((hist, index) => (
<div key={index} className="text-[9px] text-slate-500 flex justify-between items-start">
<div>
<span className="font-bold uppercase text-[#185FA5]">{hist.status}</span>
{hist.notes && <span className="ml-1 text-slate-400 italic">({hist.notes})</span>}
</div>
<span className="text-[8px] font-mono text-slate-300">{new Date(hist.timestamp).toLocaleDateString()}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Modal Footer */}
<div className="p-6 bg-slate-50 border-t border-slate-100 flex items-center justify-end space-x-3">
<button
onClick={() => { setShowModal(false); setSelectedApp(null); }}
className="px-5 py-2.5 bg-white border border-slate-200 hover:bg-slate-100 rounded-xl text-[10px] font-black uppercase tracking-widest text-slate-600 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={() => {
setShowHireConfirmModal(false);
handleMarkHired();
}}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white py-3 rounded-xl shadow-sm transition-colors"
onClick={handleSaveStatus}
className="px-5 py-2.5 bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-md shadow-blue-500/10 cursor-pointer"
>
{t('confirm_hire_action', 'Confirm Hire')}
Save Status
</button>
</div>
</div>

View File

@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use App\Models\EmployerProfile;
use App\Models\Worker;
use App\Models\JobPost;
use App\Models\JobApplication;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Inertia\Testing\AssertableInertia as Assert;
class EmployerMessageWebTest extends TestCase
{
use RefreshDatabase;
protected $worker;
protected $employer;
protected $jobPost;
protected function setUp(): void
{
parent::setUp();
// Create a test worker
$this->worker = Worker::create([
'name' => 'Jane Smith',
'email' => 'jane@example.com',
'phone' => '+971500000001',
'password' => bcrypt('password'),
'status' => 'Available',
'nationality' => 'Ethiopian',
'age' => 28,
'salary' => 1500.00,
'availability' => 'Immediate',
'experience' => '2 Years',
'religion' => 'Christian',
'bio' => 'Experienced housemaid seeking employment.',
]);
// Create an employer user
$this->employer = User::create([
'name' => 'John Employer',
'email' => 'employer_test@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
]);
// Create employer profile
EmployerProfile::create([
'user_id' => $this->employer->id,
'company_name' => 'John Ltd',
'phone' => '+971500000002',
'country' => 'United Arab Emirates',
'verification_status' => 'approved',
'language' => 'English',
'notifications' => true,
]);
// Create a Job Post
$this->jobPost = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Housekeeper',
'location' => 'Dubai',
'salary' => 1800,
'job_type' => 'full-time',
'status' => 'open',
'start_date' => '2026-08-01',
'description' => 'Test Job Description',
'requirements' => 'Test Requirements',
]);
}
/**
* Test show conversation auto-creates JobApplication and passes it as a prop.
*/
public function test_show_conversation_auto_creates_application_and_passes_prop()
{
// Authenticate session
session(['user' => (object)[
'id' => $this->employer->id,
'name' => $this->employer->name,
'email' => $this->employer->email,
'role' => 'employer',
'subscription_status' => 'active',
]]);
$conversation = Conversation::create([
'employer_id' => $this->employer->id,
'worker_id' => $this->worker->id,
]);
// Ensure no application exists initially
$this->assertDatabaseMissing('job_applications', [
'worker_id' => $this->worker->id,
]);
$response = $this->actingAs($this->employer)
->get(route('employer.messages.show', ['id' => $conversation->id]));
$response->assertStatus(200);
// Verify JobApplication was auto-created in database
$this->assertDatabaseHas('job_applications', [
'worker_id' => $this->worker->id,
'status' => 'applied',
]);
// Verify Inertia response has the 'application' prop
$response->assertInertia(fn (Assert $page) => $page
->component('Employer/Messages/Show')
->has('application')
->where('application.status', 'applied')
->has('application.status_history')
);
}
}

View File

@ -166,6 +166,7 @@ public function test_worker_job_apis()
$response->assertJsonCount(1, 'data.jobs');
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
$response->assertJsonPath('data.jobs.0.posted_by', 'Employer User');
$response->assertJsonPath('data.jobs.0.status', 'new');
// 2. Retrieve job details
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
@ -175,6 +176,7 @@ public function test_worker_job_apis()
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
$response->assertJsonPath('data.job.posted_by', 'Employer User');
$response->assertJsonPath('data.job.status', 'new');
// 3. Apply for job
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
@ -188,6 +190,13 @@ public function test_worker_job_apis()
'status' => 'applied',
]);
// Verify status becomes 'applied' in list available jobs
$response = $this->getJson('/api/workers/jobs', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('data.jobs.0.status', 'applied');
// 4. Prevent duplicate job applications
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
'Authorization' => 'Bearer worker_api_token'
@ -728,6 +737,90 @@ public function test_worker_can_view_job_matching_only_skills()
'title' => 'Electrician Needed'
]);
}
public function test_worker_can_filter_and_sort_jobs()
{
// 1. Create jobs with different contractTypes and salaries
$job1 = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Contract Plumber Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Marina',
'salary' => 1000,
'workers_needed' => 1,
'job_type' => 'Contract',
'start_date' => now()->addDays(2),
'description' => 'Fix pipes description',
'status' => 'active',
]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job1->skills()->sync([$skill->id]);
$job2 = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Full Time Plumber Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Marina',
'salary' => 5000,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->addDays(2),
'description' => 'Manage plumbing description',
'status' => 'active',
]);
$job2->skills()->sync([$skill->id]);
// 2. Filter by job_type = Contract
$response = $this->getJson('/api/workers/jobs?job_type=Contract', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Contract Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Full Time Plumber Job']);
// 3. Search for "Manage"
$response = $this->getJson('/api/workers/jobs?search=Manage', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
// 4. Sort by PRICE: HIGH TO LOW
$response = $this->getJson('/api/workers/jobs?sortBy=PRICE:%20HIGH%20TO%20LOW', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
// The first job in list should be the one with salary 5000 (Job 2)
$this->assertEquals('Full Time Plumber Job', $response->json('data.jobs.0.title'));
// 5. Filter by job_type directly
$response = $this->getJson('/api/workers/jobs?job_type=Full%20Time', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
// 6. Filter by skills
$cookingSkill = \App\Models\Skill::firstOrCreate(['name' => 'Cooking']);
$job2->skills()->sync([$skill->id, $cookingSkill->id]);
$response = $this->getJson('/api/workers/jobs?skills=Cooking', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
// 7. Filter by salary range
$response = $this->getJson('/api/workers/jobs?salary_min=2000&salary_max=6000', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
}
}