Allow updating production plan responsibility
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s

This commit is contained in:
Cauê Faleiros
2026-08-25 16:42:51 -03:00
parent c259ddea29
commit 3a0cc9ffe8
6 changed files with 97 additions and 41 deletions

View File

@@ -796,7 +796,8 @@ const initDB = async () => {
// role-based assignments be added without changing historical plans. // role-based assignments be added without changing historical plans.
await pool.query(` await pool.query(`
ALTER TABLE production_plans ALTER TABLE production_plans
ADD COLUMN IF NOT EXISTS responsible_user_id INTEGER REFERENCES app_users(id) ON DELETE SET NULL; ADD COLUMN IF NOT EXISTS responsible_user_id INTEGER REFERENCES app_users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS responsible_name TEXT;
`).catch(() => {}); `).catch(() => {});
await pool.query(` await pool.query(`

View File

@@ -1,5 +1,5 @@
const { pool } = require('../db'); const { pool } = require('../db');
const { getActiveOperationalUserById } = require('./userService'); const { getActiveOperationalUserById, SYSTEM_OPERATIONAL_USER_NAME } = require('./userService');
const STATUS_LABELS = { const STATUS_LABELS = {
open: 'Em aberto', open: 'Em aberto',
@@ -332,7 +332,7 @@ const mapProductionPlanRow = (row) => ({
statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto', statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto',
contractor: row.contractor || '', contractor: row.contractor || '',
responsibleUserId: row.responsible_user_id === null || row.responsible_user_id === undefined ? null : Number(row.responsible_user_id), responsibleUserId: row.responsible_user_id === null || row.responsible_user_id === undefined ? null : Number(row.responsible_user_id),
responsibleName: row.responsible_name || '', responsibleName: row.responsible_user_name || row.responsible_name || '',
plannedStartDate: formatDate(row.planned_start_date), plannedStartDate: formatDate(row.planned_start_date),
expectedDate: formatDate(row.expected_date), expectedDate: formatDate(row.expected_date),
completedAt: row.completed_at || null, completedAt: row.completed_at || null,
@@ -354,6 +354,7 @@ const normalizePlanPayload = (payload = {}, fallback = {}) => ({
responsibleUserId: payload.responsibleUserId === undefined responsibleUserId: payload.responsibleUserId === undefined
? normalizeInteger(fallback.responsibleUserId) ? normalizeInteger(fallback.responsibleUserId)
: normalizeInteger(payload.responsibleUserId), : normalizeInteger(payload.responsibleUserId),
responsibleName: normalizeText(payload.responsibleName) || normalizeText(fallback.responsibleName),
plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate), plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate),
expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate), expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate),
notes: normalizeText(payload.notes), notes: normalizeText(payload.notes),
@@ -364,7 +365,7 @@ const getProductionPlanById = async (id, client = pool) => {
const result = await client.query(` const result = await client.query(`
SELECT SELECT
plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id, plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id,
responsible.name AS responsible_name, responsible.name AS responsible_user_name, plan.responsible_name,
plan.planned_start_date, plan.expected_date, plan.completed_at, plan.planned_start_date, plan.expected_date, plan.completed_at,
plan.notes, plan.source, plan.created_at, plan.updated_at, plan.notes, plan.source, plan.created_at, plan.updated_at,
COUNT(po.id)::int AS order_count, COUNT(po.id)::int AS order_count,
@@ -389,6 +390,26 @@ const getProductionPlanById = async (id, client = pool) => {
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null; return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
}; };
const resolvePlanResponsible = async (plan, client = pool) => {
if (plan.responsibleUserId) {
const user = await getActiveOperationalUserById(plan.responsibleUserId, client);
if (!user) {
const error = new Error('O responsável selecionado não está disponível.');
error.statusCode = 400;
throw error;
}
return { userId: Number(user.id), name: user.name };
}
if (plan.responsibleName && plan.responsibleName !== SYSTEM_OPERATIONAL_USER_NAME) {
const error = new Error('O responsável selecionado não está disponível.');
error.statusCode = 400;
throw error;
}
return { userId: null, name: plan.responsibleName };
};
const createOrGetProductionPlan = async (client, payload, fallback) => { const createOrGetProductionPlan = async (client, payload, fallback) => {
const plan = normalizePlanPayload(payload, fallback); const plan = normalizePlanPayload(payload, fallback);
if (plan.id) { if (plan.id) {
@@ -402,17 +423,13 @@ const createOrGetProductionPlan = async (client, payload, fallback) => {
} }
if (!plan.name) return null; if (!plan.name) return null;
if (plan.responsibleUserId && !await getActiveOperationalUserById(plan.responsibleUserId, client)) { const responsible = await resolvePlanResponsible(plan, client);
const error = new Error('O responsável selecionado não está disponível.');
error.statusCode = 400;
throw error;
}
const inserted = await client.query(` const inserted = await client.query(`
INSERT INTO production_plans (name, status, contractor, responsible_user_id, planned_start_date, expected_date, notes, source) INSERT INTO production_plans (name, status, contractor, responsible_user_id, responsible_name, planned_start_date, expected_date, notes, source)
VALUES ($1, 'open', NULLIF($2, ''), $3, $4, $5, NULLIF($6, ''), $7) VALUES ($1, 'open', NULLIF($2, ''), $3, NULLIF($4, ''), $5, $6, NULLIF($7, ''), $8)
RETURNING id; RETURNING id;
`, [plan.name, plan.contractor, plan.responsibleUserId, plan.plannedStartDate, plan.expectedDate, plan.notes, plan.source]); `, [plan.name, plan.contractor, responsible.userId, responsible.name, plan.plannedStartDate, plan.expectedDate, plan.notes, plan.source]);
const id = Number(inserted.rows[0].id); const id = Number(inserted.rows[0].id);
await client.query(`UPDATE production_plans SET number = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;`, [`PP-${String(id).padStart(5, '0')}`, id]); await client.query(`UPDATE production_plans SET number = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;`, [`PP-${String(id).padStart(5, '0')}`, id]);
return getProductionPlanById(id, client); return getProductionPlanById(id, client);
@@ -749,7 +766,7 @@ const listProductionPlans = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT
plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id, plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id,
responsible.name AS responsible_name, responsible.name AS responsible_user_name, plan.responsible_name,
plan.planned_start_date, plan.expected_date, plan.completed_at, plan.planned_start_date, plan.expected_date, plan.completed_at,
plan.notes, plan.source, plan.created_at, plan.updated_at, plan.notes, plan.source, plan.created_at, plan.updated_at,
COUNT(po.id)::int AS order_count, COUNT(po.id)::int AS order_count,
@@ -781,23 +798,20 @@ const updateProductionPlan = async (id, payload = {}) => {
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
if (plan.responsibleUserId && !await getActiveOperationalUserById(plan.responsibleUserId)) { const responsible = await resolvePlanResponsible(plan);
const error = new Error('O responsável selecionado não está disponível.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(` const result = await pool.query(`
UPDATE production_plans UPDATE production_plans
SET name = $1, SET name = $1,
contractor = NULLIF($2, ''), contractor = NULLIF($2, ''),
responsible_user_id = $3, responsible_user_id = $3,
planned_start_date = $4, responsible_name = NULLIF($4, ''),
expected_date = $5, planned_start_date = $5,
notes = NULLIF($6, ''), expected_date = $6,
notes = NULLIF($7, ''),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $7 WHERE id = $8
RETURNING id; RETURNING id;
`, [plan.name, plan.contractor, plan.responsibleUserId, plan.plannedStartDate, plan.expectedDate, plan.notes, id]); `, [plan.name, plan.contractor, responsible.userId, responsible.name, plan.plannedStartDate, plan.expectedDate, plan.notes, id]);
if (!result.rowCount) { if (!result.rowCount) {
const error = new Error('Plano de produção não encontrado.'); const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404; error.statusCode = 404;

View File

@@ -1,6 +1,8 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { pool } = require('../db'); const { pool } = require('../db');
const SYSTEM_OPERATIONAL_USER_NAME = 'Super Admin';
const HASH_ALGORITHM = 'scrypt'; const HASH_ALGORITHM = 'scrypt';
const KEY_LENGTH = 64; const KEY_LENGTH = 64;
@@ -70,10 +72,14 @@ const listActiveOperationalUsers = async () => {
ORDER BY LOWER(name), id` ORDER BY LOWER(name), id`
); );
return result.rows.map((row) => ({ return [
id: Number(row.id), { id: null, name: SYSTEM_OPERATIONAL_USER_NAME, isSystem: true },
name: row.name ...result.rows.map((row) => ({
})); id: Number(row.id),
name: row.name,
isSystem: false
}))
];
}; };
const getActiveOperationalUserById = async (id, client = pool) => { const getActiveOperationalUserById = async (id, client = pool) => {
@@ -234,6 +240,7 @@ module.exports = {
listUsers, listUsers,
normalizeEmail, normalizeEmail,
publicUserFields, publicUserFields,
SYSTEM_OPERATIONAL_USER_NAME,
updateUser, updateUser,
verifyPassword verifyPassword
}; };

View File

@@ -169,7 +169,7 @@ const Cutting = () => {
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10); const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]); const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({}); const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({});
const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, expectedDate: '', notes: '' }); const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, responsibleName: '', expectedDate: '', notes: '' });
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]); const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false); const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false);
const [isResponsiblePickerOpen, setIsResponsiblePickerOpen] = useState(false); const [isResponsiblePickerOpen, setIsResponsiblePickerOpen] = useState(false);
@@ -399,8 +399,8 @@ const Cutting = () => {
selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0) selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0)
), [selectedPreviewRows]); ), [selectedPreviewRows]);
const selectedResponsibleUser = useMemo(() => ( const selectedResponsibleUser = useMemo(() => (
operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId) || null operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId && user.name === productionPlanForm.responsibleName) || null
), [operationalUsers, productionPlanForm.responsibleUserId]); ), [operationalUsers, productionPlanForm.responsibleName, productionPlanForm.responsibleUserId]);
const cutPlanReference = useMemo(() => ( const cutPlanReference = useMemo(() => (
`Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias` `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`
@@ -676,7 +676,7 @@ const Cutting = () => {
setPreviewPage(1); setPreviewPage(1);
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id)); setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
setLotSelections({}); setLotSelections({});
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, expectedDate: '', notes: '' }); setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' });
setIsPlanDetailsOpen(false); setIsPlanDetailsOpen(false);
setIsResponsiblePickerOpen(false); setIsResponsiblePickerOpen(false);
setPreviewStep('selection'); setPreviewStep('selection');
@@ -765,6 +765,7 @@ const Cutting = () => {
})), selectedLotReservations, { })), selectedLotReservations, {
name: productionPlanForm.name.trim() || cutPlanReference, name: productionPlanForm.name.trim() || cutPlanReference,
responsibleUserId: productionPlanForm.responsibleUserId, responsibleUserId: productionPlanForm.responsibleUserId,
responsibleName: productionPlanForm.responsibleName,
plannedStartDate: issueDate, plannedStartDate: issueDate,
expectedDate: productionPlanForm.expectedDate || null, expectedDate: productionPlanForm.expectedDate || null,
notes: productionPlanForm.notes.trim(), notes: productionPlanForm.notes.trim(),
@@ -961,13 +962,13 @@ const Cutting = () => {
<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="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"> <div className="max-h-56 overflow-y-auto p-1.5">
{isLoadingOperationalUsers ? <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => ( {isLoadingOperationalUsers ? <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => (
<button key={user.id} type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: user.id })); setIsResponsiblePickerOpen(false); }} 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"> <button key={user.id ?? 'super-admin'} type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: user.id, responsibleName: user.name })); setIsResponsiblePickerOpen(false); }} 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> <span className="truncate">{user.name}</span>
{productionPlanForm.responsibleUserId === user.id && <Check className="h-4 w-4 shrink-0 text-brand-primary" />} {productionPlanForm.responsibleUserId === user.id && productionPlanForm.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}
</button> </button>
)) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>} )) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}
</div> </div>
{selectedResponsibleUser && <button type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: null })); setIsResponsiblePickerOpen(false); }} 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>} {selectedResponsibleUser && <button type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: null, responsibleName: '' })); setIsResponsiblePickerOpen(false); }} 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>}
</div> </div>
<button type="button" onClick={() => setIsPlanDetailsOpen(current => !current)} className="inline-flex h-10 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"> <button type="button" onClick={() => setIsPlanDetailsOpen(current => !current)} className="inline-flex h-10 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer">

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowLeft, CheckCircle2, ClipboardList, Factory, Loader2, PlayCircle, type LucideIcon } from 'lucide-react'; import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardList, Factory, Loader2, PlayCircle, UserRound, type LucideIcon } from 'lucide-react';
import { fetchProductionPlans, updateProductionPlanStatus } from '../dataService'; import { fetchOperationalUsers, fetchProductionPlans, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
import type { ProductionOrderStatus, ProductionPlan } from '../types'; import type { OperationalUser, ProductionOrderStatus, ProductionPlan } from '../types';
const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [ 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' }, { value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' },
@@ -26,6 +26,8 @@ const ProductionPlans = () => {
const [plans, setPlans] = useState<ProductionPlan[]>([]); const [plans, setPlans] = useState<ProductionPlan[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [busyPlanId, setBusyPlanId] = useState<number | null>(null); const [busyPlanId, setBusyPlanId] = useState<number | null>(null);
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const loadPlans = useCallback(async () => { const loadPlans = useCallback(async () => {
@@ -39,10 +41,14 @@ const ProductionPlans = () => {
} }
}, []); }, []);
const loadOperationalUsers = useCallback(async () => {
setOperationalUsers(await fetchOperationalUsers());
}, []);
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => { void loadPlans(); }, 0); const timer = window.setTimeout(() => { void Promise.all([loadPlans(), loadOperationalUsers()]); }, 0);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [loadPlans]); }, [loadOperationalUsers, loadPlans]);
const activePlans = useMemo(() => plans.filter(plan => !['finished', 'canceled'].includes(plan.status)), [plans]); const activePlans = useMemo(() => plans.filter(plan => !['finished', 'canceled'].includes(plan.status)), [plans]);
const totalOpenQuantity = useMemo(() => activePlans.reduce((total, plan) => total + plan.totalQuantity, 0), [activePlans]); const totalOpenQuantity = useMemo(() => activePlans.reduce((total, plan) => total + plan.totalQuantity, 0), [activePlans]);
@@ -62,6 +68,30 @@ const ProductionPlans = () => {
} }
}; };
const changeResponsible = async (plan: ProductionPlan, user: OperationalUser | null) => {
setBusyPlanId(plan.id);
setMessage('');
try {
const updated = await updateProductionPlan(plan.id, {
name: plan.name,
contractor: plan.contractor,
responsibleUserId: user?.id ?? null,
responsibleName: user?.name || '',
plannedStartDate: plan.plannedStartDate,
expectedDate: plan.expectedDate,
notes: plan.notes,
source: plan.source
});
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
setResponsiblePickerPlanId(null);
setMessage(updated.responsibleName ? `${updated.number} atribuído a ${updated.responsibleName}.` : `${updated.number} está sem responsável.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o responsável.');
} finally {
setBusyPlanId(null);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
@@ -92,10 +122,11 @@ 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 ? ( {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"> <div className="divide-y divide-dark-border">
{plans.map(plan => ( {plans.map(plan => (
<div key={plan.id} className="grid gap-4 p-5 xl:grid-cols-[minmax(260px,1.4fr)_minmax(150px,.7fr)_minmax(180px,.7fr)_180px] xl:items-center"> <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 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 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="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="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> <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> </div>
))} ))}

View File

@@ -202,6 +202,7 @@ export type ProductionPlanPayload = {
name: string; name: string;
contractor?: string; contractor?: string;
responsibleUserId?: number | null; responsibleUserId?: number | null;
responsibleName?: string;
plannedStartDate?: string | null; plannedStartDate?: string | null;
expectedDate?: string | null; expectedDate?: string | null;
notes?: string; notes?: string;
@@ -630,8 +631,9 @@ export interface ManagedUser {
} }
export interface OperationalUser { export interface OperationalUser {
id: number; id: number | null;
name: string; name: string;
isSystem?: boolean;
} }
export interface CreateUserResult { export interface CreateUserResult {