Compare commits
3 Commits
5ed91fa36f
...
4556659fcf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4556659fcf | ||
|
|
b059ea3128 | ||
|
|
14fe69bb9d |
@@ -11,11 +11,13 @@ import {
|
||||
saveCatalogProduct,
|
||||
saveConsumptionReference
|
||||
} from '../dataService';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference } from '../types';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
|
||||
type RegistrationTab = 'products' | 'categories' | 'references';
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type RegistrationPagination = Record<RegistrationTab, { page: number; pageSize: number }>;
|
||||
|
||||
const emptyCatalog: CatalogSummary = {
|
||||
categories: [],
|
||||
@@ -111,6 +113,11 @@ const Registrations = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<RegistrationTab>('products');
|
||||
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 [feedback, setFeedback] = useState('');
|
||||
const appliedSkuPrefillRef = useRef('');
|
||||
@@ -247,6 +254,28 @@ const Registrations = () => {
|
||||
return catalog.products.filter(product => product.type === 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) => {
|
||||
setStatus('saving');
|
||||
setFeedback('');
|
||||
@@ -473,7 +502,10 @@ const Registrations = () => {
|
||||
</div>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
@@ -482,7 +514,7 @@ const Registrations = () => {
|
||||
</select>
|
||||
</div>
|
||||
<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 className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -512,6 +544,19 @@ const Registrations = () => {
|
||||
<div className={emptyStateClassName}>Nenhum produto cadastrado.</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 className={formPanelClassName}>
|
||||
@@ -662,7 +707,7 @@ const Registrations = () => {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>}
|
||||
</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 className={formPanelClassName}>
|
||||
@@ -710,7 +768,7 @@ const Registrations = () => {
|
||||
</span>
|
||||
</div>
|
||||
<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 className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -768,6 +826,19 @@ const Registrations = () => {
|
||||
</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 className={formPanelClassName}>
|
||||
|
||||
@@ -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">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Produtos afetados</span>
|
||||
{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
|
||||
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"
|
||||
title={product.name}
|
||||
>
|
||||
#{product.productId} · {product.name}
|
||||
</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>
|
||||
<RouterLink
|
||||
key={product.productId}
|
||||
to={`/products/${product.productId}`}
|
||||
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}
|
||||
>
|
||||
#{product.productId} · {product.name}
|
||||
</RouterLink>
|
||||
))}
|
||||
{products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>}
|
||||
</div>
|
||||
@@ -2136,10 +2127,11 @@ const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products
|
||||
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="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(300px,1.35fr)_minmax(380px,1fr)_150px]">
|
||||
<span>Material e impacto</span>
|
||||
<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</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 className="divide-y divide-dark-border">
|
||||
@@ -2166,7 +2158,7 @@ const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchas
|
||||
|
||||
return (
|
||||
<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="flex flex-wrap items-center gap-2">
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 xl:justify-self-end">
|
||||
<button
|
||||
type="button"
|
||||
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)}`}
|
||||
aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
|
||||
@@ -2225,10 +2227,11 @@ const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchas
|
||||
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="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(320px,1.4fr)_minmax(260px,1fr)_180px]">
|
||||
<span>Serviço e impacto</span>
|
||||
<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</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 className="divide-y divide-dark-border">
|
||||
@@ -2238,7 +2241,7 @@ const PurchaseServiceQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchase
|
||||
|
||||
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`}>
|
||||
<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="flex flex-wrap items-center gap-2">
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 xl:justify-self-end">
|
||||
<button
|
||||
type="button"
|
||||
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)}`}
|
||||
aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
|
||||
@@ -2293,6 +2306,12 @@ const getMaterialRegistrationSku = (material: string) => {
|
||||
return /^[A-Z0-9_-]{2,}$/i.test(candidate) ? candidate : '';
|
||||
};
|
||||
|
||||
const getMaterialRegistrationPath = (material: string) => buildSkuEditPath({
|
||||
sku: getMaterialRegistrationSku(material),
|
||||
name: material,
|
||||
type: 'raw_material',
|
||||
});
|
||||
|
||||
const MaterialDetailsModal = ({
|
||||
need,
|
||||
onClose,
|
||||
@@ -2309,11 +2328,7 @@ const MaterialDetailsModal = ({
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const registrationPath = buildSkuEditPath({
|
||||
sku: getMaterialRegistrationSku(need.material),
|
||||
name: need.material,
|
||||
type: 'raw_material',
|
||||
});
|
||||
const registrationPath = getMaterialRegistrationPath(need.material);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user