Add editable supply material details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s

This commit is contained in:
Cauê Faleiros
2026-08-10 10:15:01 -03:00
parent 40737faaa5
commit 5ed91fa36f
8 changed files with 261 additions and 26 deletions

View File

@@ -23,13 +23,14 @@ import {
Truck,
Trash2,
Warehouse,
X,
} from 'lucide-react';
import { buildConsumptionReferencePath } from '../catalogLinks';
import { buildSkuEditPath } from '../catalogLinks';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge';
import { classifyCutFamily } from '../analytics/cutting';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, isSuperAdmin } from '../dataService';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, isSuperAdmin, saveSupplyMaterialDetails } from '../dataService';
import { parseProductName } from '../productParsing';
import { resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
@@ -2117,14 +2118,6 @@ const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products
>
#{product.productId} · {product.name}
</RouterLink>
<RouterLink
to={buildConsumptionReferencePath({ sku: product.productId, name: product.name })}
className="flex h-7 w-7 shrink-0 items-center justify-center border-l border-dark-border text-sky-300 transition-colors hover:bg-sky-400/10"
title={`Editar composição de ${product.name}`}
aria-label={`Editar composição de ${product.name}`}
>
<Pencil className="h-3.5 w-3.5" />
</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"
@@ -2140,7 +2133,7 @@ const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products
);
};
const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
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]">
@@ -2182,7 +2175,7 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<p className="mt-1 text-xs font-semibold text-dark-muted">
{need.colors.length ? need.colors.join(', ') : 'Todas as cores'}
<span className="px-1.5 text-dark-border">•</span>
{need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado'}
{need.preferredSupplier || (need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado')}
</p>
</div>
@@ -2209,6 +2202,15 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<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>
<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"
title={`Editar dados de ${getNeedDisplayName(need)}`}
aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
</div>
@@ -2220,7 +2222,7 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
</>
);
const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
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]">
@@ -2247,7 +2249,7 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<p className="mt-1 text-xs font-semibold text-dark-muted">
{need.colors.length ? need.colors.join(', ') : 'Todas as cores'}
<span className="px-1.5 text-dark-border">•</span>
{need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado'}
{need.preferredSupplier || (need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado')}
</p>
</div>
@@ -2266,6 +2268,15 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<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>
<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"
title={`Editar dados de ${getNeedDisplayName(need)}`}
aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
</div>
@@ -2277,11 +2288,95 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
</>
);
const getMaterialRegistrationSku = (material: string) => {
const [candidate] = material.trim().split(/\s+/, 1);
return /^[A-Z0-9_-]{2,}$/i.test(candidate) ? candidate : '';
};
const MaterialDetailsModal = ({
need,
onClose,
onSave,
}: {
need: SupplyPurchaseNeed;
onClose: () => void;
onSave: (payload: { displayName: string; preferredSupplier: string; notes: string }) => Promise<void>;
}) => {
const [form, setForm] = useState({
displayName: need.material,
preferredSupplier: need.preferredSupplier || need.suppliers[0] || '',
notes: need.notes || '',
});
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const registrationPath = buildSkuEditPath({
sku: getMaterialRegistrationSku(need.material),
name: need.material,
type: 'raw_material',
});
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
setError('');
try {
await onSave(form);
onClose();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar os dados do material.');
} finally {
setIsSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
<form onSubmit={(event) => void submit(event)} className="w-full max-w-lg overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-widest text-sky-300">Dados do material</p>
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={need.material}>{need.material}</h2>
</div>
<button type="button" onClick={onClose} className="inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text" aria-label="Fechar edição">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 p-5">
<label className="block text-xs font-bold text-dark-muted">
Nome para exibição
<input value={form.displayName} onChange={(event) => setForm(current => ({ ...current, displayName: event.target.value }))} className={`${inputClassName} mt-1`} />
</label>
<label className="block text-xs font-bold text-dark-muted">
Fornecedor preferencial
<input value={form.preferredSupplier} onChange={(event) => setForm(current => ({ ...current, preferredSupplier: event.target.value }))} placeholder="Opcional" className={`${inputClassName} mt-1`} />
</label>
<label className="block text-xs font-bold text-dark-muted">
Observações operacionais
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} placeholder="Opcional" className="mt-1 min-h-24 w-full resize-y rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" />
</label>
{error && <p className="rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-2 text-sm font-semibold text-red-300">{error}</p>}
</div>
<div className="flex flex-col-reverse gap-2 border-t border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
<RouterLink to={registrationPath} className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary">
<PackageSearch className="h-4 w-4 text-brand-primary" />
Abrir Cadastros
</RouterLink>
<button type="submit" disabled={isSaving} className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
<Save className="h-4 w-4" />
{isSaving ? 'Salvando...' : 'Salvar'}
</button>
</div>
</form>
</div>
);
};
const PurchaseNeedsScreen = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [editingMaterial, setEditingMaterial] = useState<SupplyPurchaseNeed | null>(null);
const requestedTab = searchParams.get('tab');
const requestedFilter = searchParams.get('filter');
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : 'materials';
@@ -2437,7 +2532,9 @@ const PurchaseNeedsScreen = () => {
) : visibleNeeds.length ? (
<>
<div className="overflow-x-auto">
{activeTab === 'materials' ? <PurchaseMaterialQueue needs={paginatedNeeds} /> : <PurchaseServiceQueue needs={paginatedNeeds} />}
{activeTab === 'materials'
? <PurchaseMaterialQueue needs={paginatedNeeds} onEditMaterial={setEditingMaterial} />
: <PurchaseServiceQueue needs={paginatedNeeds} onEditMaterial={setEditingMaterial} />}
</div>
<PaginationControls
totalItems={visibleNeeds.length}
@@ -2465,6 +2562,17 @@ const PurchaseNeedsScreen = () => {
</div>
)}
</div>
{editingMaterial && (
<MaterialDetailsModal
key={editingMaterial.materialKey}
need={editingMaterial}
onClose={() => setEditingMaterial(null)}
onSave={async (payload) => {
await saveSupplyMaterialDetails(editingMaterial.materialKey, payload);
await loadSummary();
}}
/>
)}
</div>
);
};