gohorsejobs/frontend/src/app/post-job/page.tsx
Tiago Yamamoto 91e4417c95 feat: add working hours and salary negotiable logic
Backend:
- Updated DTOs to include SalaryNegotiable and WorkingHours
- Updated JobService to map and persist these fields (CREATE, GET, UPDATE)
- Ensure DB queries include new columns

Frontend:
- Added 'Working Hours' (Jornada de Trabalho) dropdown to PostJobPage
- Updated state and submit logic
- Improved salary display in confirmation step

Seeder:
- Updated jobs seeder to include salary_negotiable and valid working_hours
2025-12-26 15:29:51 -03:00

519 lines
30 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import {
Building2, Briefcase, Mail, Lock, Phone, MapPin,
Eye, EyeOff, Globe
} from "lucide-react";
import { LocationPicker } from "@/components/location-picker";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Common Country Codes
const COUNTRY_CODES = [
{ code: "+55", country: "Brasil (BR)" },
{ code: "+1", country: "Estados Unidos (US)" },
{ code: "+351", country: "Portugal (PT)" },
{ code: "+44", country: "Reino Unido (UK)" },
{ code: "+33", country: "França (FR)" },
{ code: "+49", country: "Alemanha (DE)" },
{ code: "+34", country: "Espanha (ES)" },
{ code: "+39", country: "Itália (IT)" },
{ code: "+81", country: "Japão (JP)" },
{ code: "+86", country: "China (CN)" },
{ code: "+91", country: "Índia (IN)" },
{ code: "+52", country: "México (MX)" },
{ code: "+54", country: "Argentina (AR)" },
{ code: "+57", country: "Colômbia (CO)" },
{ code: "+56", country: "Chile (CL)" },
{ code: "+51", country: "Peru (PE)" },
].sort((a, b) => a.country.localeCompare(b.country));
export default function PostJobPage() {
const router = useRouter();
const [step, setStep] = useState<1 | 2 | 3>(1);
const [loading, setLoading] = useState(false);
// Company/User data
const [company, setCompany] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
ddi: "+55",
phone: "",
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Job data
const [job, setJob] = useState({
title: "",
description: "",
location: "",
salaryMin: "",
salaryMax: "",
salaryFixed: "", // For fixed salary mode
employmentType: "",
workMode: "remote",
workingHours: "",
salaryNegotiable: false, // Candidate proposes salary
});
// Salary mode toggle: 'fixed' | 'range'
const [salaryMode, setSalaryMode] = useState<'fixed' | 'range'>('fixed');
const formatPhoneForDisplay = (value: string) => {
// Simple formatting to just allow numbers and basic separators if needed
// For now, just pass through but maybe restrict chars?
return value.replace(/[^\d\s-]/g, "");
};
const handleSubmit = async () => {
if (!company.name || !company.email || !company.password) {
toast.error("Preencha os dados obrigatórios da empresa");
setStep(1);
return;
}
if (company.password !== company.confirmPassword) {
toast.error("As senhas não coincidem");
setStep(1);
return;
}
if (company.password.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres");
setStep(1);
return;
}
if (!job.title || !job.description) {
toast.error("Preencha os dados da vaga");
setStep(2);
return;
}
setLoading(true);
try {
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
// Format phone: DDI + Phone (digits only)
const cleanPhone = company.phone.replace(/\D/g, "");
const finalPhone = cleanPhone ? `${company.ddi}${cleanPhone}` : "";
// 1. Register Company (creates user + company)
const registerRes = await fetch(`${apiBase}/api/v1/auth/register/company`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName: company.name,
email: company.email,
password: company.password,
phone: finalPhone,
}),
});
if (!registerRes.ok) {
const err = await registerRes.json();
throw new Error(err.message || "Erro ao registrar empresa");
}
const { token } = await registerRes.json();
// 2. Create Job with token
const jobRes = await fetch(`${apiBase}/api/v1/jobs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
title: job.title,
description: job.description,
location: job.location,
// Salary logic: if negotiable, send null values
salaryMin: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMin ? parseInt(job.salaryMin) : null)),
salaryMax: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMax ? parseInt(job.salaryMax) : null)),
salaryNegotiable: job.salaryNegotiable,
employmentType: job.employmentType || null,
workingHours: job.workingHours || null,
workMode: job.workMode,
status: "pending", // Pending review
}),
});
if (!jobRes.ok) {
const err = await jobRes.json();
throw new Error(err.message || "Erro ao criar vaga");
}
// Save token for future use
localStorage.setItem("token", token);
localStorage.setItem("auth_token", token);
toast.success("Vaga criada com sucesso! Aguardando aprovação.");
router.push("/dashboard/jobs");
} catch (err: any) {
toast.error(err.message || "Erro ao processar solicitação");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-12">
<div className="container max-w-2xl mx-auto px-4">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">Postar uma Vaga</h1>
<p className="text-muted-foreground">
Cadastre sua empresa e publique sua vaga em poucos minutos
</p>
</div>
{/* Progress Steps */}
<div className="flex justify-center gap-4 mb-8">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`flex items-center gap-2 ${step >= s ? "text-primary" : "text-muted-foreground"}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${step >= s ? "bg-primary text-white" : "bg-muted"}`}>
{s}
</div>
<span className="hidden sm:inline text-sm">
{s === 1 ? "Empresa" : s === 2 ? "Vaga" : "Confirmar"}
</span>
</div>
))}
</div>
<Card>
<CardHeader>
<CardTitle>
{step === 1 && "Dados da Empresa"}
{step === 2 && "Detalhes da Vaga"}
{step === 3 && "Confirmar e Publicar"}
</CardTitle>
<CardDescription>
{step === 1 && "Informe os dados da sua empresa para criar a conta"}
{step === 2 && "Descreva a vaga que você deseja publicar"}
{step === 3 && "Revise as informações antes de publicar"}
</CardDescription>
</CardHeader>
<CardContent>
{/* Step 1: Company */}
{step === 1 && (
<div className="space-y-4">
<div>
<Label>Nome da Empresa *</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
value={company.name}
onChange={(e) => setCompany({ ...company, name: e.target.value })}
placeholder="Minha Empresa Ltda"
className="pl-10"
/>
</div>
</div>
<div>
<Label>Email *</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="email"
value={company.email}
onChange={(e) => setCompany({ ...company, email: e.target.value })}
placeholder="contato@empresa.com"
className="pl-10"
/>
</div>
</div>
{/* Password Field */}
<div>
<Label>Senha *</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showPassword ? "text" : "password"}
value={company.password}
onChange={(e) => setCompany({ ...company, password: e.target.value })}
placeholder="••••••••"
className="pl-10 pr-10" // Extra padding for eye icon
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground focus:outline-none"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Confirm Password Field */}
<div>
<Label>Confirmar Senha *</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showConfirmPassword ? "text" : "password"}
value={company.confirmPassword}
onChange={(e) => setCompany({ ...company, confirmPassword: e.target.value })}
placeholder="••••••••"
className="pl-10 pr-10"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground focus:outline-none"
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Phone Field with DDI */}
<div>
<Label>Telefone</Label>
<div className="flex gap-2">
<div className="w-[140px]">
<Select value={company.ddi} onValueChange={(val) => setCompany({ ...company, ddi: val })}>
<SelectTrigger className="pl-9 relative">
<Globe className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="DDI" />
</SelectTrigger>
<SelectContent>
{COUNTRY_CODES.map((item) => (
<SelectItem key={item.country} value={item.code}>
<span className="flex items-center justify-between w-full min-w-[80px]">
<span>{item.code}</span>
<span className="text-muted-foreground ml-2 text-xs truncate max-w-[100px]">{item.country}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="relative flex-1">
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="tel"
value={formatPhoneForDisplay(company.phone)}
onChange={(e) => setCompany({ ...company, phone: e.target.value })}
placeholder="11 99999-9999"
className="pl-10"
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-1">
Selecione o código do país e digite o número com DDD.
</p>
</div>
<Button onClick={() => setStep(2)} className="w-full">
Próximo: Dados da Vaga
</Button>
</div>
)}
{/* Step 2: Job */}
{step === 2 && (
<div className="space-y-4">
<div>
<Label>Título da Vaga *</Label>
<div className="relative">
<Briefcase className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
value={job.title}
onChange={(e) => setJob({ ...job, title: e.target.value })}
placeholder="Desenvolvedor Full Stack"
className="pl-10"
/>
</div>
</div>
<div>
<Label>Descrição *</Label>
<Textarea
value={job.description}
onChange={(e) => setJob({ ...job, description: e.target.value })}
placeholder="Descreva as responsabilidades, requisitos e benefícios..."
rows={5}
/>
</div>
<div>
<div>
{/* Includes Label and Layout internally. */}
<LocationPicker
value={job.location}
onChange={(val) => setJob({ ...job, location: val })}
/>
</div>
</div>
{/* Salary Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Salário</Label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={job.salaryNegotiable}
onChange={(e) => setJob({ ...job, salaryNegotiable: e.target.checked })}
className="rounded"
/>
<span className="text-muted-foreground">Candidato envia proposta</span>
</label>
</div>
{!job.salaryNegotiable && (
<>
{salaryMode === 'fixed' ? (
<div>
<Input
type="number"
value={job.salaryFixed}
onChange={(e) => setJob({ ...job, salaryFixed: e.target.value })}
placeholder="Valor"
/>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<Input
type="number"
value={job.salaryMin}
onChange={(e) => setJob({ ...job, salaryMin: e.target.value })}
placeholder="Mín"
/>
<Input
type="number"
value={job.salaryMax}
onChange={(e) => setJob({ ...job, salaryMax: e.target.value })}
placeholder="Máx"
/>
</div>
)}
<button
type="button"
onClick={() => setSalaryMode(salaryMode === 'fixed' ? 'range' : 'fixed')}
className="flex items-center gap-1 text-sm text-primary hover:underline"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{salaryMode === 'fixed' ? 'Faixa salarial' : 'Salário fixo'}
</button>
</>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Tipo de Contrato</Label>
<select
value={job.employmentType}
onChange={(e) => setJob({ ...job, employmentType: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="">Qualquer</option>
<option value="permanent">Permanente</option>
<option value="contract">Contrato (PJ)</option>
<option value="training">Estágio/Trainee</option>
<option value="temporary">Temporário</option>
<option value="voluntary">Voluntário</option>
</select>
</div>
<div>
<Label>Jornada de Trabalho</Label>
<select
value={job.workingHours}
onChange={(e) => setJob({ ...job, workingHours: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="">Qualquer</option>
<option value="full-time">Tempo Integral</option>
<option value="part-time">Meio Período</option>
</select>
</div>
<div>
<Label>Modelo de Trabalho</Label>
<select
value={job.workMode}
onChange={(e) => setJob({ ...job, workMode: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="remote">Remoto</option>
<option value="hybrid">Híbrido</option>
<option value="onsite">Presencial</option>
</select>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setStep(1)} className="flex-1">
Voltar
</Button>
<Button onClick={() => setStep(3)} className="flex-1">
Próximo: Confirmar
</Button>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 3 && (
<div className="space-y-6">
<div className="bg-muted/50 rounded-lg p-4">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Building2 className="h-4 w-4" /> Empresa
</h3>
<p><strong>Nome:</strong> {company.name}</p>
<p><strong>Email:</strong> {company.email}</p>
{company.phone && <p><strong>Telefone:</strong> {company.ddi} {company.phone}</p>}
</div>
<div className="bg-muted/50 rounded-lg p-4">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Briefcase className="h-4 w-4" /> Vaga
</h3>
<p><strong>Título:</strong> {job.title}</p>
<p><strong>Localização:</strong> {job.location || "Não informado"}</p>
<p><strong>Salário:</strong> {
job.salaryNegotiable
? "Candidato envia proposta"
: salaryMode === 'fixed'
? (job.salaryFixed ? `R$ ${job.salaryFixed}` : "A combinar")
: (job.salaryMin && job.salaryMax ? `R$ ${job.salaryMin} - R$ ${job.salaryMax}` : "A combinar")
}</p>
<p><strong>Tipo:</strong> {job.employmentType || "Qualquer"} / {job.workingHours === 'full-time' ? 'Integral' : job.workingHours === 'part-time' ? 'Meio Período' : 'Qualquer'} / {job.workMode}</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setStep(2)} className="flex-1">
Voltar
</Button>
<Button onClick={handleSubmit} disabled={loading} className="flex-1">
{loading ? "Publicando..." : "Publicar Vaga"}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</main>
<Footer />
</div>
);
}