feat: add currency, salary period, and rich text description
Frontend: - Added currency selector (BRL, USD, EUR, JPY, GBP, CNY, AED, CAD, AUD, CHF) - Added salary period dropdown (hourly, daily, weekly, monthly, yearly) - Created RichTextEditor component for job descriptions (Bold, Lists, Alignment) - Updated confirmation step to display currency symbol and period label Backend: - JobService now persists currency in job creation - Extended currency validation in DTOs Seeder: - Already includes currency in job insertion
This commit is contained in:
parent
91e4417c95
commit
cca951ca23
4 changed files with 178 additions and 19 deletions
|
|
@ -8,7 +8,7 @@ type CreateJobRequest struct {
|
|||
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"`
|
||||
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY CNY AED CAD AUD CHF"`
|
||||
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"`
|
||||
|
|
@ -29,7 +29,7 @@ type UpdateJobRequest struct {
|
|||
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"`
|
||||
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY CNY AED CAD AUD CHF"`
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
|
|||
|
||||
query := `
|
||||
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, currency,
|
||||
employment_type, working_hours, location, region_id, city_id,
|
||||
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)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
|
|
@ -39,6 +39,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
|
|||
SalaryMin: req.SalaryMin,
|
||||
SalaryMax: req.SalaryMax,
|
||||
SalaryType: req.SalaryType,
|
||||
Currency: req.Currency,
|
||||
SalaryNegotiable: req.SalaryNegotiable,
|
||||
EmploymentType: req.EmploymentType,
|
||||
WorkingHours: req.WorkingHours,
|
||||
|
|
@ -59,7 +60,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
|
|||
|
||||
err := s.DB.QueryRow(
|
||||
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.Currency,
|
||||
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.SalaryNegotiable,
|
||||
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
Eye, EyeOff, Globe
|
||||
} from "lucide-react";
|
||||
import { LocationPicker } from "@/components/location-picker";
|
||||
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
// Common Country Codes
|
||||
|
|
@ -37,6 +38,24 @@ const COUNTRY_CODES = [
|
|||
{ code: "+51", country: "Peru (PE)" },
|
||||
].sort((a, b) => a.country.localeCompare(b.country));
|
||||
|
||||
// Currency symbol helper
|
||||
const getCurrencySymbol = (code: string): string => {
|
||||
const symbols: Record<string, string> = {
|
||||
'BRL': 'R$', 'USD': '$', 'EUR': '€', 'JPY': '¥', 'GBP': '£',
|
||||
'CNY': '¥', 'AED': 'د.إ', 'CAD': 'C$', 'AUD': 'A$', 'CHF': 'Fr'
|
||||
};
|
||||
return symbols[code] || code;
|
||||
};
|
||||
|
||||
// Salary period label helper
|
||||
const getSalaryPeriodLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
'hourly': '/hora', 'daily': '/dia', 'weekly': '/semana',
|
||||
'monthly': '/mês', 'yearly': '/ano'
|
||||
};
|
||||
return labels[type] || '';
|
||||
};
|
||||
|
||||
export default function PostJobPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
|
|
@ -63,6 +82,8 @@ export default function PostJobPage() {
|
|||
salaryMin: "",
|
||||
salaryMax: "",
|
||||
salaryFixed: "", // For fixed salary mode
|
||||
currency: "BRL", // Default currency
|
||||
salaryType: "monthly", // Default salary period
|
||||
employmentType: "",
|
||||
workMode: "remote",
|
||||
workingHours: "",
|
||||
|
|
@ -144,6 +165,8 @@ export default function PostJobPage() {
|
|||
// 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)),
|
||||
salaryType: job.salaryNegotiable ? null : job.salaryType,
|
||||
currency: job.salaryNegotiable ? null : job.currency,
|
||||
salaryNegotiable: job.salaryNegotiable,
|
||||
employmentType: job.employmentType || null,
|
||||
workingHours: job.workingHours || null,
|
||||
|
|
@ -348,12 +371,12 @@ export default function PostJobPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Descrição *</Label>
|
||||
<Textarea
|
||||
<Label>Descrição da Vaga *</Label>
|
||||
<RichTextEditor
|
||||
value={job.description}
|
||||
onChange={(e) => setJob({ ...job, description: e.target.value })}
|
||||
onChange={(val) => setJob({ ...job, description: val })}
|
||||
placeholder="Descreva as responsabilidades, requisitos e benefícios..."
|
||||
rows={5}
|
||||
minHeight="150px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -382,17 +405,53 @@ export default function PostJobPage() {
|
|||
|
||||
{!job.salaryNegotiable && (
|
||||
<>
|
||||
{salaryMode === 'fixed' ? (
|
||||
{/* Currency and Period Row */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
value={job.salaryFixed}
|
||||
onChange={(e) => setJob({ ...job, salaryFixed: e.target.value })}
|
||||
placeholder="Valor"
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground">Moeda</Label>
|
||||
<select
|
||||
value={job.currency}
|
||||
onChange={(e) => setJob({ ...job, currency: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background text-sm"
|
||||
>
|
||||
<option value="BRL">R$ (BRL)</option>
|
||||
<option value="USD">$ (USD)</option>
|
||||
<option value="EUR">€ (EUR)</option>
|
||||
<option value="JPY">¥ (JPY)</option>
|
||||
<option value="GBP">£ (GBP)</option>
|
||||
<option value="CNY">¥ (CNY)</option>
|
||||
<option value="AED">د.إ (AED)</option>
|
||||
<option value="CAD">$ (CAD)</option>
|
||||
<option value="AUD">$ (AUD)</option>
|
||||
<option value="CHF">Fr (CHF)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Período</Label>
|
||||
<select
|
||||
value={job.salaryType}
|
||||
onChange={(e) => setJob({ ...job, salaryType: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background text-sm"
|
||||
>
|
||||
<option value="hourly">por hora</option>
|
||||
<option value="daily">por dia</option>
|
||||
<option value="weekly">por semana</option>
|
||||
<option value="monthly">por mês</option>
|
||||
<option value="yearly">por ano</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Salary Value(s) */}
|
||||
{salaryMode === 'fixed' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={job.salaryFixed}
|
||||
onChange={(e) => setJob({ ...job, salaryFixed: e.target.value })}
|
||||
placeholder="Valor"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={job.salaryMin}
|
||||
|
|
@ -493,8 +552,8 @@ export default function PostJobPage() {
|
|||
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")
|
||||
? (job.salaryFixed ? `${getCurrencySymbol(job.currency)} ${job.salaryFixed} ${getSalaryPeriodLabel(job.salaryType)}` : "A combinar")
|
||||
: (job.salaryMin && job.salaryMax ? `${getCurrencySymbol(job.currency)} ${job.salaryMin} - ${job.salaryMax} ${getSalaryPeriodLabel(job.salaryType)}` : "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>
|
||||
|
|
|
|||
99
frontend/src/components/rich-text-editor.tsx
Normal file
99
frontend/src/components/rich-text-editor.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Bold, List, ListOrdered, AlignLeft, AlignRight } from "lucide-react";
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
export function RichTextEditor({ value, onChange, placeholder, minHeight = "200px" }: RichTextEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const execCommand = useCallback((command: string, value?: string) => {
|
||||
document.execCommand(command, false, value);
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
const handleInput = () => {
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
};
|
||||
|
||||
const ToolbarButton = ({ onClick, active, children, title }: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`p-1.5 rounded hover:bg-muted transition-colors ${active ? 'bg-muted text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg overflow-hidden ${isFocused ? 'ring-2 ring-ring ring-offset-2' : ''}`}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-2 border-b bg-muted/30">
|
||||
<ToolbarButton onClick={() => execCommand('bold')} title="Negrito">
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<ToolbarButton onClick={() => execCommand('insertUnorderedList')} title="Lista com marcadores">
|
||||
<List className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => execCommand('insertOrderedList')} title="Lista numerada">
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<ToolbarButton onClick={() => execCommand('justifyLeft')} title="Alinhar à esquerda">
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => execCommand('justifyRight')} title="Alinhar à direita">
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{/* Editor Area */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className="p-3 outline-none bg-background"
|
||||
style={{ minHeight }}
|
||||
data-placeholder={placeholder}
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
|
||||
<style jsx>{`
|
||||
[contenteditable]:empty:before {
|
||||
content: attr(data-placeholder);
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue