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

@@ -266,6 +266,17 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_material_details (
material_key VARCHAR(255) PRIMARY KEY,
display_name TEXT,
preferred_supplier VARCHAR(255),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS consumption_references (
id SERIAL PRIMARY KEY,

View File

@@ -10,6 +10,7 @@ const {
deleteReceipt,
getSupplySummary,
listFabricPlans,
saveMaterialDetails,
listLots,
listMovements,
listPurchaseNeeds,
@@ -124,4 +125,12 @@ router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => {
}
});
router.put('/supply/materials/:materialKey', verifyToken, async (req, res, next) => {
try {
res.json(await saveMaterialDetails(req.params.materialKey, req.body || {}));
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -90,6 +90,15 @@ const mapFabricPlan = (row) => ({
updatedAt: row.updated_at
});
const mapMaterialDetails = (row) => ({
materialKey: row.material_key,
displayName: row.display_name || '',
preferredSupplier: row.preferred_supplier || '',
notes: row.notes || '',
createdAt: row.created_at,
updatedAt: row.updated_at
});
const createValidationError = (message) => {
const error = new Error(message);
error.statusCode = 400;
@@ -141,6 +150,39 @@ const listFabricPlans = async () => {
return result.rows.map(mapFabricPlan);
};
const listMaterialDetails = async () => {
const result = await pool.query(`
SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at
FROM supply_material_details
`);
return result.rows.map(mapMaterialDetails);
};
const saveMaterialDetails = async (materialKey, payload) => {
const normalizedKey = normalizeKey(materialKey);
if (!normalizedKey) throw createValidationError('Material inválido para edição.');
const result = await pool.query(`
INSERT INTO supply_material_details (
material_key, display_name, preferred_supplier, notes, updated_at
)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
ON CONFLICT (material_key) DO UPDATE
SET display_name = EXCLUDED.display_name,
preferred_supplier = EXCLUDED.preferred_supplier,
notes = EXCLUDED.notes,
updated_at = CURRENT_TIMESTAMP
RETURNING material_key, display_name, preferred_supplier, notes, created_at, updated_at
`, [
normalizedKey,
normalizeText(payload.displayName) || null,
normalizeText(payload.preferredSupplier) || null,
normalizeText(payload.notes) || null
]);
return mapMaterialDetails(result.rows[0]);
};
const buildPurchaseNeeds = (plans, lots, receipts) => {
const needsByMaterial = new Map();
@@ -150,6 +192,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
const current = needsByMaterial.get(key) || {
material: plan.material,
materialKey: normalizeKey(plan.material),
plannedKg: 0,
stockKg: 0,
pendingKg: 0,
@@ -189,6 +232,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
return {
material: need.material,
materialKey: need.materialKey,
plannedKg: need.plannedKg,
stockKg,
pendingKg: need.pendingKg,
@@ -309,6 +353,7 @@ const listTinyStockRows = async () => {
const mergeNeedLine = (needsByMaterial, key, patch) => {
const current = needsByMaterial.get(key) || {
material: patch.material,
materialKey: patch.materialKey || normalizeKey(patch.material),
plannedKg: 0,
stockKg: 0,
pendingKg: 0,
@@ -397,6 +442,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
mergeNeedLine(needsByMaterial, key, {
material,
materialKey: normalizeKey(material),
plannedKg: suggestedQuantity * quantityPerUnit,
priority: 'Atenção',
unit,
@@ -557,6 +603,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
return {
material: need.material,
materialKey: need.materialKey,
plannedKg: need.plannedKg,
stockKg,
pendingKg: need.pendingKg,
@@ -633,6 +680,25 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
});
};
const applyMaterialDetails = (purchaseNeeds, materialDetails) => {
const detailsByKey = new Map(materialDetails.map(detail => [detail.materialKey, detail]));
return purchaseNeeds.map(need => {
const detail = detailsByKey.get(normalizeKey(need.materialKey || need.material));
if (!detail) return { ...need, preferredSupplier: '', notes: '' };
return {
...need,
material: detail.displayName || need.material,
suppliers: detail.preferredSupplier
? [detail.preferredSupplier, ...need.suppliers.filter(supplier => supplier !== detail.preferredSupplier)]
: need.suppliers,
preferredSupplier: detail.preferredSupplier,
notes: detail.notes
};
});
};
const buildStats = (receipts, lots, purchaseNeeds) => {
const totalQuantityKg = lots.reduce((total, lot) => (
lot.unit === 'kg' ? total + lot.quantity : total
@@ -649,15 +715,19 @@ const buildStats = (receipts, lots, purchaseNeeds) => {
};
const getSupplySummary = async () => {
const [receipts, lots, movements, fabricPlans] = await Promise.all([
const [receipts, lots, movements, fabricPlans, materialDetails] = await Promise.all([
listReceipts(),
listLots(),
listMovements(),
listFabricPlans()
listFabricPlans(),
listMaterialDetails()
]);
const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts);
const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds);
const purchaseNeeds = applyMaterialDetails(
mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds),
materialDetails
);
return {
receipts,
@@ -670,15 +740,19 @@ const getSupplySummary = async () => {
};
const listPurchaseNeeds = async () => {
const [plans, lots, receipts] = await Promise.all([
const [plans, lots, receipts, materialDetails] = await Promise.all([
listFabricPlans(),
listLots(),
listReceipts()
listReceipts(),
listMaterialDetails()
]);
return mergePurchaseNeeds(
buildPurchaseNeeds(plans, lots, receipts),
await buildProjectPurchaseNeeds(lots, receipts)
return applyMaterialDetails(
mergePurchaseNeeds(
buildPurchaseNeeds(plans, lots, receipts),
await buildProjectPurchaseNeeds(lots, receipts)
),
materialDetails
);
};
@@ -967,8 +1041,10 @@ module.exports = {
buildProjectPurchaseNeeds,
buildPurchaseNeeds,
listFabricPlans,
listMaterialDetails,
listLots,
listMovements,
listPurchaseNeeds,
listReceipts
listReceipts,
saveMaterialDetails
};

View File

@@ -3,13 +3,15 @@ type SkuEditParams = {
name?: string;
color?: string;
size?: string;
type?: 'finished_product' | 'raw_material';
};
export const buildSkuEditPath = ({ sku, name = '', color = '', size = '' }: SkuEditParams) => {
export const buildSkuEditPath = ({ sku, name = '', color = '', size = '', type }: SkuEditParams) => {
const params = new URLSearchParams({ tab: 'products', sku });
if (name) params.set('name', name);
if (color) params.set('color', color);
if (size) params.set('size', size);
if (type) params.set('type', type);
return `/registrations?${params.toString()}`;
};

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -388,6 +388,24 @@ export const fetchSupplySummary = async (): Promise<SupplySummary> => {
}
};
export const saveSupplyMaterialDetails = async (
materialKey: string,
payload: SupplyMaterialDetailsPayload
): Promise<void> => {
const response = await authFetch(`/supply/materials/${encodeURIComponent(materialKey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível salvar os dados do material.');
}
analyticsCache.clear();
};
export const createSupplyReceipt = async (payload: SupplyReceiptPayload): Promise<SupplyReceipt> => {
const response = await authFetch('/supply/receipts', {
method: 'POST',

View File

@@ -158,6 +158,7 @@ const Registrations = () => {
useEffect(() => {
const requestedTab = searchParams.get('tab');
const sku = (searchParams.get('sku') || '').trim();
const requestedType: CatalogProductType = searchParams.get('type') === 'raw_material' ? 'raw_material' : 'finished_product';
if (!sku && !(requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references')) return;
const prefillKey = `${requestedTab || ''}|${sku}|${searchParams.get('name') || ''}|${searchParams.get('color') || ''}|${catalog.products.length}`;
@@ -210,6 +211,7 @@ const Registrations = () => {
const requestedSize = (searchParams.get('size') || '').trim().toUpperCase();
setProductForm({
...defaultProductForm,
type: requestedType,
sku,
name: searchParams.get('name') || '',
color: searchParams.get('color') || '',

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>
);
};

View File

@@ -292,6 +292,7 @@ export interface SupplyFabricPlan {
export interface SupplyPurchaseNeed {
material: string;
materialKey: string;
plannedKg: number;
stockKg: number;
pendingKg: number;
@@ -303,6 +304,8 @@ export interface SupplyPurchaseNeed {
unit?: string;
source?: string;
missingReference?: boolean;
preferredSupplier?: string;
notes?: string;
products?: Array<{
productId: string;
name: string;
@@ -315,6 +318,12 @@ export interface SupplyPurchaseNeed {
}>;
}
export type SupplyMaterialDetailsPayload = {
displayName?: string;
preferredSupplier?: string;
notes?: string;
};
export interface SupplyStats {
totalQuantityKg: number;
activeLots: number;