Compare commits

..

3 Commits

Author SHA1 Message Date
Cauê Faleiros
4556659fcf Use ten rows by default in registrations
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m38s
2026-08-10 10:41:34 -03:00
Cauê Faleiros
b059ea3128 Paginate registration lists 2026-08-10 10:36:41 -03:00
Cauê Faleiros
14fe69bb9d Align supply material actions 2026-08-10 10:26:53 -03:00
2 changed files with 122 additions and 36 deletions

View File

@@ -11,11 +11,13 @@ import {
saveCatalogProduct, saveCatalogProduct,
saveConsumptionReference saveConsumptionReference
} from '../dataService'; } from '../dataService';
import PaginationControls from '../components/PaginationControls';
import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference } from '../types'; import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference } from '../types';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
type RegistrationTab = 'products' | 'categories' | 'references'; type RegistrationTab = 'products' | 'categories' | 'references';
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
type RegistrationPagination = Record<RegistrationTab, { page: number; pageSize: number }>;
const emptyCatalog: CatalogSummary = { const emptyCatalog: CatalogSummary = {
categories: [], categories: [],
@@ -111,6 +113,11 @@ const Registrations = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<RegistrationTab>('products'); const [activeTab, setActiveTab] = useState<RegistrationTab>('products');
const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all'); const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all');
const [pagination, setPagination] = useState<RegistrationPagination>({
products: { page: 1, pageSize: 10 },
categories: { page: 1, pageSize: 10 },
references: { page: 1, pageSize: 10 },
});
const [status, setStatus] = useState<SaveStatus>('idle'); const [status, setStatus] = useState<SaveStatus>('idle');
const [feedback, setFeedback] = useState(''); const [feedback, setFeedback] = useState('');
const appliedSkuPrefillRef = useRef(''); const appliedSkuPrefillRef = useRef('');
@@ -247,6 +254,28 @@ const Registrations = () => {
return catalog.products.filter(product => product.type === productFilter); return catalog.products.filter(product => product.type === productFilter);
}, [catalog.products, productFilter]); }, [catalog.products, productFilter]);
const updatePagination = (tab: RegistrationTab, patch: Partial<RegistrationPagination[RegistrationTab]>) => {
setPagination(current => ({
...current,
[tab]: { ...current[tab], ...patch }
}));
};
const productTotalPages = Math.ceil(filteredProducts.length / pagination.products.pageSize);
const safeProductPage = Math.min(pagination.products.page, productTotalPages || 1);
const productStartIndex = (safeProductPage - 1) * pagination.products.pageSize;
const paginatedProducts = filteredProducts.slice(productStartIndex, productStartIndex + pagination.products.pageSize);
const categoryTotalPages = Math.ceil(catalog.categories.length / pagination.categories.pageSize);
const safeCategoryPage = Math.min(pagination.categories.page, categoryTotalPages || 1);
const categoryStartIndex = (safeCategoryPage - 1) * pagination.categories.pageSize;
const paginatedCategories = catalog.categories.slice(categoryStartIndex, categoryStartIndex + pagination.categories.pageSize);
const referenceTotalPages = Math.ceil(catalog.consumptionReferences.length / pagination.references.pageSize);
const safeReferencePage = Math.min(pagination.references.page, referenceTotalPages || 1);
const referenceStartIndex = (safeReferencePage - 1) * pagination.references.pageSize;
const paginatedReferences = catalog.consumptionReferences.slice(referenceStartIndex, referenceStartIndex + pagination.references.pageSize);
const runAction = async (action: () => Promise<void>, successMessage: string) => { const runAction = async (action: () => Promise<void>, successMessage: string) => {
setStatus('saving'); setStatus('saving');
setFeedback(''); setFeedback('');
@@ -473,7 +502,10 @@ const Registrations = () => {
</div> </div>
<select <select
value={productFilter} value={productFilter}
onChange={(event) => setProductFilter(event.target.value as 'all' | CatalogProductType)} onChange={(event) => {
setProductFilter(event.target.value as 'all' | CatalogProductType);
updatePagination('products', { page: 1 });
}}
className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer" className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
> >
<option value="all">Todos</option> <option value="all">Todos</option>
@@ -482,7 +514,7 @@ const Registrations = () => {
</select> </select>
</div> </div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
{filteredProducts.map(product => ( {paginatedProducts.map(product => (
<div key={product.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"> <div key={product.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -512,6 +544,19 @@ const Registrations = () => {
<div className={emptyStateClassName}>Nenhum produto cadastrado.</div> <div className={emptyStateClassName}>Nenhum produto cadastrado.</div>
)} )}
</div> </div>
<PaginationControls
totalItems={filteredProducts.length}
currentPage={safeProductPage}
totalPages={productTotalPages}
pageSize={pagination.products.pageSize}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="produtos"
pageSizeLabel="por página"
startIndex={productStartIndex}
endIndex={Math.min(productStartIndex + pagination.products.pageSize, filteredProducts.length)}
onPageChange={(page) => updatePagination('products', { page })}
onPageSizeChange={(pageSize) => updatePagination('products', { page: 1, pageSize })}
/>
</div> </div>
<div className={formPanelClassName}> <div className={formPanelClassName}>
@@ -662,7 +707,7 @@ const Registrations = () => {
</div> </div>
</div> </div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
{catalog.categories.map(category => ( {paginatedCategories.map(category => (
<div key={category.id} className="flex items-center justify-between gap-3 p-4"> <div key={category.id} className="flex items-center justify-between gap-3 p-4">
<div> <div>
<h3 className="text-sm font-bold text-dark-text">{category.name}</h3> <h3 className="text-sm font-bold text-dark-text">{category.name}</h3>
@@ -675,6 +720,19 @@ const Registrations = () => {
))} ))}
{!catalog.categories.length && <div className={emptyStateClassName}>Nenhuma categoria cadastrada.</div>} {!catalog.categories.length && <div className={emptyStateClassName}>Nenhuma categoria cadastrada.</div>}
</div> </div>
<PaginationControls
totalItems={catalog.categories.length}
currentPage={safeCategoryPage}
totalPages={categoryTotalPages}
pageSize={pagination.categories.pageSize}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="categorias"
pageSizeLabel="por página"
startIndex={categoryStartIndex}
endIndex={Math.min(categoryStartIndex + pagination.categories.pageSize, catalog.categories.length)}
onPageChange={(page) => updatePagination('categories', { page })}
onPageSizeChange={(pageSize) => updatePagination('categories', { page: 1, pageSize })}
/>
</div> </div>
<div className={formPanelClassName}> <div className={formPanelClassName}>
@@ -710,7 +768,7 @@ const Registrations = () => {
</span> </span>
</div> </div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
{catalog.consumptionReferences.map(reference => ( {paginatedReferences.map(reference => (
<div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between"> <div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -768,6 +826,19 @@ const Registrations = () => {
</div> </div>
)} )}
</div> </div>
<PaginationControls
totalItems={catalog.consumptionReferences.length}
currentPage={safeReferencePage}
totalPages={referenceTotalPages}
pageSize={pagination.references.pageSize}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="referências"
pageSizeLabel="por página"
startIndex={referenceStartIndex}
endIndex={Math.min(referenceStartIndex + pagination.references.pageSize, catalog.consumptionReferences.length)}
onPageChange={(page) => updatePagination('references', { page })}
onPageSizeChange={(pageSize) => updatePagination('references', { page: 1, pageSize })}
/>
</div> </div>
<div className={formPanelClassName}> <div className={formPanelClassName}>

View File

@@ -2110,23 +2110,14 @@ const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-dark-border/70 pt-3"> <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-dark-border/70 pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Produtos afetados</span> <span className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Produtos afetados</span>
{products.slice(0, 3).map(product => ( {products.slice(0, 3).map(product => (
<div key={product.productId} className="flex max-w-full items-center overflow-hidden rounded-md border border-dark-border bg-dark-input"> <RouterLink
<RouterLink key={product.productId}
to={`/products/${product.productId}`} to={`/products/${product.productId}`}
className="min-w-0 cursor-pointer truncate px-2 py-1 text-[11px] font-bold text-dark-muted transition-colors hover:text-brand-primary" className="max-w-full cursor-pointer truncate rounded-md border border-dark-border bg-dark-input px-2 py-1 text-[11px] font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary"
title={product.name} title={product.name}
> >
#{product.productId} · {product.name} #{product.productId} · {product.name}
</RouterLink> </RouterLink>
<RouterLink
to={`/products/${product.productId}`}
className="flex h-7 w-7 shrink-0 items-center justify-center border-l border-dark-border text-brand-primary transition-colors hover:bg-brand-primary/10"
title={`Ver produto ${product.name}`}
aria-label={`Ver produto ${product.name}`}
>
<Eye className="h-3.5 w-3.5" />
</RouterLink>
</div>
))} ))}
{products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>} {products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>}
</div> </div>
@@ -2136,10 +2127,11 @@ const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products
const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => ( const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => (
<> <>
<div className="border-b border-dark-border bg-dark-input/40 px-4 py-3"> <div className="border-b border-dark-border bg-dark-input/40 px-4 py-3">
<div className="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(300px,1.35fr)_minmax(380px,1fr)_150px]"> <div className="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(280px,1.35fr)_minmax(380px,1fr)_150px_88px]">
<span>Material e impacto</span> <span>Material</span>
<span className="hidden xl:block">Como a compra foi calculada</span> <span className="hidden xl:block">Como a compra foi calculada</span>
<span className="hidden text-right xl:block">Decisão</span> <span className="hidden text-right xl:block">Impacto</span>
<span className="hidden text-right xl:block">Ações</span>
</div> </div>
</div> </div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
@@ -2166,7 +2158,7 @@ const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchas
return ( return (
<article key={need.material} className={`border-l-2 ${accent} px-4 py-4 transition-colors hover:bg-dark-input/35`}> <article key={need.material} className={`border-l-2 ${accent} px-4 py-4 transition-colors hover:bg-dark-input/35`}>
<div className="grid gap-4 xl:grid-cols-[minmax(300px,1.35fr)_minmax(380px,1fr)_150px] xl:items-center"> <div className="grid gap-4 xl:grid-cols-[minmax(280px,1.35fr)_minmax(380px,1fr)_150px_88px] xl:items-center">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</h3> <h3 className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</h3>
@@ -2202,15 +2194,25 @@ const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchas
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Impacto</p> <p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Impacto</p>
<p className="mt-1 text-sm font-bold text-dark-text">{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem</p> <p className="mt-1 text-sm font-bold text-dark-text">{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas</p> <p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas</p>
</div>
<div className="flex items-center gap-2 xl:justify-self-end">
<button <button
type="button" type="button"
onClick={() => onEditMaterial(need)} onClick={() => onEditMaterial(need)}
className="mt-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border bg-dark-input text-sky-300 transition-colors hover:border-sky-400/50 hover:bg-sky-400/10" className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Editar dados de ${getNeedDisplayName(need)}`} title={`Editar dados de ${getNeedDisplayName(need)}`}
aria-label={`Editar dados de ${getNeedDisplayName(need)}`} aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
<RouterLink
to={getMaterialRegistrationPath(need.material)}
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver cadastro de ${getNeedDisplayName(need)}`}
aria-label={`Ver cadastro de ${getNeedDisplayName(need)}`}
>
<Eye className="h-3.5 w-3.5" />
</RouterLink>
</div> </div>
</div> </div>
@@ -2225,10 +2227,11 @@ const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchas
const PurchaseServiceQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => ( const PurchaseServiceQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => (
<> <>
<div className="border-b border-dark-border bg-dark-input/40 px-4 py-3"> <div className="border-b border-dark-border bg-dark-input/40 px-4 py-3">
<div className="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(320px,1.4fr)_minmax(260px,1fr)_180px]"> <div className="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(300px,1.4fr)_minmax(260px,1fr)_150px_88px]">
<span>Serviço e impacto</span> <span>Serviço</span>
<span className="hidden xl:block">Volume a programar</span> <span className="hidden xl:block">Volume a programar</span>
<span className="hidden text-right xl:block">Próximo passo</span> <span className="hidden text-right xl:block">Impacto</span>
<span className="hidden text-right xl:block">Ações</span>
</div> </div>
</div> </div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
@@ -2238,7 +2241,7 @@ const PurchaseServiceQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchase
return ( return (
<article key={need.material} className={`border-l-2 ${isCovered ? 'border-l-emerald-400' : 'border-l-sky-400'} px-4 py-4 transition-colors hover:bg-dark-input/35`}> <article key={need.material} className={`border-l-2 ${isCovered ? 'border-l-emerald-400' : 'border-l-sky-400'} px-4 py-4 transition-colors hover:bg-dark-input/35`}>
<div className="grid gap-4 xl:grid-cols-[minmax(320px,1.4fr)_minmax(260px,1fr)_180px] xl:items-center"> <div className="grid gap-4 xl:grid-cols-[minmax(300px,1.4fr)_minmax(260px,1fr)_150px_88px] xl:items-center">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</h3> <h3 className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</h3>
@@ -2268,15 +2271,25 @@ const PurchaseServiceQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchase
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Impacto</p> <p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Impacto</p>
<p className="mt-1 text-sm font-bold text-dark-text">{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem</p> <p className="mt-1 text-sm font-bold text-dark-text">{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas</p> <p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas</p>
</div>
<div className="flex items-center gap-2 xl:justify-self-end">
<button <button
type="button" type="button"
onClick={() => onEditMaterial(need)} onClick={() => onEditMaterial(need)}
className="mt-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border bg-dark-input text-sky-300 transition-colors hover:border-sky-400/50 hover:bg-sky-400/10" className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Editar dados de ${getNeedDisplayName(need)}`} title={`Editar dados de ${getNeedDisplayName(need)}`}
aria-label={`Editar dados de ${getNeedDisplayName(need)}`} aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
<RouterLink
to={getMaterialRegistrationPath(need.material)}
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver cadastro de ${getNeedDisplayName(need)}`}
aria-label={`Ver cadastro de ${getNeedDisplayName(need)}`}
>
<Eye className="h-3.5 w-3.5" />
</RouterLink>
</div> </div>
</div> </div>
@@ -2293,6 +2306,12 @@ const getMaterialRegistrationSku = (material: string) => {
return /^[A-Z0-9_-]{2,}$/i.test(candidate) ? candidate : ''; return /^[A-Z0-9_-]{2,}$/i.test(candidate) ? candidate : '';
}; };
const getMaterialRegistrationPath = (material: string) => buildSkuEditPath({
sku: getMaterialRegistrationSku(material),
name: material,
type: 'raw_material',
});
const MaterialDetailsModal = ({ const MaterialDetailsModal = ({
need, need,
onClose, onClose,
@@ -2309,11 +2328,7 @@ const MaterialDetailsModal = ({
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const registrationPath = buildSkuEditPath({ const registrationPath = getMaterialRegistrationPath(need.material);
sku: getMaterialRegistrationSku(need.material),
name: need.material,
type: 'raw_material',
});
const submit = async (event: FormEvent<HTMLFormElement>) => { const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();