From 4bfd0d4591114608b85b57f8e900d4dd29dfb16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 16 Jul 2026 12:56:11 -0300 Subject: [PATCH] Improve users and purchase needs UI --- src/pages/AdminUsers.tsx | 549 ++++++++++++++++++++++++--------------- src/pages/Supplies.tsx | 94 +++++-- 2 files changed, 411 insertions(+), 232 deletions(-) diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index d77fb71..428ab61 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { + AlertTriangle, AtSign, Check, Copy, @@ -8,10 +9,13 @@ import { Loader2, Mail, Plus, + RefreshCw, Search, - Shield, Trash2, User as UserIcon, + UserCheck, + UserPlus, + UserX, X } from 'lucide-react'; import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService'; @@ -31,25 +35,30 @@ const statusOptions: Array<{ key: StatusFilter; label: string }> = [ { key: 'inactive', label: 'Inativos' } ]; -const formatDateTime = (value: string) => { - const date = new Date(value); - if (!value || Number.isNaN(date.getTime())) return '-'; - - return new Intl.DateTimeFormat('pt-BR', { - day: '2-digit', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }).format(date); +const roleLabels: Record = { + admin: 'Admin', + supervisor: 'Supervisor', + producao: 'Produção', + operacao: 'Operação', + vendedor: 'Vendedor', + financeiro: 'Financeiro', + user: 'Usuário' }; +const inputWrapClassName = 'flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary'; +const inputClassName = 'min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted'; + const getInitials = (name: string) => { const parts = name.trim().split(/\s+/).filter(Boolean); if (!parts.length) return '?'; return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase(); }; +const getUserRoleLabel = (user: ManagedUser) => { + const role = String((user as ManagedUser & { role?: string }).role || 'user').toLowerCase(); + return roleLabels[role] || role; +}; + const AdminUsers = () => { const [users, setUsers] = useState([]); const [searchTerm, setSearchTerm] = useState(''); @@ -87,6 +96,8 @@ const AdminUsers = () => { }); }, [searchTerm, statusFilter, users]); + const hasFilters = searchTerm.trim() !== '' || statusFilter !== 'all'; + const loadUsers = async () => { setIsLoading(true); try { @@ -119,6 +130,11 @@ const AdminUsers = () => { resetForm(); }; + const clearFilters = () => { + setSearchTerm(''); + setStatusFilter('all'); + }; + const openCreateModal = () => { resetForm(); setIsModalOpen(true); @@ -251,129 +267,200 @@ const AdminUsers = () => { ? 'border-amber-500/30 bg-amber-500/10 text-amber-200' : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'; + const statusCounts: Record = { + all: users.length, + active: activeCount, + inactive: inactiveCount + }; + + const stats = [ + { label: 'Total', value: users.length, icon: UserIcon, tone: 'text-dark-text', detail: 'acessos cadastrados' }, + { label: 'Ativos', value: activeCount, icon: UserCheck, tone: 'text-emerald-300', detail: 'podem entrar agora' }, + { label: 'Inativos', value: inactiveCount, icon: UserX, tone: 'text-red-300', detail: 'bloqueados no login' }, + { label: 'Exibindo', value: filteredUsers.length, icon: Search, tone: 'text-brand-primary', detail: hasFilters ? 'após filtros' : 'sem filtros' } + ]; + return (
-
-
-
- - Super admin -
-

Usuários

-

Controle quem acessa o painel.

+
+
+

Usuários

+

+ Gerencie acessos individuais, senhas iniciais e bloqueios de entrada no painel. +

-
+
+
-
-
-
-
-

Total

-

{users.length}

+
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+
+

{stat.label}

+

{stat.value}

+

{stat.detail}

+
+
+ +
+
-
-

Ativos

-

{activeCount}

-
-
-

Inativos

-

{inactiveCount}

-
-
+ ); + })} +
-
-
- - setSearchTerm(event.target.value)} - className="min-w-0 flex-1 bg-transparent text-sm text-dark-text outline-none placeholder:text-dark-muted" - placeholder="Buscar nome ou e-mail" - /> +
+
+
+
+

Acessos cadastrados

+

Busque, filtre e edite os usuários do painel.

