Add editable supply material details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') || '',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user