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
type CreateJobRequest struct {
CompanyID string `json:"companyId" validate:"required"`
Title string `json:"title" validate:"required,min=5,max=255"`
Description string `json:"description" validate:"required,min=20"`
SalaryMin *float64 `json:"salaryMin,omitempty"`
SalaryMax *float64 `json:"salaryMax,omitempty"`
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"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"`
Location *string `json:"location,omitempty"`
RegionID *int `json:"regionId,omitempty"`
CityID *int `json:"cityId,omitempty"`
Requirements map[string]interface{} `json:"requirements,omitempty"`
Benefits map[string]interface{} `json:"benefits,omitempty"`
VisaSupport bool `json:"visaSupport"`
LanguageLevel *string `json:"languageLevel,omitempty"`
Status string `json:"status" validate:"oneof=draft open closed review published paused expired archived reported"`
CompanyID string `json:"companyId" validate:"required"`
Title string `json:"title" validate:"required,min=5,max=255"`
Description string `json:"description" validate:"required,min=20"`
SalaryMin *float64 `json:"salaryMin,omitempty"`
SalaryMax *float64 `json:"salaryMax,omitempty"`
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"`
SalaryNegotiable bool `json:"salaryNegotiable"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"`
Location *string `json:"location,omitempty"`
RegionID *int `json:"regionId,omitempty"`
CityID *int `json:"cityId,omitempty"`
Requirements map[string]interface{} `json:"requirements,omitempty"`
Benefits map[string]interface{} `json:"benefits,omitempty"`
VisaSupport bool `json:"visaSupport"`
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
type UpdateJobRequest struct {
Title *string `json:"title,omitempty" validate:"omitempty,min=5,max=255"`
Description *string `json:"description,omitempty" validate:"omitempty,min=20"`
SalaryMin *float64 `json:"salaryMin,omitempty"`
SalaryMax *float64 `json:"salaryMax,omitempty"`
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"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"`
Location *string `json:"location,omitempty"`
RegionID *int `json:"regionId,omitempty"`
CityID *int `json:"cityId,omitempty"`
Requirements map[string]interface{} `json:"requirements,omitempty"`
Benefits map[string]interface{} `json:"benefits,omitempty"`
VisaSupport *bool `json:"visaSupport,omitempty"`
LanguageLevel *string `json:"languageLevel,omitempty"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed review published paused expired archived reported"`
Title *string `json:"title,omitempty" validate:"omitempty,min=5,max=255"`
Description *string `json:"description,omitempty" validate:"omitempty,min=20"`
SalaryMin *float64 `json:"salaryMin,omitempty"`
SalaryMax *float64 `json:"salaryMax,omitempty"`
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"`
SalaryNegotiable *bool `json:"salaryNegotiable,omitempty"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"`
Location *string `json:"location,omitempty"`
RegionID *int `json:"regionId,omitempty"`
CityID *int `json:"cityId,omitempty"`
Requirements map[string]interface{} `json:"requirements,omitempty"`
Benefits map[string]interface{} `json:"benefits,omitempty"`
VisaSupport *bool `json:"visaSupport,omitempty"`
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)

View file

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

View file

@ -65,6 +65,7 @@ export default function PostJobPage() {
salaryFixed: "", // For fixed salary mode
employmentType: "",
workMode: "remote",
workingHours: "",
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)),
salaryNegotiable: job.salaryNegotiable,
employmentType: job.employmentType || null,
workingHours: job.workingHours || null,
workMode: job.workMode,
status: "pending", // Pending review
}),
@ -418,7 +420,7 @@ export default function PostJobPage() {
</>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Tipo de Contrato</Label>
<select
@ -434,6 +436,18 @@ export default function PostJobPage() {
<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
@ -482,7 +496,7 @@ export default function PostJobPage() {
? (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.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 className="flex gap-3">
<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 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
jobParams.push(
company.id,
seedUserId,
title,
`We are looking for a talented ${title} to join our ${company.name} team. Your role as ${level} will be crucial.`,
salaryMin,
salaryMax,
salaryNegotiable ? null : salaryMin, // If negotiable, min is null
salaryNegotiable ? null : salaryMax, // If negotiable, max is null
salaryType,
currency,
employmentType,
workMode === 'remote' ? 'Flexible' : '9:00-18:00',
workingHours,
workMode === 'remote' ? 'Remote (Global)' : internationalLocations[i % internationalLocations.length],
JSON.stringify(template.skills),
JSON.stringify(getRandomItems(benefits, 4)),
i % 5 === 0, // 20% offer visa support
'beginner',
'open',
workMode
workMode,
salaryNegotiable
);
// ($1, $2, $3, ...), ($18, $19, ...)
const placeHolders = [];
for (let k = 0; k < 17; k++) {
for (let k = 0; k < 18; k++) {
placeHolders.push(`$${paramCounter++}`);
}
jobValues.push(`(${placeHolders.join(',')})`);
@ -139,7 +144,7 @@ export async function seedJobs() {
const query = `
INSERT INTO jobs (company_id, created_by, title, description,
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(',')}
`;
await pool.query(query, jobParams);