Improve users and purchase needs UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
AtSign,
|
AtSign,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -8,10 +9,13 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Shield,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
|
UserCheck,
|
||||||
|
UserPlus,
|
||||||
|
UserX,
|
||||||
X
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
|
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
|
||||||
@@ -31,25 +35,30 @@ const statusOptions: Array<{ key: StatusFilter; label: string }> = [
|
|||||||
{ key: 'inactive', label: 'Inativos' }
|
{ key: 'inactive', label: 'Inativos' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDateTime = (value: string) => {
|
const roleLabels: Record<string, string> = {
|
||||||
const date = new Date(value);
|
admin: 'Admin',
|
||||||
if (!value || Number.isNaN(date.getTime())) return '-';
|
supervisor: 'Supervisor',
|
||||||
|
producao: 'Produção',
|
||||||
return new Intl.DateTimeFormat('pt-BR', {
|
operacao: 'Operação',
|
||||||
day: '2-digit',
|
vendedor: 'Vendedor',
|
||||||
month: 'short',
|
financeiro: 'Financeiro',
|
||||||
year: 'numeric',
|
user: 'Usuário'
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
}).format(date);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 getInitials = (name: string) => {
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
if (!parts.length) return '?';
|
if (!parts.length) return '?';
|
||||||
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
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 AdminUsers = () => {
|
||||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -87,6 +96,8 @@ const AdminUsers = () => {
|
|||||||
});
|
});
|
||||||
}, [searchTerm, statusFilter, users]);
|
}, [searchTerm, statusFilter, users]);
|
||||||
|
|
||||||
|
const hasFilters = searchTerm.trim() !== '' || statusFilter !== 'all';
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -119,6 +130,11 @@ const AdminUsers = () => {
|
|||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setStatusFilter('all');
|
||||||
|
};
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
resetForm();
|
resetForm();
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -251,129 +267,200 @@ const AdminUsers = () => {
|
|||||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
|
||||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
|
||||||
|
|
||||||
|
const statusCounts: Record<StatusFilter, number> = {
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div>
|
<div className="max-w-3xl">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-brand-primary">
|
<h1 className="text-3xl font-bold text-dark-text">Usuários</h1>
|
||||||
<Shield className="h-4 w-4" />
|
<p className="mt-2 text-sm leading-6 text-dark-muted">
|
||||||
Super admin
|
Gerencie acessos individuais, senhas iniciais e bloqueios de entrada no painel.
|
||||||
</div>
|
</p>
|
||||||
<h1 className="text-2xl font-bold text-dark-text">Usuários</h1>
|
|
||||||
<p className="mt-1 text-sm text-dark-muted">Controle quem acessa o painel.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadUsers()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Atualizar
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCreateModal}
|
onClick={openCreateModal}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<UserPlus className="h-4 w-4" />
|
||||||
Novo usuário
|
Novo acesso
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="rounded-lg border border-dark-border bg-dark-card">
|
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="flex flex-col gap-4 border-b border-dark-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
{stats.map((stat) => {
|
||||||
<div className="grid grid-cols-3 divide-x divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-input lg:w-[360px]">
|
const Icon = stat.icon;
|
||||||
<div className="px-4 py-3">
|
return (
|
||||||
<p className="text-xs font-semibold text-dark-muted">Total</p>
|
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||||
<p className="mt-1 text-xl font-bold text-dark-text">{users.length}</p>
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
||||||
|
<p className={`mt-2 text-2xl font-bold ${stat.tone}`}>{stat.value}</p>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{stat.detail}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3">
|
);
|
||||||
<p className="text-xs font-semibold text-dark-muted">Ativos</p>
|
})}
|
||||||
<p className="mt-1 text-xl font-bold text-emerald-300">{activeCount}</p>
|
</section>
|
||||||
</div>
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<p className="text-xs font-semibold text-dark-muted">Inativos</p>
|
|
||||||
<p className="mt-1 text-xl font-bold text-red-300">{inactiveCount}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||||
<div className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 md:w-80">
|
<div className="border-b border-dark-border p-4">
|
||||||
<Search className="h-4 w-4 shrink-0 text-dark-muted" />
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<input
|
<div>
|
||||||
type="search"
|
<h2 className="text-base font-bold text-dark-text">Acessos cadastrados</h2>
|
||||||
value={searchTerm}
|
<p className="mt-1 text-sm text-dark-muted">Busque, filtre e edite os usuários do painel.</p>
|
||||||
onChange={(event) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
{statusOptions.map((option) => (
|
<div className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 lg:w-80">
|
||||||
<button
|
<Search className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
key={option.key}
|
<input
|
||||||
type="button"
|
type="search"
|
||||||
onClick={() => setStatusFilter(option.key)}
|
value={searchTerm}
|
||||||
className={`h-8 cursor-pointer rounded-md px-3 text-sm font-semibold transition-colors ${
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
statusFilter === option.key
|
className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted"
|
||||||
? 'bg-dark-card text-dark-text shadow-sm'
|
placeholder="Buscar nome ou e-mail"
|
||||||
: 'text-dark-muted hover:text-dark-text'
|
/>
|
||||||
}`}
|
{searchTerm && (
|
||||||
>
|
<button
|
||||||
{option.label}
|
type="button"
|
||||||
</button>
|
onClick={() => setSearchTerm('')}
|
||||||
))}
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-dark-muted transition-colors hover:bg-dark-card hover:text-dark-text"
|
||||||
|
aria-label="Limpar busca"
|
||||||
|
title="Limpar busca"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(option.key)}
|
||||||
|
className={`h-8 cursor-pointer rounded-md px-3 text-sm font-semibold transition-colors ${
|
||||||
|
statusFilter === option.key
|
||||||
|
? 'bg-dark-card text-dark-text shadow-sm'
|
||||||
|
: 'text-dark-muted hover:text-dark-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
<span className="ml-1 text-xs opacity-70">{statusCounts[option.key]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-80 items-center justify-center text-brand-primary">
|
<div className="divide-y divide-dark-border" aria-label="Carregando usuários">
|
||||||
<Loader2 className="h-7 w-7 animate-spin" />
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div key={`user-skeleton-${index}`} className="grid grid-cols-[1.4fr_1fr_120px_140px_88px] gap-5 px-5 py-4">
|
||||||
|
{Array.from({ length: 5 }).map((__, column) => (
|
||||||
|
<div key={`user-skeleton-${index}-${column}`} className="skeleton h-4" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
<div className="flex min-h-[360px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-brand-primary">
|
||||||
<UserIcon className="h-6 w-6" />
|
<UserPlus className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-bold text-dark-text">Nenhum usuário cadastrado</p>
|
<p className="text-lg font-bold text-dark-text">Nenhum usuário cadastrado</p>
|
||||||
<p className="mt-1 max-w-sm text-sm text-dark-muted">Crie o primeiro acesso para liberar o painel a outra pessoa.</p>
|
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Crie o primeiro acesso individual para tirar o uso compartilhado do login administrativo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateModal}
|
||||||
|
className="mt-5 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"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Criar primeiro acesso
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : filteredUsers.length === 0 ? (
|
) : filteredUsers.length === 0 ? (
|
||||||
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-dark-muted">
|
||||||
<Search className="h-6 w-6" />
|
<Search className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-bold text-dark-text">Nenhum resultado</p>
|
<p className="text-lg font-bold text-dark-text">Nenhum resultado</p>
|
||||||
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca ou limpe os filtros.</p>
|
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Não encontramos usuários com os filtros atuais.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||||
|
>
|
||||||
|
Limpar filtros
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[760px] text-left">
|
<table className="w-full min-w-[900px] table-fixed text-left">
|
||||||
<thead className="border-b border-dark-border text-xs uppercase tracking-widest text-dark-muted">
|
<thead className="border-b border-dark-border bg-dark-input/35 text-xs uppercase tracking-widest text-dark-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-5 py-3 font-bold">Usuário</th>
|
<th className="w-[34%] px-5 py-3 font-bold">Usuário</th>
|
||||||
<th className="px-5 py-3 font-bold">E-mail</th>
|
<th className="w-[24%] px-5 py-3 font-bold">E-mail</th>
|
||||||
<th className="px-5 py-3 font-bold">Status</th>
|
<th className="w-[16%] px-5 py-3 font-bold">Perfil</th>
|
||||||
<th className="px-5 py-3 text-right font-bold">Criado em</th>
|
<th className="w-[14%] px-5 py-3 font-bold">Status</th>
|
||||||
<th className="px-5 py-3 text-right font-bold">Ações</th>
|
<th className="w-[12%] px-5 py-3 text-right font-bold">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-dark-border">
|
<tbody className="divide-y divide-dark-border">
|
||||||
{filteredUsers.map((user) => (
|
{filteredUsers.map((user) => (
|
||||||
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
|
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-primary/10 text-sm font-bold text-brand-primary">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary/10 text-sm font-bold text-brand-primary">
|
||||||
{getInitials(user.name)}
|
{getInitials(user.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold text-dark-text">{user.name}</p>
|
<p className="truncate font-bold text-dark-text">{user.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs font-semibold text-dark-muted">ID {user.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex min-w-0 items-center gap-2 text-dark-muted">
|
<div className="flex min-w-0 items-center gap-2 text-dark-muted">
|
||||||
<Mail className="h-4 w-4 shrink-0" />
|
<Mail className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">{user.email}</span>
|
<span className="truncate font-semibold">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className="inline-flex rounded-full border border-brand-primary/20 bg-brand-primary/10 px-2.5 py-1 text-xs font-bold text-brand-primary">
|
||||||
|
{getUserRoleLabel(user)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${
|
||||||
user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'
|
user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'
|
||||||
@@ -382,13 +469,12 @@ const AdminUsers = () => {
|
|||||||
{user.isActive ? 'Ativo' : 'Inativo'}
|
{user.isActive ? 'Ativo' : 'Inativo'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-right text-dark-muted">{formatDateTime(user.createdAt)}</td>
|
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEditModal(user)}
|
onClick={() => openEditModal(user)}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
|
||||||
aria-label={`Editar ${user.name}`}
|
aria-label={`Editar ${user.name}`}
|
||||||
title="Editar"
|
title="Editar"
|
||||||
>
|
>
|
||||||
@@ -400,7 +486,7 @@ const AdminUsers = () => {
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
setUserToDelete(user);
|
setUserToDelete(user);
|
||||||
}}
|
}}
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
|
||||||
aria-label={`Excluir ${user.name}`}
|
aria-label={`Excluir ${user.name}`}
|
||||||
title="Excluir"
|
title="Excluir"
|
||||||
>
|
>
|
||||||
@@ -417,14 +503,19 @@ const AdminUsers = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/70 px-4 py-6">
|
||||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
<div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
|
||||||
<div className="flex items-start justify-between border-b border-dark-border px-5 py-4">
|
<div className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
|
||||||
<div>
|
<div className="flex items-start gap-3">
|
||||||
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo usuário'}</h2>
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-brand-primary">
|
||||||
<p className="mt-1 text-sm text-dark-muted">
|
{editingUser ? <Edit3 className="h-5 w-5" /> : <UserPlus className="h-5 w-5" />}
|
||||||
{editingUser ? 'Atualize dados, status e senha de acesso.' : 'Crie um acesso individual para o painel.'}
|
</div>
|
||||||
</p>
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo acesso'}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-dark-muted">
|
||||||
|
{editingUser ? 'Atualize identificação, status e senha.' : 'Crie uma credencial individual para o painel.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -437,120 +528,157 @@ const AdminUsers = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser} className="space-y-4 p-5">
|
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
|
||||||
<label className="block">
|
<div className="space-y-5 p-5">
|
||||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span>
|
<section>
|
||||||
<div className="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">
|
<div className="mb-3">
|
||||||
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
|
<h3 className="text-sm font-bold text-dark-text">Identificação</h3>
|
||||||
<input
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Use um nome reconhecível e um e-mail individual.</p>
|
||||||
type="text"
|
</div>
|
||||||
value={name}
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
onChange={(event) => setName(event.target.value)}
|
<label className="block">
|
||||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span>
|
||||||
placeholder="Nome completo"
|
<div className={inputWrapClassName}>
|
||||||
required
|
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
autoFocus
|
<input
|
||||||
/>
|
type="text"
|
||||||
</div>
|
value={name}
|
||||||
</label>
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="Nome completo"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span>
|
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span>
|
||||||
<div className="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">
|
<div className={inputWrapClassName}>
|
||||||
<AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
|
<AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
className={inputClassName}
|
||||||
placeholder="usuario@empresa.com"
|
placeholder="usuario@empresa.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
<section>
|
||||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">{editingUser ? 'Senha' : 'Senha inicial'}</span>
|
<div className="mb-3">
|
||||||
<div className="grid grid-cols-2 rounded-lg border border-dark-border bg-dark-input p-1">
|
<h3 className="text-sm font-bold text-dark-text">{editingUser ? 'Senha' : 'Senha inicial'}</h3>
|
||||||
<button
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
type="button"
|
{editingUser ? 'Mantenha a senha atual ou defina uma nova.' : 'Gere uma senha temporária ou defina uma senha manual.'}
|
||||||
onClick={() => setPasswordMode('auto')}
|
</p>
|
||||||
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'auto' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
</div>
|
||||||
>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{editingUser ? 'Manter' : 'Gerar'}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={() => setPasswordMode('auto')}
|
||||||
type="button"
|
className={`cursor-pointer rounded-xl border p-4 text-left transition-colors ${
|
||||||
onClick={() => setPasswordMode('manual')}
|
passwordMode === 'auto'
|
||||||
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'manual' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
? 'border-brand-primary/50 bg-brand-primary/10 text-dark-text'
|
||||||
>
|
: 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'
|
||||||
{editingUser ? 'Alterar' : 'Definir'}
|
}`}
|
||||||
</button>
|
>
|
||||||
</div>
|
<span className="flex items-center gap-2 text-sm font-bold">
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
{editingUser ? 'Manter senha' : 'Gerar senha'}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs font-semibold opacity-80">
|
||||||
|
{editingUser ? 'Não altera a credencial atual.' : 'Mostra uma senha para copiar após criar.'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPasswordMode('manual')}
|
||||||
|
className={`cursor-pointer rounded-xl border p-4 text-left transition-colors ${
|
||||||
|
passwordMode === 'manual'
|
||||||
|
? 'border-brand-primary/50 bg-brand-primary/10 text-dark-text'
|
||||||
|
: 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-bold">
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
{editingUser ? 'Alterar senha' : 'Definir senha'}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs font-semibold opacity-80">
|
||||||
|
Use quando a senha já será combinada fora do sistema.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{passwordMode === 'manual' && (
|
{passwordMode === 'manual' && (
|
||||||
<div className="mt-3 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">
|
<div className="mt-3">
|
||||||
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" />
|
<div className={inputWrapClassName}>
|
||||||
|
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="Mínimo de 6 caracteres"
|
||||||
|
minLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{editingUser && (
|
||||||
|
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-bold text-dark-text">Usuário ativo</span>
|
||||||
|
<span className="mt-0.5 block text-xs font-semibold text-dark-muted">Usuários inativos não conseguem entrar.</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="checkbox"
|
||||||
value={password}
|
checked={isActive}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setIsActive(event.target.checked)}
|
||||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
className="h-5 w-5 cursor-pointer accent-brand-primary"
|
||||||
placeholder="Mínimo de 6 caracteres"
|
|
||||||
minLength={6}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{notice.tone === 'error' ? <X className="mt-0.5 h-4 w-4 shrink-0" /> : <Check className="mt-0.5 h-4 w-4 shrink-0" />}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p>{notice.text}</p>
|
||||||
|
{notice.temporaryPassword && (
|
||||||
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded-lg bg-black/25 px-3 py-2 text-amber-100">
|
||||||
|
{notice.temporaryPassword}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyTemporaryPassword}
|
||||||
|
className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg border border-amber-400/30 px-3 text-xs font-bold text-amber-100 transition-colors hover:bg-amber-400/10"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{copyLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingUser && (
|
<div className="flex flex-col-reverse gap-2 border-t border-dark-border bg-dark-input/35 px-5 py-4 sm:flex-row sm:justify-end">
|
||||||
<label className="flex cursor-pointer items-center justify-between rounded-lg border border-dark-border bg-dark-input px-3 py-3">
|
|
||||||
<div>
|
|
||||||
<span className="block text-sm font-semibold text-dark-text">Usuário ativo</span>
|
|
||||||
<span className="mt-0.5 block text-xs text-dark-muted">Usuários inativos não conseguem entrar.</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isActive}
|
|
||||||
onChange={(event) => setIsActive(event.target.checked)}
|
|
||||||
className="h-5 w-5 cursor-pointer accent-brand-primary"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notice && (
|
|
||||||
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
{notice.tone === 'error' ? <X className="mt-0.5 h-4 w-4 shrink-0" /> : <Check className="mt-0.5 h-4 w-4 shrink-0" />}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p>{notice.text}</p>
|
|
||||||
{notice.temporaryPassword && (
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<code className="min-w-0 flex-1 truncate rounded-md bg-black/25 px-3 py-2 text-amber-100">
|
|
||||||
{notice.temporaryPassword}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={copyTemporaryPassword}
|
|
||||||
className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-amber-400/30 px-3 text-xs font-bold text-amber-100 transition-colors hover:bg-amber-400/10"
|
|
||||||
>
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
{copyLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
disabled={isSubmitting}
|
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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
@@ -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"
|
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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||||
{editingUser ? 'Salvar alterações' : 'Criar usuário'}
|
{editingUser ? 'Salvar alterações' : 'Criar acesso'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -570,19 +698,24 @@ const AdminUsers = () => {
|
|||||||
|
|
||||||
{userToDelete && (
|
{userToDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
||||||
<div className="w-full max-w-md overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
|
||||||
<div className="border-b border-dark-border px-5 py-4">
|
<div className="flex items-start gap-3 border-b border-dark-border px-5 py-4">
|
||||||
<h2 className="text-lg font-bold text-dark-text">Excluir usuário</h2>
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-red-500/25 bg-red-500/10 text-red-300">
|
||||||
<p className="mt-1 text-sm text-dark-muted">Esta ação remove o acesso imediatamente.</p>
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-dark-text">Excluir usuário</h2>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-dark-muted">Esta ação remove o acesso imediatamente.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 p-5">
|
<div className="space-y-4 p-5">
|
||||||
<div className="rounded-lg border border-dark-border bg-dark-input p-4">
|
<div className="rounded-xl border border-dark-border bg-dark-input p-4">
|
||||||
<p className="font-semibold text-dark-text">{userToDelete.name}</p>
|
<p className="font-bold text-dark-text">{userToDelete.name}</p>
|
||||||
<p className="mt-1 text-sm text-dark-muted">{userToDelete.email}</p>
|
<p className="mt-1 text-sm font-semibold text-dark-muted">{userToDelete.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice?.tone === 'error' && (
|
{notice?.tone === 'error' && (
|
||||||
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
<div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
|
||||||
{notice.text}
|
{notice.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -596,7 +729,7 @@ const AdminUsers = () => {
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting}
|
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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { buildConsumptionReferencePath } from '../catalogLinks';
|
import { buildConsumptionReferencePath } from '../catalogLinks';
|
||||||
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
|
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
|
||||||
import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
||||||
|
|
||||||
@@ -1148,6 +1149,8 @@ const PurchaseNeedsScreen = () => {
|
|||||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
const nextSummary = await fetchSupplySummary();
|
const nextSummary = await fetchSupplySummary();
|
||||||
@@ -1178,6 +1181,10 @@ const PurchaseNeedsScreen = () => {
|
|||||||
const visibleNeeds = summary.purchaseNeeds.filter(need => (
|
const visibleNeeds = summary.purchaseNeeds.filter(need => (
|
||||||
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch)
|
!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 purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length;
|
||||||
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
|
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
|
||||||
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
||||||
@@ -1230,7 +1237,15 @@ const PurchaseNeedsScreen = () => {
|
|||||||
</div>
|
</div>
|
||||||
<label className="relative mt-4 block">
|
<label className="relative mt-4 block">
|
||||||
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
||||||
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar por material, fornecedor ou cor..." />
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className={`${inputClassName} pl-9`}
|
||||||
|
placeholder="Buscar por material, fornecedor ou cor..."
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className={emptyStateClassName}>
|
<div className={emptyStateClassName}>
|
||||||
@@ -1239,20 +1254,33 @@ const PurchaseNeedsScreen = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : visibleNeeds.length ? (
|
) : visibleNeeds.length ? (
|
||||||
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
||||||
<div className="hidden grid-cols-[1.3fr_110px_110px_110px_110px_110px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted lg:grid">
|
<div className="overflow-x-auto">
|
||||||
<span>Material</span>
|
<table className="w-full min-w-[920px] table-fixed border-collapse">
|
||||||
<span>Planejado</span>
|
<colgroup>
|
||||||
<span>Estoque</span>
|
<col />
|
||||||
<span>Pendente</span>
|
<col className="w-[120px]" />
|
||||||
<span>Comprar</span>
|
<col className="w-[120px]" />
|
||||||
<span>Cobertura</span>
|
<col className="w-[120px]" />
|
||||||
</div>
|
<col className="w-[120px]" />
|
||||||
<div className="divide-y divide-dark-border">
|
<col className="w-[150px]" />
|
||||||
{visibleNeeds.map(need => {
|
</colgroup>
|
||||||
|
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left">Material</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Planejado</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Estoque</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Pendente</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Comprar</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left">Cobertura</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-border bg-dark-card">
|
||||||
|
{paginatedNeeds.map(need => {
|
||||||
const referenceProduct = need.products?.[0];
|
const referenceProduct = need.products?.[0];
|
||||||
return (
|
return (
|
||||||
<div key={need.material} className="grid grid-cols-1 gap-3 bg-dark-card px-4 py-4 lg:grid-cols-[1.3fr_110px_110px_110px_110px_110px] lg:items-center">
|
<tr key={need.material}>
|
||||||
<div>
|
<td className="px-4 py-4 align-middle">
|
||||||
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-bold text-dark-text">{need.material}</p>
|
<p className="text-sm font-bold text-dark-text">{need.material}</p>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<p className="text-xs font-semibold text-dark-muted">
|
<p className="text-xs font-semibold text-dark-muted">
|
||||||
@@ -1277,12 +1305,14 @@ const PurchaseNeedsScreen = () => {
|
|||||||
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
|
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</p>
|
</td>
|
||||||
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</p>
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td>
|
||||||
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</p>
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</td>
|
||||||
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</p>
|
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</td>
|
||||||
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
|
<td className={`px-4 py-4 text-right align-middle text-sm font-bold ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td>
|
||||||
|
<td className="px-4 py-4 align-middle">
|
||||||
|
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${
|
||||||
need.missingReference
|
need.missingReference
|
||||||
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||||
: need.status === 'critical'
|
: need.status === 'critical'
|
||||||
@@ -1293,10 +1323,30 @@ const PurchaseNeedsScreen = () => {
|
|||||||
}`}>
|
}`}>
|
||||||
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
|
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<PaginationControls
|
||||||
|
totalItems={visibleNeeds.length}
|
||||||
|
currentPage={safeCurrentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={itemsPerPage}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
itemLabel="materiais"
|
||||||
|
pageSizeLabel="itens por página"
|
||||||
|
startIndex={startIndex}
|
||||||
|
endIndex={Math.min(startIndex + itemsPerPage, visibleNeeds.length)}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onPageSizeChange={(pageSize) => {
|
||||||
|
setItemsPerPage(pageSize);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="border-t border-dark-border px-4 py-3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={emptyStateClassName}>
|
<div className={emptyStateClassName}>
|
||||||
@@ -1306,10 +1356,6 @@ const PurchaseNeedsScreen = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`${panelClassName} p-5`}>
|
|
||||||
<h2 className="text-base font-bold text-dark-text">Como o fluxo está conectado</h2>
|
|
||||||
<p className="mt-2 text-sm font-semibold text-dark-muted">Plano de malha cria demanda. Recebimento pendente entra como material a receber. Aprovar recebimento move para estoque e movimentações.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user