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"`
|
||||
IsVerified bool `db:"is_verified" json:"is_verified"`
|
||||
// Location
|
||||
Latitude float64 `db:"latitude" json:"latitude"`
|
||||
Longitude float64 `db:"longitude" json:"longitude"`
|
||||
City string `db:"city" json:"city"`
|
||||
State string `db:"state" json:"state"`
|
||||
Latitude float64 `db:"latitude" json:"latitude"`
|
||||
Longitude float64 `db:"longitude" json:"longitude"`
|
||||
City string `db:"city" json:"city"`
|
||||
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"`
|
||||
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.UpdatedAt = now
|
||||
|
||||
query := `INSERT INTO companies (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, :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, :phone, :operating_hours, :is_24_hours, :created_at, :updated_at)`
|
||||
|
||||
_, err := r.db.NamedExecContext(ctx, query, company)
|
||||
return err
|
||||
|
|
@ -72,7 +72,7 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil
|
|||
filter.Limit = 20
|
||||
}
|
||||
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
|
||||
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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ func (r *Repository) UpdateCompany(ctx context.Context, company *domain.Company)
|
|||
company.UpdatedAt = time.Now().UTC()
|
||||
|
||||
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`
|
||||
|
||||
res, err := r.db.NamedExecContext(ctx, query, company)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ interface Company {
|
|||
state: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
phone: string
|
||||
operating_hours: string
|
||||
is_24_hours: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -180,8 +183,43 @@ export function CompanyPage() {
|
|||
const [shippingEditing, setShippingEditing] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
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(() => {
|
||||
loadCompany()
|
||||
|
|
@ -194,7 +232,12 @@ export function CompanyPage() {
|
|||
setCompany(data)
|
||||
setForm({
|
||||
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)
|
||||
void loadUsers(data.id)
|
||||
|
|
@ -457,29 +500,99 @@ export function CompanyPage() {
|
|||
|
||||
{company && activeTab === 'overview' && editing && (
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Razão Social
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.corporate_name}
|
||||
onChange={(e) => setForm({ ...form, corporate_name: e.target.value })}
|
||||
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Razão Social
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.corporate_name}
|
||||
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>
|
||||
<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 className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Horário de Funcionamento</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_24_hours}
|
||||
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 className="flex gap-3">
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
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">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-right text-xs font-semibold text-gray-500">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
|
|
@ -550,6 +664,23 @@ export function CompanyPage() {
|
|||
{user.email_verified ? 'Verificado' : 'Pendente'}
|
||||
</span>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -800,6 +931,69 @@ export function CompanyPage() {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue