feat: Implement granular permission-based status transitions for pedidos.

This commit is contained in:
2025-12-05 19:34:22 -03:00
parent 80e9b76649
commit ff91d8a3ab
2 changed files with 341 additions and 43 deletions

View File

@@ -30,6 +30,7 @@
const historyQuery = $derived.by(() => useQuery(api.pedidos.getHistory, { pedidoId })); const historyQuery = $derived.by(() => useQuery(api.pedidos.getHistory, { pedidoId }));
const objetosQuery = $derived.by(() => useQuery(api.objetos.list, {})); const objetosQuery = $derived.by(() => useQuery(api.objetos.list, {}));
const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {})); const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {}));
const permissionsQuery = $derived.by(() => useQuery(api.pedidos.getPermissions, { pedidoId }));
// Derived state // Derived state
let pedido = $derived(pedidoQuery.data); let pedido = $derived(pedidoQuery.data);
@@ -37,6 +38,7 @@
let history = $derived(historyQuery.data || []); let history = $derived(historyQuery.data || []);
let objetos = $derived(objetosQuery.data || []); let objetos = $derived(objetosQuery.data || []);
let acoes = $derived(acoesQuery.data || []); let acoes = $derived(acoesQuery.data || []);
let permissions = $derived(permissionsQuery.data);
type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo'; type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
@@ -114,7 +116,8 @@
itemsQuery.isLoading || itemsQuery.isLoading ||
historyQuery.isLoading || historyQuery.isLoading ||
objetosQuery.isLoading || objetosQuery.isLoading ||
acoesQuery.isLoading acoesQuery.isLoading ||
permissionsQuery.isLoading
); );
let error = $derived( let error = $derived(
@@ -256,23 +259,48 @@
} }
} }
async function updateStatus( async function handleEnviarParaAceite() {
novoStatus: if (!confirm('Enviar para aceite?')) return;
| 'cancelado'
| 'concluido'
| 'em_rascunho'
| 'aguardando_aceite'
| 'em_analise'
| 'precisa_ajustes'
) {
if (!confirm(`Confirmar alteração de status para: ${novoStatus}?`)) return;
try { try {
await client.mutation(api.pedidos.updateStatus, { await client.mutation(api.pedidos.enviarParaAceite, { pedidoId });
pedidoId,
novoStatus
});
} catch (e) { } catch (e) {
alert('Erro ao atualizar status: ' + (e as Error).message); alert('Erro: ' + (e as Error).message);
}
}
async function handleIniciarAnalise() {
if (!confirm('Iniciar análise?')) return;
try {
await client.mutation(api.pedidos.iniciarAnalise, { pedidoId });
} catch (e) {
alert('Erro: ' + (e as Error).message);
}
}
async function handleConcluir() {
if (!confirm('Concluir pedido?')) return;
try {
await client.mutation(api.pedidos.concluirPedido, { pedidoId });
} catch (e) {
alert('Erro: ' + (e as Error).message);
}
}
async function handleSolicitarAjustes() {
if (!confirm('Solicitar ajustes?')) return;
try {
await client.mutation(api.pedidos.solicitarAjustes, { pedidoId });
} catch (e) {
alert('Erro: ' + (e as Error).message);
}
}
async function handleCancelar() {
if (!confirm('Cancelar pedido?')) return;
try {
await client.mutation(api.pedidos.cancelarPedido, { pedidoId });
} catch (e) {
alert('Erro: ' + (e as Error).message);
} }
} }
@@ -635,42 +663,45 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} {#if permissions?.canSendToAcceptance}
<button <button
onclick={() => updateStatus('aguardando_aceite')} onclick={handleEnviarParaAceite}
class="flex items-center gap-2 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700" class="flex items-center gap-2 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
> >
<Send size={18} /> Enviar para Aceite <Send size={18} /> Enviar para Aceite
</button> </button>
{/if} {/if}
{#if pedido.status === 'aguardando_aceite'} {#if permissions?.canStartAnalysis}
<button <button
onclick={() => updateStatus('em_analise')} onclick={handleIniciarAnalise}
class="flex items-center gap-2 rounded bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700" class="flex items-center gap-2 rounded bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700"
> >
<Clock size={18} /> Iniciar Análise <Clock size={18} /> Iniciar Análise
</button> </button>
{/if} {/if}
{#if pedido.status === 'em_analise'} {#if permissions?.canConclude}
<button <button
onclick={() => updateStatus('concluido')} onclick={handleConcluir}
class="flex items-center gap-2 rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700" class="flex items-center gap-2 rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
> >
<CheckCircle size={18} /> Concluir <CheckCircle size={18} /> Concluir
</button> </button>
{/if}
{#if permissions?.canRequestAdjustments}
<button <button
onclick={() => updateStatus('precisa_ajustes')} onclick={handleSolicitarAjustes}
class="flex items-center gap-2 rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600" class="flex items-center gap-2 rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600"
> >
<AlertTriangle size={18} /> Solicitar Ajustes <AlertTriangle size={18} /> Solicitar Ajustes
</button> </button>
{/if} {/if}
{#if pedido.status !== 'cancelado' && pedido.status !== 'concluido'} {#if permissions?.canCancel}
<button <button
onclick={() => updateStatus('cancelado')} onclick={handleCancelar}
class="flex items-center gap-2 rounded bg-red-100 px-4 py-2 text-red-700 hover:bg-red-200" class="flex items-center gap-2 rounded bg-red-100 px-4 py-2 text-red-700 hover:bg-red-200"
> >
<XCircle size={18} /> Cancelar <XCircle size={18} /> Cancelar

View File

@@ -956,28 +956,91 @@ export const updateItem = mutation({
} }
}); });
export const updateStatus = mutation({ export const getPermissions = query({
args: { args: { pedidoId: v.id('pedidos') },
pedidoId: v.id('pedidos'),
novoStatus: v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
)
},
returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx); const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId); const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido not found');
if (!pedido || !user.funcionarioId) {
return {
canSendToAcceptance: false,
canStartAnalysis: false,
canConclude: false,
canRequestAdjustments: false,
canCancel: false
};
}
// Check Compras Sector
let isInComprasSector = false;
const config = await ctx.db.query('config').first();
if (config && config.comprasSetorId) {
const funcionarioSetores = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
isInComprasSector = !!funcionarioSetores;
}
// Check if user has added items
// Optimized: Check if ANY item in this order was added by this user
const userItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId))
.first();
const hasAddedItems = !!userItem;
const isCreator = pedido.criadoPor === user._id;
return {
canSendToAcceptance:
(pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && hasAddedItems,
canStartAnalysis: pedido.status === 'aguardando_aceite' && isInComprasSector,
canConclude: pedido.status === 'em_analise' && isInComprasSector,
canRequestAdjustments: pedido.status === 'em_analise' && isInComprasSector,
canCancel:
pedido.status !== 'cancelado' &&
pedido.status !== 'concluido' &&
(isCreator || isInComprasSector)
};
}
});
export const enviarParaAceite = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') {
throw new Error('Status inválido para envio.');
}
// Check if user has added items
const userItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId))
.first();
if (!userItem) {
throw new Error(
'Você precisa ter adicionado ao menos um item ao pedido para enviá-lo para aceite.'
);
}
const oldStatus = pedido.status; const oldStatus = pedido.status;
const newStatus = 'aguardando_aceite';
await ctx.db.patch(args.pedidoId, { await ctx.db.patch(args.pedidoId, {
status: args.novoStatus, status: newStatus,
atualizadoEm: Date.now() atualizadoEm: Date.now()
}); });
@@ -985,15 +1048,219 @@ export const updateStatus = mutation({
pedidoId: args.pedidoId, pedidoId: args.pedidoId,
usuarioId: user._id, usuarioId: user._id,
acao: 'alteracao_status', acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }), detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now() data: Date.now()
}); });
// Trigger Notifications
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId, pedidoId: args.pedidoId,
oldStatus, oldStatus,
newStatus: args.novoStatus, newStatus,
actorId: user._id
});
}
});
export const iniciarAnalise = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'aguardando_aceite') {
throw new Error('O pedido não está aguardando aceite.');
}
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).');
const oldStatus = pedido.status;
const newStatus = 'em_analise';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
aceitoPor: user.funcionarioId, // Also mark as accepted by this user
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const concluirPedido = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'em_analise') {
throw new Error('O pedido deve estar em análise para ser concluído.');
}
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).');
const oldStatus = pedido.status;
const newStatus = 'concluido';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const solicitarAjustes = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'em_analise') {
throw new Error('O pedido deve estar em análise para solicitar ajustes.');
}
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).');
const oldStatus = pedido.status;
const newStatus = 'precisa_ajustes';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
aceitoPor: undefined, // Clear accepted by since it's back to adjustments
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const cancelarPedido = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status === 'concluido' || pedido.status === 'cancelado') {
throw new Error('Pedido já finalizado.');
}
// Anyone involved (creator or compras) can cancel? Or just creator?
// Logic: If it's creator OR Compras.
const isCreator = pedido.criadoPor === user._id;
let isCompras = false;
const config = await ctx.db.query('config').first();
if (config && config.comprasSetorId && user.funcionarioId) {
const fs = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
isCompras = !!fs;
}
if (!isCreator && !isCompras) {
throw new Error('Permissão negada para cancelar este pedido.');
}
const oldStatus = pedido.status;
const newStatus = 'cancelado';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id actorId: user._id
}); });
} }