-
- {statusOptions.map((option) => ( - - ))} +
+
+ + setSearchTerm(event.target.value)} + className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted" + placeholder="Buscar nome ou e-mail" + /> + {searchTerm && ( + + )} +
+ +
+ {statusOptions.map((option) => ( + + ))} +
{isLoading ? ( -
- +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ {Array.from({ length: 5 }).map((__, column) => ( +
+ ))} +
+ ))}
) : users.length === 0 ? ( -
-
- +
+
+
-

Nenhum usuário cadastrado

-

Crie o primeiro acesso para liberar o painel a outra pessoa.

+

Nenhum usuário cadastrado

+

Crie o primeiro acesso individual para tirar o uso compartilhado do login administrativo.

+
) : filteredUsers.length === 0 ? ( -
-
- +
+
+
-

Nenhum resultado

-

Ajuste a busca ou limpe os filtros.

+

Nenhum resultado

+

Não encontramos usuários com os filtros atuais.

+
) : (
- - +
+ - - - - - + + + + + {filteredUsers.map((user) => ( + -
UsuárioE-mailStatusCriado emAçõesUsuárioE-mailPerfilStatusAções
-
-
+
+
{getInitials(user.name)}
-

{user.name}

+

{user.name}

+

ID {user.id}

- {user.email} + {user.email}
+ + {getUserRoleLabel(user)} + + { {user.isActive ? 'Ativo' : 'Inativo'} {formatDateTime(user.createdAt)}
-
- + +
+
+
+

Identificação

+

Use um nome reconhecível e um e-mail individual.

+
+
+ - + +
+
-
- {editingUser ? 'Senha' : 'Senha inicial'} -
- - -
+
+
+

{editingUser ? 'Senha' : 'Senha inicial'}

+

+ {editingUser ? 'Mantenha a senha atual ou defina uma nova.' : 'Gere uma senha temporária ou defina uma senha manual.'} +

+
+
+ + +
- {passwordMode === 'manual' && ( -
- + {passwordMode === 'manual' && ( +
+
+ + setPassword(event.target.value)} + className={inputClassName} + placeholder="Mínimo de 6 caracteres" + minLength={6} + required + /> +
+
+ )} +
+ + {editingUser && ( + + )} + + {notice && ( +
+
+ {notice.tone === 'error' ? : } +
+

{notice.text}

+ {notice.temporaryPassword && ( +
+ + {notice.temporaryPassword} + + +
+ )} +
+
)}
- {editingUser && ( - - )} - - {notice && ( -
-
- {notice.tone === 'error' ? : } -
-

{notice.text}

- {notice.temporaryPassword && ( -
- - {notice.temporaryPassword} - - -
- )} -
-
-
- )} - -
+
@@ -560,7 +688,7 @@ const AdminUsers = () => { className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50" > {isSubmitting ? : } - {editingUser ? 'Salvar alterações' : 'Criar usuário'} + {editingUser ? 'Salvar alterações' : 'Criar acesso'}
@@ -570,19 +698,24 @@ const AdminUsers = () => { {userToDelete && (
-
-
-

Excluir usuário

-

Esta ação remove o acesso imediatamente.

+
+
+
+ +
+
+

Excluir usuário

+

Esta ação remove o acesso imediatamente.

+
-
-

{userToDelete.name}

-

{userToDelete.email}

+
+

{userToDelete.name}

+

{userToDelete.email}

{notice?.tone === 'error' && ( -
+
{notice.text}
)} @@ -596,7 +729,7 @@ const AdminUsers = () => { setNotice(null); }} disabled={isSubmitting} - className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50" + className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50" > Cancelar diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index 601cc49..d898ca8 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -23,6 +23,7 @@ import { Warehouse, } from 'lucide-react'; import { buildConsumptionReferencePath } from '../catalogLinks'; +import PaginationControls from '../components/PaginationControls'; import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService'; import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; @@ -1148,6 +1149,8 @@ const PurchaseNeedsScreen = () => { const [summary, setSummary] = useState(emptySupplySummary); const [isLoading, setIsLoading] = useState(true); const [search, setSearch] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); const loadSummary = async () => { const nextSummary = await fetchSupplySummary(); @@ -1178,6 +1181,10 @@ const PurchaseNeedsScreen = () => { const visibleNeeds = summary.purchaseNeeds.filter(need => ( !normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch) )); + const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage); + const safeCurrentPage = Math.min(currentPage, totalPages || 1); + const startIndex = (safeCurrentPage - 1) * itemsPerPage; + const paginatedNeeds = visibleNeeds.slice(startIndex, startIndex + itemsPerPage); const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length; const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds); const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size; @@ -1230,7 +1237,15 @@ const PurchaseNeedsScreen = () => {
{isLoading ? (
@@ -1239,20 +1254,33 @@ const PurchaseNeedsScreen = () => {
) : visibleNeeds.length ? (
-
- Material - Planejado - Estoque - Pendente - Comprar - Cobertura -
-
- {visibleNeeds.map(need => { +
+ + + + + + + + + + + + + + + + + + + + + {paginatedNeeds.map(need => { const referenceProduct = need.products?.[0]; return ( -
-
+
+ + + + + + + ); })} + +
MaterialPlanejadoEstoquePendenteComprarCobertura
+

{need.material}

@@ -1277,12 +1305,14 @@ const PurchaseNeedsScreen = () => { {need.products.length > 2 ? ` +${need.products.length - 2}` : ''}

) : null} -
-

{formatNeedQuantity(need, need.plannedKg)}

-

{formatNeedQuantity(need, need.stockKg)}

-

{formatNeedQuantity(need, need.pendingKg)}

-

0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}

- +
{formatNeedQuantity(need, need.plannedKg)}{formatNeedQuantity(need, need.stockKg)}{formatNeedQuantity(need, need.pendingKg)} 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)} + { }`}> {need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]} - +
+ { + setItemsPerPage(pageSize); + setCurrentPage(1); + }} + className="border-t border-dark-border px-4 py-3" + />
) : (
@@ -1306,10 +1356,6 @@ const PurchaseNeedsScreen = () => {
)}
-
-

Como o fluxo está conectado

-

Plano de malha cria demanda. Recebimento pendente entra como material a receber. Aprovar recebimento move para estoque e movimentações.

-
); };