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

@ -2,43 +2,45 @@ package dto
// CreateJobRequest represents the request to create a new job // CreateJobRequest represents the request to create a new job
type CreateJobRequest struct { type CreateJobRequest struct {
CompanyID string `json:"companyId" validate:"required"` CompanyID string `json:"companyId" validate:"required"`
Title string `json:"title" validate:"required,min=5,max=255"` Title string `json:"title" validate:"required,min=5,max=255"`
Description string `json:"description" validate:"required,min=20"` Description string `json:"description" validate:"required,min=20"`
SalaryMin *float64 `json:"salaryMin,omitempty"` SalaryMin *float64 `json:"salaryMin,omitempty"`
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"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"` SalaryNegotiable bool `json:"salaryNegotiable"`
WorkingHours *string `json:"workingHours,omitempty"` EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
Location *string `json:"location,omitempty"` WorkingHours *string `json:"workingHours,omitempty"`
RegionID *int `json:"regionId,omitempty"` Location *string `json:"location,omitempty"`
CityID *int `json:"cityId,omitempty"` RegionID *int `json:"regionId,omitempty"`
Requirements map[string]interface{} `json:"requirements,omitempty"` CityID *int `json:"cityId,omitempty"`
Benefits map[string]interface{} `json:"benefits,omitempty"` Requirements map[string]interface{} `json:"requirements,omitempty"`
VisaSupport bool `json:"visaSupport"` Benefits map[string]interface{} `json:"benefits,omitempty"`
LanguageLevel *string `json:"languageLevel,omitempty"` VisaSupport bool `json:"visaSupport"`
Status string `json:"status" validate:"oneof=draft open closed review published paused expired archived reported"` LanguageLevel *string `json:"languageLevel,omitempty"`
Status string `json:"status" validate:"oneof=draft open closed review published paused expired archived reported"`
} }
// UpdateJobRequest represents the request to update a job // UpdateJobRequest represents the request to update a job
type UpdateJobRequest struct { type UpdateJobRequest struct {
Title *string `json:"title,omitempty" validate:"omitempty,min=5,max=255"` Title *string `json:"title,omitempty" validate:"omitempty,min=5,max=255"`
Description *string `json:"description,omitempty" validate:"omitempty,min=20"` Description *string `json:"description,omitempty" validate:"omitempty,min=20"`
SalaryMin *float64 `json:"salaryMin,omitempty"` SalaryMin *float64 `json:"salaryMin,omitempty"`
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"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"` SalaryNegotiable *bool `json:"salaryNegotiable,omitempty"`
WorkingHours *string `json:"workingHours,omitempty"` EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
Location *string `json:"location,omitempty"` WorkingHours *string `json:"workingHours,omitempty"`
RegionID *int `json:"regionId,omitempty"` Location *string `json:"location,omitempty"`
CityID *int `json:"cityId,omitempty"` RegionID *int `json:"regionId,omitempty"`
Requirements map[string]interface{} `json:"requirements,omitempty"` CityID *int `json:"cityId,omitempty"`
Benefits map[string]interface{} `json:"benefits,omitempty"` Requirements map[string]interface{} `json:"requirements,omitempty"`
VisaSupport *bool `json:"visaSupport,omitempty"` Benefits map[string]interface{} `json:"benefits,omitempty"`
LanguageLevel *string `json:"languageLevel,omitempty"` VisaSupport *bool `json:"visaSupport,omitempty"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed review published paused expired archived reported"` LanguageLevel *string `json:"languageLevel,omitempty"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed review published paused expired archived reported"`
} }
// CreateApplicationRequest represents a job application (guest or logged user) // CreateApplicationRequest represents a job application (guest or logged user)

View file

@ -26,31 +26,32 @@ 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
` `
job := &models.Job{ job := &models.Job{
CompanyID: req.CompanyID, CompanyID: req.CompanyID,
CreatedBy: createdBy, CreatedBy: createdBy,
Title: req.Title, Title: req.Title,
Description: req.Description, Description: req.Description,
SalaryMin: req.SalaryMin, SalaryMin: req.SalaryMin,
SalaryMax: req.SalaryMax, SalaryMax: req.SalaryMax,
SalaryType: req.SalaryType, SalaryType: req.SalaryType,
EmploymentType: req.EmploymentType, SalaryNegotiable: req.SalaryNegotiable,
WorkingHours: req.WorkingHours, EmploymentType: req.EmploymentType,
Location: req.Location, WorkingHours: req.WorkingHours,
RegionID: req.RegionID, Location: req.Location,
CityID: req.CityID, RegionID: req.RegionID,
Requirements: req.Requirements, CityID: req.CityID,
Benefits: req.Benefits, Requirements: req.Requirements,
VisaSupport: req.VisaSupport, Benefits: req.Benefits,
LanguageLevel: req.LanguageLevel, VisaSupport: req.VisaSupport,
Status: req.Status, LanguageLevel: req.LanguageLevel,
CreatedAt: time.Now(), Status: req.Status,
UpdatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(),
} }
fmt.Println("[JOB_SERVICE DEBUG] Executing INSERT query...") fmt.Println("[JOB_SERVICE DEBUG] Executing INSERT query...")
@ -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);