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
This commit is contained in:
Tiago Yamamoto 2025-12-26 15:29:51 -03:00
parent d6bb579260
commit 91e4417c95
4 changed files with 95 additions and 67 deletions

View file

@ -9,6 +9,7 @@ type CreateJobRequest struct {
SalaryMax *float64 `json:"salaryMax,omitempty"` SalaryMax *float64 `json:"salaryMax,omitempty"`
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"` SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"` Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"`
SalaryNegotiable bool `json:"salaryNegotiable"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"` EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"` WorkingHours *string `json:"workingHours,omitempty"`
Location *string `json:"location,omitempty"` Location *string `json:"location,omitempty"`
@ -29,6 +30,7 @@ type UpdateJobRequest struct {
SalaryMax *float64 `json:"salaryMax,omitempty"` SalaryMax *float64 `json:"salaryMax,omitempty"`
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"` SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"` Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"`
SalaryNegotiable *bool `json:"salaryNegotiable,omitempty"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"` EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"` WorkingHours *string `json:"workingHours,omitempty"`
Location *string `json:"location,omitempty"` Location *string `json:"location,omitempty"`

View file

@ -26,8 +26,8 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
INSERT INTO jobs ( INSERT INTO jobs (
company_id, created_by, title, description, salary_min, salary_max, salary_type, company_id, created_by, title, description, salary_min, salary_max, salary_type,
employment_type, working_hours, location, region_id, city_id, employment_type, working_hours, location, region_id, city_id,
requirements, benefits, visa_support, language_level, status, created_at, updated_at requirements, benefits, visa_support, language_level, status, created_at, updated_at, salary_negotiable
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING id, created_at, updated_at RETURNING id, created_at, updated_at
` `
@ -39,6 +39,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
SalaryMin: req.SalaryMin, SalaryMin: req.SalaryMin,
SalaryMax: req.SalaryMax, SalaryMax: req.SalaryMax,
SalaryType: req.SalaryType, SalaryType: req.SalaryType,
SalaryNegotiable: req.SalaryNegotiable,
EmploymentType: req.EmploymentType, EmploymentType: req.EmploymentType,
WorkingHours: req.WorkingHours, WorkingHours: req.WorkingHours,
Location: req.Location, Location: req.Location,
@ -60,7 +61,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
query, query,
job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType,
job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID, job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID,
job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable,
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt) ).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt)
if err != nil { if err != nil {
@ -76,7 +77,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
baseQuery := ` baseQuery := `
SELECT SELECT
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type, j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.created_at, j.updated_at, j.employment_type, j.work_mode, j.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, j.created_at, j.updated_at,
COALESCE(c.name, '') as company_name, c.logo_url as company_logo_url, COALESCE(c.name, '') as company_name, c.logo_url as company_logo_url,
r.name as region_name, ci.name as city_name r.name as region_name, ci.name as city_name
FROM jobs j FROM jobs j
@ -217,7 +218,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
var j models.JobWithCompany var j models.JobWithCompany
if err := rows.Scan( if err := rows.Scan(
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.WorkMode, &j.Location, &j.Status, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt, &j.EmploymentType, &j.WorkMode, &j.WorkingHours, &j.Location, &j.Status, &j.SalaryNegotiable, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt,
&j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName,
); err != nil { ); err != nil {
return nil, 0, err return nil, 0, err
@ -238,13 +239,14 @@ func (s *JobService) GetJobByID(id string) (*models.Job, error) {
var j models.Job var j models.Job
query := ` query := `
SELECT id, company_id, title, description, salary_min, salary_max, salary_type, SELECT id, company_id, title, description, salary_min, salary_max, salary_type,
employment_type, working_hours, location, region_id, city_id, employment_type, working_hours, location, region_id, city_id, salary_negotiable,
requirements, benefits, visa_support, language_level, status, created_at, updated_at requirements, benefits, visa_support, language_level, status, created_at, updated_at
FROM jobs WHERE id = $1 FROM jobs WHERE id = $1
` `
err := s.DB.QueryRow(query, id).Scan( err := s.DB.QueryRow(query, id).Scan(
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID, &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID, &j.SalaryNegotiable,
&j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.CreatedAt, &j.UpdatedAt, &j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.CreatedAt, &j.UpdatedAt,
) )
if err != nil { if err != nil {
@ -274,6 +276,11 @@ func (s *JobService) UpdateJob(id string, req dto.UpdateJobRequest) (*models.Job
args = append(args, *req.Status) args = append(args, *req.Status)
argId++ argId++
} }
if req.SalaryNegotiable != nil {
setClauses = append(setClauses, fmt.Sprintf("salary_negotiable = $%d", argId))
args = append(args, *req.SalaryNegotiable)
argId++
}
if len(setClauses) == 0 { if len(setClauses) == 0 {
return s.GetJobByID(id) return s.GetJobByID(id)

View file

@ -65,6 +65,7 @@ export default function PostJobPage() {
salaryFixed: "", // For fixed salary mode salaryFixed: "", // For fixed salary mode
employmentType: "", employmentType: "",
workMode: "remote", workMode: "remote",
workingHours: "",
salaryNegotiable: false, // Candidate proposes salary salaryNegotiable: false, // Candidate proposes salary
}); });
@ -145,6 +146,7 @@ export default function PostJobPage() {
salaryMax: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMax ? parseInt(job.salaryMax) : null)), salaryMax: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMax ? parseInt(job.salaryMax) : null)),
salaryNegotiable: job.salaryNegotiable, salaryNegotiable: job.salaryNegotiable,
employmentType: job.employmentType || null, employmentType: job.employmentType || null,
workingHours: job.workingHours || null,
workMode: job.workMode, workMode: job.workMode,
status: "pending", // Pending review status: "pending", // Pending review
}), }),
@ -418,7 +420,7 @@ export default function PostJobPage() {
</> </>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<Label>Tipo de Contrato</Label> <Label>Tipo de Contrato</Label>
<select <select
@ -434,6 +436,18 @@ export default function PostJobPage() {
<option value="voluntary">Voluntário</option> <option value="voluntary">Voluntário</option>
</select> </select>
</div> </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> <div>
<Label>Modelo de Trabalho</Label> <Label>Modelo de Trabalho</Label>
<select <select
@ -482,7 +496,7 @@ export default function PostJobPage() {
? (job.salaryFixed ? `R$ ${job.salaryFixed}` : "A combinar") ? (job.salaryFixed ? `R$ ${job.salaryFixed}` : "A combinar")
: (job.salaryMin && job.salaryMax ? `R$ ${job.salaryMin} - R$ ${job.salaryMax}` : "A combinar") : (job.salaryMin && job.salaryMax ? `R$ ${job.salaryMin} - R$ ${job.salaryMax}` : "A combinar")
}</p> }</p>
<p><strong>Tipo:</strong> {job.employmentType || "Qualquer"} / {job.workMode}</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>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" onClick={() => setStep(2)} className="flex-1"> <Button variant="outline" onClick={() => setStep(2)} className="flex-1">

View file

@ -103,30 +103,35 @@ export async function seedJobs() {
const currency = currencies[i % currencies.length]; const currency = currencies[i % currencies.length];
const salaryType = salaryTypes[i % salaryTypes.length]; const salaryType = salaryTypes[i % salaryTypes.length];
const workingHoursOptions = ['full-time', 'part-time', ''];
const workingHours = workingHoursOptions[i % 3];
const salaryNegotiable = i % 10 === 0; // 10% negotiable
// Prepare params for bulk insert // Prepare params for bulk insert
jobParams.push( jobParams.push(
company.id, company.id,
seedUserId, seedUserId,
title, title,
`We are looking for a talented ${title} to join our ${company.name} team. Your role as ${level} will be crucial.`, `We are looking for a talented ${title} to join our ${company.name} team. Your role as ${level} will be crucial.`,
salaryMin, salaryNegotiable ? null : salaryMin, // If negotiable, min is null
salaryMax, salaryNegotiable ? null : salaryMax, // If negotiable, max is null
salaryType, salaryType,
currency, currency,
employmentType, employmentType,
workMode === 'remote' ? 'Flexible' : '9:00-18:00', workingHours,
workMode === 'remote' ? 'Remote (Global)' : internationalLocations[i % internationalLocations.length], workMode === 'remote' ? 'Remote (Global)' : internationalLocations[i % internationalLocations.length],
JSON.stringify(template.skills), JSON.stringify(template.skills),
JSON.stringify(getRandomItems(benefits, 4)), JSON.stringify(getRandomItems(benefits, 4)),
i % 5 === 0, // 20% offer visa support i % 5 === 0, // 20% offer visa support
'beginner', 'beginner',
'open', 'open',
workMode workMode,
salaryNegotiable
); );
// ($1, $2, $3, ...), ($18, $19, ...) // ($1, $2, $3, ...), ($18, $19, ...)
const placeHolders = []; const placeHolders = [];
for (let k = 0; k < 17; k++) { for (let k = 0; k < 18; k++) {
placeHolders.push(`$${paramCounter++}`); placeHolders.push(`$${paramCounter++}`);
} }
jobValues.push(`(${placeHolders.join(',')})`); jobValues.push(`(${placeHolders.join(',')})`);
@ -139,7 +144,7 @@ export async function seedJobs() {
const query = ` const query = `
INSERT INTO jobs (company_id, created_by, title, description, INSERT INTO jobs (company_id, created_by, title, description,
salary_min, salary_max, salary_type, currency, employment_type, working_hours, salary_min, salary_max, salary_type, currency, employment_type, working_hours,
location, requirements, benefits, visa_support, language_level, status, work_mode) location, requirements, benefits, visa_support, language_level, status, work_mode, salary_negotiable)
VALUES ${jobValues.join(',')} VALUES ${jobValues.join(',')}
`; `;
await pool.query(query, jobParams); await pool.query(query, jobParams);