Add production plan release checklist
This commit is contained in:
@@ -189,6 +189,21 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
// The release decision remains auditable when the plan changes status.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS production_plan_reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
production_plan_id INTEGER NOT NULL UNIQUE REFERENCES production_plans(id) ON DELETE CASCADE,
|
||||
decision VARCHAR(30) NOT NULL DEFAULT 'pending',
|
||||
checklist JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
notes TEXT,
|
||||
reviewed_by TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, saveProductionPlanReview, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -75,6 +75,14 @@ router.patch('/production-plans/:id/status', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/production-plans/:id/review', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await saveProductionPlanReview(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
|
||||
try {
|
||||
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
|
||||
|
||||
@@ -351,10 +351,57 @@ const mapProductionPlanRow = (row) => ({
|
||||
actualCutQuantity: Number(row.actual_cut_quantity || 0),
|
||||
finishedQuantity: Number(row.finished_quantity || 0),
|
||||
statusCounts: row.status_counts || {},
|
||||
review: row.review_id ? {
|
||||
id: Number(row.review_id),
|
||||
decision: row.review_decision || 'pending',
|
||||
checklist: row.review_checklist || {},
|
||||
notes: row.review_notes || '',
|
||||
reviewedBy: row.reviewed_by || '',
|
||||
reviewedAt: row.reviewed_at || null,
|
||||
updatedAt: row.review_updated_at || null
|
||||
} : null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null
|
||||
});
|
||||
|
||||
const REVIEW_DECISIONS = new Set(['pending', 'approved', 'adjustments_required']);
|
||||
const REVIEW_CHECKLIST_KEYS = new Set(['materialsReserved', 'cutSpecificationReady', 'sizeAndColorReviewed', 'responsibleAndDateDefined']);
|
||||
|
||||
const normalizeProductionPlanReview = (payload = {}) => {
|
||||
const decision = normalizeText(payload.decision).toLowerCase() || 'pending';
|
||||
if (!REVIEW_DECISIONS.has(decision)) {
|
||||
const error = new Error('Decisão de revisão inválida.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const rawChecklist = payload.checklist && typeof payload.checklist === 'object' ? payload.checklist : {};
|
||||
const checklist = {};
|
||||
REVIEW_CHECKLIST_KEYS.forEach(key => { checklist[key] = Boolean(rawChecklist[key]); });
|
||||
return { decision, checklist, notes: normalizeText(payload.notes), reviewedBy: normalizeText(payload.reviewedBy) };
|
||||
};
|
||||
|
||||
const saveProductionPlanReview = async (id, payload = {}) => {
|
||||
const review = normalizeProductionPlanReview(payload);
|
||||
const planExists = await pool.query('SELECT id FROM production_plans WHERE id = $1', [id]);
|
||||
if (!planExists.rowCount) {
|
||||
const error = new Error('Plano de produção não encontrado.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await pool.query(`
|
||||
INSERT INTO production_plan_reviews (production_plan_id, decision, checklist, notes, reviewed_by, reviewed_at, updated_at)
|
||||
VALUES ($1, $2, $3::jsonb, NULLIF($4, ''), NULLIF($5, ''), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (production_plan_id) DO UPDATE
|
||||
SET decision = EXCLUDED.decision,
|
||||
checklist = EXCLUDED.checklist,
|
||||
notes = EXCLUDED.notes,
|
||||
reviewed_by = EXCLUDED.reviewed_by,
|
||||
reviewed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [id, review.decision, JSON.stringify(review.checklist), review.notes, review.reviewedBy]);
|
||||
return getProductionPlanById(id);
|
||||
};
|
||||
|
||||
const normalizePlanPayload = (payload = {}, fallback = {}) => ({
|
||||
id: normalizeInteger(payload.id),
|
||||
name: normalizeText(payload.name) || normalizeText(fallback.name),
|
||||
@@ -388,12 +435,16 @@ const getProductionPlanById = async (id, client = pool) => {
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) grouped_statuses
|
||||
), '{}'::json) AS status_counts
|
||||
), '{}'::json) AS status_counts,
|
||||
review.id AS review_id, review.decision AS review_decision,
|
||||
review.checklist AS review_checklist, review.notes AS review_notes,
|
||||
review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at
|
||||
FROM production_plans plan
|
||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
||||
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
|
||||
LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id
|
||||
WHERE plan.id = $1
|
||||
GROUP BY plan.id, responsible.id;
|
||||
GROUP BY plan.id, responsible.id, review.id;
|
||||
`, [id]);
|
||||
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
|
||||
};
|
||||
@@ -789,11 +840,15 @@ const listProductionPlans = async () => {
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) grouped_statuses
|
||||
), '{}'::json) AS status_counts
|
||||
), '{}'::json) AS status_counts,
|
||||
review.id AS review_id, review.decision AS review_decision,
|
||||
review.checklist AS review_checklist, review.notes AS review_notes,
|
||||
review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at
|
||||
FROM production_plans plan
|
||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
||||
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
|
||||
GROUP BY plan.id, responsible.id
|
||||
LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id
|
||||
GROUP BY plan.id, responsible.id, review.id
|
||||
ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC;
|
||||
`);
|
||||
return result.rows.map(mapProductionPlanRow);
|
||||
@@ -1942,6 +1997,7 @@ module.exports = {
|
||||
getProductComposition,
|
||||
importTinyProductCompositionExport,
|
||||
listProductionPlans,
|
||||
saveProductionPlanReview,
|
||||
listProductionWorkstations,
|
||||
listProductCompositions,
|
||||
listProductionOrders,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, 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, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionPlanReviewPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -281,6 +281,16 @@ export const updateProductionPlanStatus = async (id: number, status: ProductionO
|
||||
return data as ProductionPlan;
|
||||
};
|
||||
|
||||
export const saveProductionPlanReview = async (id: number, payload: ProductionPlanReviewPayload): Promise<ProductionPlan> => {
|
||||
const response = await authFetch(`/production-plans/${id}/review`, {
|
||||
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 a revisão operacional.');
|
||||
analyticsCache.clear();
|
||||
return data as ProductionPlan;
|
||||
};
|
||||
|
||||
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
|
||||
const response = await authFetch(`/production-orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardList, Factory, Loader2, PlayCircle, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import { fetchOperationalUsers, fetchProductionPlans, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
|
||||
import type { OperationalUser, ProductionOrderStatus, ProductionPlan } from '../types';
|
||||
import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, UserRound, X, type LucideIcon } from 'lucide-react';
|
||||
import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
|
||||
import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } from '../types';
|
||||
|
||||
const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [
|
||||
{ value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' },
|
||||
@@ -28,6 +28,16 @@ const ProductionPlans = () => {
|
||||
const [busyPlanId, setBusyPlanId] = useState<number | null>(null);
|
||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
|
||||
const [reviewingPlanId, setReviewingPlanId] = useState<number | null>(null);
|
||||
const [reviewForm, setReviewForm] = useState({
|
||||
decision: 'pending' as ProductionPlanReviewDecision,
|
||||
materialsReserved: false,
|
||||
cutSpecificationReady: false,
|
||||
sizeAndColorReviewed: false,
|
||||
responsibleAndDateDefined: false,
|
||||
notes: '',
|
||||
reviewedBy: '',
|
||||
});
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const loadPlans = useCallback(async () => {
|
||||
@@ -92,6 +102,53 @@ const ProductionPlans = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openReview = (plan: ProductionPlan) => {
|
||||
const checklist = plan.review?.checklist || {};
|
||||
setReviewForm({
|
||||
decision: plan.review?.decision || 'pending',
|
||||
materialsReserved: Boolean(checklist.materialsReserved),
|
||||
cutSpecificationReady: Boolean(checklist.cutSpecificationReady),
|
||||
sizeAndColorReviewed: Boolean(checklist.sizeAndColorReviewed),
|
||||
responsibleAndDateDefined: Boolean(checklist.responsibleAndDateDefined),
|
||||
notes: plan.review?.notes || '',
|
||||
reviewedBy: plan.review?.reviewedBy || plan.responsibleName || '',
|
||||
});
|
||||
setResponsiblePickerPlanId(null);
|
||||
setReviewingPlanId(plan.id);
|
||||
};
|
||||
|
||||
const submitReview = async (plan: ProductionPlan) => {
|
||||
setBusyPlanId(plan.id);
|
||||
setMessage('');
|
||||
try {
|
||||
const updated = await saveProductionPlanReview(plan.id, {
|
||||
decision: reviewForm.decision,
|
||||
checklist: {
|
||||
materialsReserved: reviewForm.materialsReserved,
|
||||
cutSpecificationReady: reviewForm.cutSpecificationReady,
|
||||
sizeAndColorReviewed: reviewForm.sizeAndColorReviewed,
|
||||
responsibleAndDateDefined: reviewForm.responsibleAndDateDefined,
|
||||
},
|
||||
notes: reviewForm.notes,
|
||||
reviewedBy: reviewForm.reviewedBy,
|
||||
});
|
||||
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
|
||||
setReviewingPlanId(null);
|
||||
setMessage(updated.review?.decision === 'approved' ? `${updated.number} liberado para operação.` : `Revisão operacional de ${updated.number} registrada.`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Não foi possível salvar a revisão.');
|
||||
} finally {
|
||||
setBusyPlanId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reviewDecisionLabel: Record<ProductionPlanReviewDecision, string> = { pending: 'Em revisão', approved: 'Liberado', adjustments_required: 'Ajustes necessários' };
|
||||
const reviewDecisionStyle = (decision?: ProductionPlanReviewDecision) => decision === 'approved'
|
||||
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
: decision === 'adjustments_required'
|
||||
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
||||
: 'border-dark-border bg-dark-input text-dark-muted';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
@@ -122,12 +179,15 @@ const ProductionPlans = () => {
|
||||
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : plans.length ? (
|
||||
<div className="divide-y divide-dark-border">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="grid gap-4 p-5 xl:grid-cols-[minmax(240px,1.3fr)_minmax(150px,.7fr)_minmax(160px,.7fr)_minmax(190px,.75fr)_180px] xl:items-center">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span></div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
|
||||
<div key={plan.id} className="p-5">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(240px,1.3fr)_minmax(150px,.7fr)_minmax(160px,.7fr)_minmax(190px,.75fr)_180px] xl:items-center">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span>{plan.review && <span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${reviewDecisionStyle(plan.review.decision)}`}>{reviewDecisionLabel[plan.review.decision]}</span>}</div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
|
||||
<div className="text-sm"><p className="text-xs font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 font-bold text-dark-text">{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.</p><p className="mt-1 text-xs font-semibold text-dark-muted">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p></div>
|
||||
<div className="flex flex-wrap gap-1.5">{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => <span key={option.value} className={`rounded-full border px-2 py-1 text-xs font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div>
|
||||
<div className="relative text-xs font-bold text-dark-muted"><p>Responsável</p><button type="button" disabled={busyPlanId === plan.id} onClick={() => setResponsiblePickerPlanId(current => current === plan.id ? null : plan.id)} className="mt-1 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{plan.responsibleName || 'Selecionar responsável'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${responsiblePickerPlanId === plan.id ? 'rotate-180' : ''}`} /></button>{responsiblePickerPlanId === plan.id && <div className="absolute right-0 z-20 mt-2 w-full min-w-[270px] overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-56 overflow-y-auto p-1.5">{operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" onClick={() => void changeResponsible(plan, user)} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input cursor-pointer"><span className="truncate">{user.name}</span>{plan.responsibleUserId === user.id && plan.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>)}{!operationalUsers.length && <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{plan.responsibleName && <button type="button" onClick={() => void changeResponsible(plan, null)} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text cursor-pointer">Remover responsável</button>}</div>}</div>
|
||||
<label className="text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
||||
<div className="flex gap-2 xl:flex-col"><button type="button" disabled={busyPlanId === plan.id} onClick={() => openReview(plan)} className="inline-flex h-10 flex-1 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:opacity-60 cursor-pointer"><ClipboardCheck className="h-4 w-4 text-brand-primary" />Checklist</button><label className="flex-1 text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label></div>
|
||||
</div>
|
||||
{reviewingPlanId === plan.id && <div className="mt-5 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.045] p-4"><div className="flex items-start justify-between gap-4"><div><h3 className="text-sm font-bold text-dark-text">Liberação operacional</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre a decisão antes de avançar o plano. Este registro não muda o estoque nem substitui o recebimento.</p></div><button type="button" onClick={() => setReviewingPlanId(null)} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted hover:text-dark-text cursor-pointer" aria-label="Fechar checklist"><X className="h-4 w-4" /></button></div><div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e data definidos' }].map(item => <label key={item.key} className="flex items-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 py-2.5 text-xs font-bold text-dark-text"><input type="checkbox" checked={reviewForm[item.key as keyof Pick<typeof reviewForm, 'materialsReserved' | 'cutSpecificationReady' | 'sizeAndColorReviewed' | 'responsibleAndDateDefined'>]} onChange={(event) => setReviewForm(current => ({ ...current, [item.key]: event.target.checked }))} className="h-4 w-4 accent-brand-primary" />{item.label}</label>)}</div><div className="mt-4 grid gap-3 md:grid-cols-[180px_minmax(0,1fr)_minmax(0,1fr)_auto]"><label className="text-xs font-bold text-dark-muted">Decisão<select value={reviewForm.decision} onChange={(event) => setReviewForm(current => ({ ...current, decision: event.target.value as ProductionPlanReviewDecision }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary"><option value="pending">Em revisão</option><option value="approved">Liberar para operação</option><option value="adjustments_required">Solicitar ajustes</option></select></label><label className="text-xs font-bold text-dark-muted">Revisado por<input value={reviewForm.reviewedBy} onChange={(event) => setReviewForm(current => ({ ...current, reviewedBy: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Nome do usuário" /></label><label className="text-xs font-bold text-dark-muted">Observação<input value={reviewForm.notes} onChange={(event) => setReviewForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Ajuste, orientação ou motivo" /></label><button type="button" disabled={busyPlanId === plan.id} onClick={() => void submitReview(plan)} className="mt-5 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-xs font-bold text-brand-contrast disabled:opacity-60 cursor-pointer"><Check className="h-4 w-4" />Salvar</button></div></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
25
src/types.ts
25
src/types.ts
@@ -232,10 +232,35 @@ export interface ProductionPlan {
|
||||
actualCutQuantity: number;
|
||||
finishedQuantity: number;
|
||||
statusCounts: Record<string, number>;
|
||||
review: ProductionPlanReview | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export type ProductionPlanReviewDecision = 'pending' | 'approved' | 'adjustments_required';
|
||||
|
||||
export interface ProductionPlanReview {
|
||||
id: number;
|
||||
decision: ProductionPlanReviewDecision;
|
||||
checklist: {
|
||||
materialsReserved?: boolean;
|
||||
cutSpecificationReady?: boolean;
|
||||
sizeAndColorReviewed?: boolean;
|
||||
responsibleAndDateDefined?: boolean;
|
||||
};
|
||||
notes: string;
|
||||
reviewedBy: string;
|
||||
reviewedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export type ProductionPlanReviewPayload = {
|
||||
decision: ProductionPlanReviewDecision;
|
||||
checklist: ProductionPlanReview['checklist'];
|
||||
notes?: string;
|
||||
reviewedBy?: string;
|
||||
};
|
||||
|
||||
export type ProductionPlanPayload = {
|
||||
id?: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user