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
519 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|