feat(marketplace): add company management and user editing features
Backend: - Add phone, operating_hours, is_24_hours fields to Tenant model - Create migration 0005_tenants_operating_hours.sql for new columns - Update postgres repository queries for new fields Frontend Company.tsx: - Expand company edit form with phone, city, state - Add operating hours section with 24h toggle - Add user edit/delete buttons with Actions column - Add user edit modal with name, email, role fields - Add handleDeleteUser and handleSaveUser functions
This commit is contained in:
parent
352ef86617
commit
8d4731268e
4 changed files with 240 additions and 32 deletions
|
|
@ -15,10 +15,15 @@ type Tenant struct {
|
||||||
LicenseNumber string `db:"license_number" json:"license_number"`
|
LicenseNumber string `db:"license_number" json:"license_number"`
|
||||||
IsVerified bool `db:"is_verified" json:"is_verified"`
|
IsVerified bool `db:"is_verified" json:"is_verified"`
|
||||||
// Location
|
// Location
|
||||||
Latitude float64 `db:"latitude" json:"latitude"`
|
Latitude float64 `db:"latitude" json:"latitude"`
|
||||||
Longitude float64 `db:"longitude" json:"longitude"`
|
Longitude float64 `db:"longitude" json:"longitude"`
|
||||||
City string `db:"city" json:"city"`
|
City string `db:"city" json:"city"`
|
||||||
State string `db:"state" json:"state"`
|
State string `db:"state" json:"state"`
|
||||||
|
// Contact & Hours
|
||||||
|
Phone string `db:"phone" json:"phone"`
|
||||||
|
OperatingHours string `db:"operating_hours" json:"operating_hours"` // e.g. "Seg-Sex: 08:00-18:00, Sab: 08:00-12:00"
|
||||||
|
Is24Hours bool `db:"is_24_hours" json:"is_24_hours"`
|
||||||
|
// Timestamps
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE companies ADD COLUMN phone TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE companies ADD COLUMN operating_hours TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE companies ADD COLUMN is_24_hours BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE companies DROP COLUMN phone;
|
||||||
|
ALTER TABLE companies DROP COLUMN operating_hours;
|
||||||
|
ALTER TABLE companies DROP COLUMN is_24_hours;
|
||||||
|
|
@ -29,8 +29,8 @@ func (r *Repository) CreateCompany(ctx context.Context, company *domain.Company)
|
||||||
company.CreatedAt = now
|
company.CreatedAt = now
|
||||||
company.UpdatedAt = now
|
company.UpdatedAt = now
|
||||||
|
|
||||||
query := `INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at)
|
query := `INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, phone, operating_hours, is_24_hours, created_at, updated_at)
|
||||||
VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :city, :state, :created_at, :updated_at)`
|
VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :city, :state, :phone, :operating_hours, :is_24_hours, :created_at, :updated_at)`
|
||||||
|
|
||||||
_, err := r.db.NamedExecContext(ctx, query, company)
|
_, err := r.db.NamedExecContext(ctx, query, company)
|
||||||
return err
|
return err
|
||||||
|
|
@ -72,7 +72,7 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil
|
||||||
filter.Limit = 20
|
filter.Limit = 20
|
||||||
}
|
}
|
||||||
args = append(args, filter.Limit, filter.Offset)
|
args = append(args, filter.Limit, filter.Offset)
|
||||||
listQuery := fmt.Sprintf("SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
|
listQuery := fmt.Sprintf("SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, phone, operating_hours, is_24_hours, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
|
||||||
|
|
||||||
var companies []domain.Company
|
var companies []domain.Company
|
||||||
if err := r.db.SelectContext(ctx, &companies, listQuery, args...); err != nil {
|
if err := r.db.SelectContext(ctx, &companies, listQuery, args...); err != nil {
|
||||||
|
|
@ -83,7 +83,7 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil
|
||||||
|
|
||||||
func (r *Repository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
func (r *Repository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
||||||
var company domain.Company
|
var company domain.Company
|
||||||
query := `SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at FROM companies WHERE id = $1`
|
query := `SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, phone, operating_hours, is_24_hours, created_at, updated_at FROM companies WHERE id = $1`
|
||||||
if err := r.db.GetContext(ctx, &company, query, id); err != nil {
|
if err := r.db.GetContext(ctx, &company, query, id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +94,7 @@ func (r *Repository) UpdateCompany(ctx context.Context, company *domain.Company)
|
||||||
company.UpdatedAt = time.Now().UTC()
|
company.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
query := `UPDATE companies
|
query := `UPDATE companies
|
||||||
SET cnpj = :cnpj, corporate_name = :corporate_name, category = :category, license_number = :license_number, is_verified = :is_verified, latitude = :latitude, longitude = :longitude, city = :city, state = :state, updated_at = :updated_at
|
SET cnpj = :cnpj, corporate_name = :corporate_name, category = :category, license_number = :license_number, is_verified = :is_verified, latitude = :latitude, longitude = :longitude, city = :city, state = :state, phone = :phone, operating_hours = :operating_hours, is_24_hours = :is_24_hours, updated_at = :updated_at
|
||||||
WHERE id = :id`
|
WHERE id = :id`
|
||||||
|
|
||||||
res, err := r.db.NamedExecContext(ctx, query, company)
|
res, err := r.db.NamedExecContext(ctx, query, company)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ interface Company {
|
||||||
state: string
|
state: string
|
||||||
latitude: number
|
latitude: number
|
||||||
longitude: number
|
longitude: number
|
||||||
|
phone: string
|
||||||
|
operating_hours: string
|
||||||
|
is_24_hours: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -180,8 +183,43 @@ export function CompanyPage() {
|
||||||
const [shippingEditing, setShippingEditing] = useState(false)
|
const [shippingEditing, setShippingEditing] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
corporate_name: '',
|
corporate_name: '',
|
||||||
license_number: ''
|
license_number: '',
|
||||||
|
phone: '',
|
||||||
|
operating_hours: '',
|
||||||
|
is_24_hours: false,
|
||||||
|
city: '',
|
||||||
|
state: ''
|
||||||
})
|
})
|
||||||
|
// User editing state
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
|
const [userForm, setUserForm] = useState({ name: '', email: '', role: '' })
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir este usuário?')) return
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/v1/users/${userId}`)
|
||||||
|
if (company) void loadUsers(company.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao excluir usuário:', err)
|
||||||
|
setUsersError('Erro ao excluir usuário')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveUser = async () => {
|
||||||
|
if (!editingUser) return
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/v1/users/${editingUser.id}`, {
|
||||||
|
name: userForm.name,
|
||||||
|
email: userForm.email,
|
||||||
|
role: userForm.role
|
||||||
|
})
|
||||||
|
setEditingUser(null)
|
||||||
|
if (company) void loadUsers(company.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao salvar usuário:', err)
|
||||||
|
setUsersError('Erro ao salvar usuário')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCompany()
|
loadCompany()
|
||||||
|
|
@ -194,7 +232,12 @@ export function CompanyPage() {
|
||||||
setCompany(data)
|
setCompany(data)
|
||||||
setForm({
|
setForm({
|
||||||
corporate_name: data.corporate_name,
|
corporate_name: data.corporate_name,
|
||||||
license_number: data.license_number
|
license_number: data.license_number,
|
||||||
|
phone: data.phone || '',
|
||||||
|
operating_hours: data.operating_hours || '',
|
||||||
|
is_24_hours: data.is_24_hours || false,
|
||||||
|
city: data.city,
|
||||||
|
state: data.state
|
||||||
})
|
})
|
||||||
setError(null)
|
setError(null)
|
||||||
void loadUsers(data.id)
|
void loadUsers(data.id)
|
||||||
|
|
@ -457,29 +500,99 @@ export function CompanyPage() {
|
||||||
|
|
||||||
{company && activeTab === 'overview' && editing && (
|
{company && activeTab === 'overview' && editing && (
|
||||||
<div className="rounded-lg bg-white p-6 shadow-sm space-y-4">
|
<div className="rounded-lg bg-white p-6 shadow-sm space-y-4">
|
||||||
<div>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div>
|
||||||
Razão Social
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
</label>
|
Razão Social
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
value={form.corporate_name}
|
type="text"
|
||||||
onChange={(e) => setForm({ ...form, corporate_name: e.target.value })}
|
value={form.corporate_name}
|
||||||
className="w-full rounded border border-gray-200 px-3 py-2"
|
onChange={(e) => setForm({ ...form, corporate_name: e.target.value })}
|
||||||
/>
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Licença Sanitária
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.license_number}
|
||||||
|
onChange={(e) => setForm({ ...form, license_number: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Telefone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
placeholder="(00) 00000-0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Cidade
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.city}
|
||||||
|
onChange={(e) => setForm({ ...form, city: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.state}
|
||||||
|
onChange={(e) => setForm({ ...form, state: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
maxLength={2}
|
||||||
|
placeholder="GO"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div className="border-t pt-4">
|
||||||
Licença Sanitária
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Horário de Funcionamento</h3>
|
||||||
</label>
|
<div className="space-y-3">
|
||||||
<input
|
<label className="flex items-center gap-2">
|
||||||
type="text"
|
<input
|
||||||
value={form.license_number}
|
type="checkbox"
|
||||||
onChange={(e) => setForm({ ...form, license_number: e.target.value })}
|
checked={form.is_24_hours}
|
||||||
className="w-full rounded border border-gray-200 px-3 py-2"
|
onChange={(e) => setForm({ ...form, is_24_hours: e.target.checked })}
|
||||||
/>
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Funciona 24 horas</span>
|
||||||
|
</label>
|
||||||
|
{!form.is_24_hours && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Horários
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.operating_hours}
|
||||||
|
onChange={(e) => setForm({ ...form, operating_hours: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Seg-Sex: 08:00 - 18:00 Sáb: 08:00 - 12:00 Dom: Fechado"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={saveChanges}
|
onClick={saveChanges}
|
||||||
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
|
@ -529,6 +642,7 @@ export function CompanyPage() {
|
||||||
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500">Email</th>
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500">Email</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500">Perfil</th>
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500">Perfil</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500">Status</th>
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500">Status</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-500">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
|
@ -550,6 +664,23 @@ export function CompanyPage() {
|
||||||
{user.email_verified ? 'Verificado' : 'Pendente'}
|
{user.email_verified ? 'Verificado' : 'Pendente'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUser(user)
|
||||||
|
setUserForm({ name: user.name, email: user.email, role: user.role })
|
||||||
|
}}
|
||||||
|
className="text-medicalBlue hover:underline text-xs font-medium mr-2"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(user.id)}
|
||||||
|
className="text-red-600 hover:underline text-xs font-medium"
|
||||||
|
>
|
||||||
|
Excluir
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -800,6 +931,69 @@ export function CompanyPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User Edit Modal */}
|
||||||
|
{editingUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setEditingUser(null)}>
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Editar Usuário</h2>
|
||||||
|
<button onClick={() => setEditingUser(null)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nome</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userForm.name}
|
||||||
|
onChange={(e) => setUserForm({ ...userForm, name: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={userForm.email}
|
||||||
|
onChange={(e) => setUserForm({ ...userForm, email: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Perfil</label>
|
||||||
|
<select
|
||||||
|
value={userForm.role}
|
||||||
|
onChange={(e) => setUserForm({ ...userForm, role: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="Dono">Dono</option>
|
||||||
|
<option value="Colaborador">Colaborador</option>
|
||||||
|
<option value="Entregador">Entregador</option>
|
||||||
|
<option value="Admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveUser}
|
||||||
|
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Salvar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingUser(null)}
|
||||||
|
className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Shell>
|
</Shell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue