Merge pull request #46 from rede5/codex/add-company-configuration-overview

Add company page tabs for overview, users and shipping settings
This commit is contained in:
Tiago Yamamoto 2025-12-23 14:31:11 -03:00 committed by GitHub
commit 9b5c600f98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,15 +1,164 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Shell } from '../layouts/Shell'
import { apiClient } from '../services/apiClient'
interface Company {
id: string
role: string
cnpj: string
corporate_name: string
category: string
license_number: string
is_verified: boolean
city: string
state: string
latitude: number
longitude: number
created_at: string
updated_at: string
}
interface CompanyRating {
company_id: string
average_score: number
total_reviews: number
}
interface User {
id: string
company_id: string
role: string
name: string
username: string
email: string
email_verified: boolean
created_at: string
}
interface UserPage {
users: User[]
total: number
page: number
page_size: number
}
type ShippingMethodType = 'pickup' | 'own_delivery' | 'third_party_delivery'
interface ShippingMethod {
id: string
vendor_id: string
type: ShippingMethodType
active: boolean
preparation_minutes: number
max_radius_km: number
min_fee_cents: number
price_per_km_cents: number
free_shipping_threshold_cents?: number | null
pickup_address: string
pickup_hours: string
created_at: string
updated_at: string
}
interface ShippingMethodDraft {
type: ShippingMethodType
active: boolean
preparation_minutes: number
max_radius_km: number
min_fee_reais: string
price_per_km_reais: string
free_shipping_threshold_reais: string
pickup_address: string
pickup_hours: string
}
const tabs = [
{ id: 'overview', label: 'Dados da empresa' },
{ id: 'users', label: 'Usuários' },
{ id: 'shipping', label: 'Frete' }
]
const categoryLabels: Record<string, string> = {
farmacia: 'Farmácia',
distribuidora: 'Distribuidora',
admin: 'Administrador'
}
const shippingLabels: Record<ShippingMethodType, string> = {
pickup: 'Retirada no local',
own_delivery: 'Entrega própria',
third_party_delivery: 'Entrega via terceiros'
}
const formatDate = (value: string) => new Date(value).toLocaleDateString('pt-BR')
const formatCurrency = (value: number) =>
value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
const centsToReaisString = (value?: number | null) =>
value ? (value / 100).toFixed(2) : '0.00'
const parseNumber = (value: string) => {
const parsed = Number.parseFloat(value.replace(',', '.'))
return Number.isNaN(parsed) ? 0 : parsed
}
const createDefaultShippingDraft = (): ShippingMethodDraft[] => [
{
type: 'pickup',
active: false,
preparation_minutes: 30,
max_radius_km: 0,
min_fee_reais: '0.00',
price_per_km_reais: '0.00',
free_shipping_threshold_reais: '',
pickup_address: '',
pickup_hours: ''
},
{
type: 'own_delivery',
active: false,
preparation_minutes: 45,
max_radius_km: 10,
min_fee_reais: '5.00',
price_per_km_reais: '1.50',
free_shipping_threshold_reais: '',
pickup_address: '',
pickup_hours: ''
},
{
type: 'third_party_delivery',
active: false,
preparation_minutes: 60,
max_radius_km: 15,
min_fee_reais: '8.00',
price_per_km_reais: '2.00',
free_shipping_threshold_reais: '',
pickup_address: '',
pickup_hours: ''
}
]
const mergeShippingDrafts = (methods: ShippingMethod[]) => {
const defaults = createDefaultShippingDraft()
return defaults.map((fallback) => {
const existing = methods.find((method) => method.type === fallback.type)
if (!existing) {
return fallback
}
return {
type: existing.type,
active: existing.active,
preparation_minutes: existing.preparation_minutes,
max_radius_km: existing.max_radius_km,
min_fee_reais: centsToReaisString(existing.min_fee_cents),
price_per_km_reais: centsToReaisString(existing.price_per_km_cents),
free_shipping_threshold_reais: existing.free_shipping_threshold_cents
? centsToReaisString(existing.free_shipping_threshold_cents)
: '',
pickup_address: existing.pickup_address,
pickup_hours: existing.pickup_hours
}
})
}
export function CompanyPage() {
@ -17,6 +166,18 @@ export function CompanyPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [activeTab, setActiveTab] = useState('overview')
const [rating, setRating] = useState<CompanyRating | null>(null)
const [ratingError, setRatingError] = useState<string | null>(null)
const [users, setUsers] = useState<User[]>([])
const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState<string | null>(null)
const [usersMeta, setUsersMeta] = useState({ total: 0, page: 1, page_size: 20 })
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([])
const [shippingDraft, setShippingDraft] = useState<ShippingMethodDraft[]>(createDefaultShippingDraft())
const [shippingLoading, setShippingLoading] = useState(false)
const [shippingError, setShippingError] = useState<string | null>(null)
const [shippingEditing, setShippingEditing] = useState(false)
const [form, setForm] = useState({
corporate_name: '',
license_number: ''
@ -36,6 +197,9 @@ export function CompanyPage() {
license_number: data.license_number
})
setError(null)
void loadUsers(data.id)
void loadShipping(data.id)
void loadRating(data.id)
} catch (err) {
setError('Erro ao carregar dados da empresa')
console.error(err)
@ -44,6 +208,54 @@ export function CompanyPage() {
}
}
const loadUsers = async (companyId: string) => {
try {
setUsersLoading(true)
const data = await apiClient.get<UserPage>(`/v1/users?page=1&page_size=50&company_id=${companyId}`)
setUsers(data.users || [])
setUsersMeta({
total: data.total,
page: data.page,
page_size: data.page_size
})
setUsersError(null)
} catch (err) {
console.error('Erro ao carregar usuários:', err)
setUsersError('Erro ao carregar usuários da empresa')
} finally {
setUsersLoading(false)
}
}
const loadShipping = async (companyId: string) => {
try {
setShippingLoading(true)
const data = await apiClient.get<ShippingMethod[]>(`/v1/shipping/settings/${companyId}`)
setShippingMethods(data || [])
setShippingDraft(mergeShippingDrafts(data || []))
setShippingError(null)
} catch (err) {
console.error('Erro ao carregar frete:', err)
setShippingMethods([])
setShippingDraft(createDefaultShippingDraft())
setShippingError('Erro ao carregar configurações de frete')
} finally {
setShippingLoading(false)
}
}
const loadRating = async (companyId: string) => {
try {
const data = await apiClient.get<CompanyRating>(`/v1/companies/${companyId}/rating`)
setRating(data)
setRatingError(null)
} catch (err) {
console.error('Erro ao carregar avaliação:', err)
setRating(null)
setRatingError('Erro ao carregar avaliação da empresa')
}
}
const saveChanges = async () => {
if (!company) return
try {
@ -55,18 +267,53 @@ export function CompanyPage() {
}
}
const roleLabels: Record<string, string> = {
pharmacy: 'Farmácia',
distributor: 'Distribuidora',
admin: 'Administrador'
const saveShippingSettings = async () => {
if (!company) return
try {
setShippingLoading(true)
const payload = {
methods: shippingDraft.map((method) => ({
type: method.type,
active: method.active,
preparation_minutes: method.preparation_minutes,
max_radius_km: method.max_radius_km,
min_fee_cents: Math.round(parseNumber(method.min_fee_reais) * 100),
price_per_km_cents: Math.round(parseNumber(method.price_per_km_reais) * 100),
free_shipping_threshold_cents: method.free_shipping_threshold_reais
? Math.round(parseNumber(method.free_shipping_threshold_reais) * 100)
: undefined,
pickup_address: method.pickup_address,
pickup_hours: method.pickup_hours
}))
}
await apiClient.put(`/v1/shipping/settings/${company.id}`, payload)
setShippingEditing(false)
await loadShipping(company.id)
} catch (err) {
console.error('Erro ao salvar frete:', err)
setShippingError('Erro ao salvar configurações de frete')
} finally {
setShippingLoading(false)
}
}
const shippingSummary = useMemo(() => {
if (!shippingMethods.length) {
return 'Nenhuma configuração ativa.'
}
const actives = shippingMethods.filter((method) => method.active)
if (!actives.length) {
return 'Configurações cadastradas, mas sem métodos ativos.'
}
return `${actives.length} método(s) ativo(s)`
}, [shippingMethods])
return (
<Shell>
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-medicalBlue">Minha Empresa</h1>
{company && !editing && (
{company && !editing && activeTab === 'overview' && (
<button
onClick={() => setEditing(true)}
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white"
@ -76,6 +323,21 @@ export function CompanyPage() {
)}
</div>
<div className="flex gap-2 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-semibold ${activeTab === tab.id
? 'border-b-2 border-medicalBlue text-medicalBlue'
: 'text-gray-500 hover:text-medicalBlue'
}`}
>
{tab.label}
</button>
))}
</div>
{loading && (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
@ -86,7 +348,7 @@ export function CompanyPage() {
<div className="rounded bg-red-100 p-4 text-red-700">{error}</div>
)}
{company && !editing && (
{company && activeTab === 'overview' && !editing && (
<div className="rounded-lg bg-white p-6 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Status</span>
@ -100,7 +362,7 @@ export function CompanyPage() {
</span>
)}
</div>
<div className="border-t pt-4 space-y-3">
<div className="grid gap-4 border-t pt-4 sm:grid-cols-2">
<div>
<p className="text-sm text-gray-500">Razão Social</p>
<p className="font-semibold text-gray-800">{company.corporate_name}</p>
@ -110,24 +372,63 @@ export function CompanyPage() {
<p className="font-semibold text-gray-800">{company.cnpj}</p>
</div>
<div>
<p className="text-sm text-gray-500">Tipo</p>
<p className="font-semibold text-gray-800">{roleLabels[company.role] || company.role}</p>
<p className="text-sm text-gray-500">Categoria</p>
<p className="font-semibold text-gray-800">
{categoryLabels[company.category] || company.category}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Licença Sanitária</p>
<p className="font-semibold text-gray-800">{company.license_number}</p>
</div>
<div>
<p className="text-sm text-gray-500">Cadastro</p>
<p className="text-sm text-gray-500">Localização</p>
<p className="font-semibold text-gray-800">
{new Date(company.created_at).toLocaleDateString('pt-BR')}
{company.city}/{company.state}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Coordenadas</p>
<p className="font-semibold text-gray-800">
{company.latitude.toFixed(4)}, {company.longitude.toFixed(4)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Cadastro</p>
<p className="font-semibold text-gray-800">
{formatDate(company.created_at)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Atualização</p>
<p className="font-semibold text-gray-800">
{formatDate(company.updated_at)}
</p>
</div>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Avaliação</p>
<p className="font-semibold text-gray-800">
{rating ? rating.average_score.toFixed(1) : 'Sem avaliação'}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Total de reviews</p>
<p className="font-semibold text-gray-800">
{rating ? rating.total_reviews : 0}
</p>
</div>
</div>
{ratingError && (
<p className="mt-2 text-sm text-yellow-700">{ratingError}</p>
)}
</div>
</div>
)}
{company && editing && (
{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">
@ -167,6 +468,310 @@ export function CompanyPage() {
</div>
</div>
)}
{activeTab === 'users' && (
<div className="rounded-lg bg-white p-6 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">Usuários da empresa</h2>
<p className="text-sm text-gray-500">
{usersMeta.total} usuário(s) vinculados.
</p>
</div>
<button
onClick={() => company && loadUsers(company.id)}
className="rounded border border-gray-200 px-3 py-1 text-sm text-gray-600 hover:border-medicalBlue hover:text-medicalBlue"
>
Atualizar
</button>
</div>
{usersLoading ? (
<div className="py-6 text-center text-gray-500">Carregando usuários...</div>
) : usersError ? (
<div className="rounded bg-red-100 p-4 text-red-700">{usersError}</div>
) : users.length === 0 ? (
<div className="py-6 text-center text-gray-500">
Nenhum usuário associado a esta empresa.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500">Nome</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">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{users.map((user) => (
<tr key={user.id}>
<td className="px-4 py-3 text-sm text-gray-900">
<div className="font-medium">{user.name}</div>
<div className="text-xs text-gray-500">@{user.username}</div>
</td>
<td className="px-4 py-3 text-sm text-gray-700">{user.email}</td>
<td className="px-4 py-3 text-sm text-gray-700">{user.role}</td>
<td className="px-4 py-3 text-sm">
<span
className={`rounded-full px-2 py-1 text-xs font-semibold ${user.email_verified
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{user.email_verified ? 'Verificado' : 'Pendente'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'shipping' && (
<div className="rounded-lg bg-white p-6 shadow-sm space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">Configuração de frete</h2>
<p className="text-sm text-gray-500">{shippingSummary}</p>
</div>
<div className="flex gap-2">
{!shippingEditing && (
<button
onClick={() => setShippingEditing(true)}
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white"
>
Editar frete
</button>
)}
{shippingEditing && (
<>
<button
onClick={saveShippingSettings}
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
>
Salvar
</button>
<button
onClick={() => {
setShippingEditing(false)
setShippingDraft(mergeShippingDrafts(shippingMethods))
}}
className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700"
>
Cancelar
</button>
</>
)}
</div>
</div>
{shippingLoading && (
<div className="py-6 text-center text-gray-500">Carregando frete...</div>
)}
{shippingError && (
<div className="rounded bg-red-100 p-4 text-red-700">{shippingError}</div>
)}
<div className="space-y-4">
{(shippingEditing ? shippingDraft : mergeShippingDrafts(shippingMethods)).map((method, index) => (
<div key={method.type} className="rounded-lg border border-gray-200 p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-md font-semibold text-gray-900">
{shippingLabels[method.type]}
</h3>
{shippingEditing ? (
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={method.active}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = { ...method, active: e.target.checked }
setShippingDraft(updated)
}}
/>
Ativo
</label>
) : (
<span
className={`rounded-full px-2 py-1 text-xs font-semibold ${method.active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}
>
{method.active ? 'Ativo' : 'Inativo'}
</span>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs text-gray-500">Tempo de preparo (min)</p>
{shippingEditing ? (
<input
type="number"
value={method.preparation_minutes}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = {
...method,
preparation_minutes: Number(e.target.value)
}
setShippingDraft(updated)
}}
className="mt-1 w-full rounded border border-gray-200 px-3 py-2 text-sm"
/>
) : (
<p className="font-semibold text-gray-800">{method.preparation_minutes} min</p>
)}
</div>
<div>
<p className="text-xs text-gray-500">Raio máximo (km)</p>
{shippingEditing ? (
<input
type="number"
value={method.max_radius_km}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = {
...method,
max_radius_km: Number(e.target.value)
}
setShippingDraft(updated)
}}
className="mt-1 w-full rounded border border-gray-200 px-3 py-2 text-sm"
/>
) : (
<p className="font-semibold text-gray-800">{method.max_radius_km} km</p>
)}
</div>
<div>
<p className="text-xs text-gray-500">Taxa mínima (R$)</p>
{shippingEditing ? (
<input
type="text"
value={method.min_fee_reais}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = {
...method,
min_fee_reais: e.target.value
}
setShippingDraft(updated)
}}
className="mt-1 w-full rounded border border-gray-200 px-3 py-2 text-sm"
/>
) : (
<p className="font-semibold text-gray-800">
{formatCurrency(parseNumber(method.min_fee_reais))}
</p>
)}
</div>
<div>
<p className="text-xs text-gray-500">Preço por km (R$)</p>
{shippingEditing ? (
<input
type="text"
value={method.price_per_km_reais}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = {
...method,
price_per_km_reais: e.target.value
}
setShippingDraft(updated)
}}
className="mt-1 w-full rounded border border-gray-200 px-3 py-2 text-sm"
/>
) : (
<p className="font-semibold text-gray-800">
{formatCurrency(parseNumber(method.price_per_km_reais))}
</p>
)}
</div>
<div>
<p className="text-xs text-gray-500">Frete grátis acima de (R$)</p>
{shippingEditing ? (
<input
type="text"
value={method.free_shipping_threshold_reais}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = {
...method,
free_shipping_threshold_reais: e.target.value
}
setShippingDraft(updated)
}}
className="mt-1 w-full rounded border border-gray-200 px-3 py-2 text-sm"
placeholder="Deixe em branco se não aplica"
/>
) : (
<p className="font-semibold text-gray-800">
{method.free_shipping_threshold_reais
? formatCurrency(parseNumber(method.free_shipping_threshold_reais))
: 'Não aplica'}
</p>
)}
</div>
<div>
<p className="text-xs text-gray-500">Endereço de retirada</p>
{shippingEditing ? (
<input
type="text"
value={method.pickup_address}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = {
...method,
pickup_address: e.target.value
}
setShippingDraft(updated)
}}
className="mt-1 w-full rounded border border-gray-200 px-3 py-2 text-sm"
placeholder="Informe o endereço para retirada"
/>
) : (
<p className="font-semibold text-gray-800">
{method.pickup_address || 'Não informado'}
</p>
)}
</div>
<div className="md:col-span-2">
<p className="text-xs text-gray-500">Horário de retirada</p>
{shippingEditing ? (
<input
type="text"
value={method.pickup_hours}
onChange={(e) => {
const updated = [...shippingDraft]
updated[index] = {
...method,
pickup_hours: e.target.value
}
setShippingDraft(updated)
}}
className="mt-1 w-full rounded border border-gray-200 px-3 py-2 text-sm"
placeholder="Ex: Seg-Sex 08:00 - 18:00"
/>
) : (
<p className="font-semibold text-gray-800">
{method.pickup_hours || 'Não informado'}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</Shell>
)