feat: Implement granular permission-based status transitions for pedidos.
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
const historyQuery = $derived.by(() => useQuery(api.pedidos.getHistory, { pedidoId }));
|
||||
const objetosQuery = $derived.by(() => useQuery(api.objetos.list, {}));
|
||||
const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {}));
|
||||
const permissionsQuery = $derived.by(() => useQuery(api.pedidos.getPermissions, { pedidoId }));
|
||||
|
||||
// Derived state
|
||||
let pedido = $derived(pedidoQuery.data);
|
||||
@@ -37,6 +38,7 @@
|
||||
let history = $derived(historyQuery.data || []);
|
||||
let objetos = $derived(objetosQuery.data || []);
|
||||
let acoes = $derived(acoesQuery.data || []);
|
||||
let permissions = $derived(permissionsQuery.data);
|
||||
|
||||
type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
|
||||
|
||||
@@ -114,7 +116,8 @@
|
||||
itemsQuery.isLoading ||
|
||||
historyQuery.isLoading ||
|
||||
objetosQuery.isLoading ||
|
||||
acoesQuery.isLoading
|
||||
acoesQuery.isLoading ||
|
||||
permissionsQuery.isLoading
|
||||
);
|
||||
|
||||
let error = $derived(
|
||||
@@ -256,23 +259,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(
|
||||
novoStatus:
|
||||
| 'cancelado'
|
||||
| 'concluido'
|
||||
| 'em_rascunho'
|
||||
| 'aguardando_aceite'
|
||||
| 'em_analise'
|
||||
| 'precisa_ajustes'
|
||||
) {
|
||||
if (!confirm(`Confirmar alteração de status para: ${novoStatus}?`)) return;
|
||||
async function handleEnviarParaAceite() {
|
||||
if (!confirm('Enviar para aceite?')) return;
|
||||
try {
|
||||
await client.mutation(api.pedidos.updateStatus, {
|
||||
pedidoId,
|
||||
novoStatus
|
||||
});
|
||||
await client.mutation(api.pedidos.enviarParaAceite, { pedidoId });
|
||||
} 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 class="flex gap-2">
|
||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||
{#if permissions?.canSendToAcceptance}
|
||||
<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"
|
||||
>
|
||||
<Send size={18} /> Enviar para Aceite
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if pedido.status === 'aguardando_aceite'}
|
||||
{#if permissions?.canStartAnalysis}
|
||||
<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"
|
||||
>
|
||||
<Clock size={18} /> Iniciar Análise
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if pedido.status === 'em_analise'}
|
||||
{#if permissions?.canConclude}
|
||||
<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"
|
||||
>
|
||||
<CheckCircle size={18} /> Concluir
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if permissions?.canRequestAdjustments}
|
||||
<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"
|
||||
>
|
||||
<AlertTriangle size={18} /> Solicitar Ajustes
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if pedido.status !== 'cancelado' && pedido.status !== 'concluido'}
|
||||
{#if permissions?.canCancel}
|
||||
<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"
|
||||
>
|
||||
<XCircle size={18} /> Cancelar
|
||||
|
||||
@@ -956,28 +956,91 @@ export const updateItem = mutation({
|
||||
}
|
||||
});
|
||||
|
||||
export const updateStatus = mutation({
|
||||
args: {
|
||||
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(),
|
||||
export const getPermissions = query({
|
||||
args: { pedidoId: v.id('pedidos') },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await getUsuarioAutenticado(ctx);
|
||||
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 newStatus = 'aguardando_aceite';
|
||||
|
||||
await ctx.db.patch(args.pedidoId, {
|
||||
status: args.novoStatus,
|
||||
status: newStatus,
|
||||
atualizadoEm: Date.now()
|
||||
});
|
||||
|
||||
@@ -985,15 +1048,219 @@ export const updateStatus = mutation({
|
||||
pedidoId: args.pedidoId,
|
||||
usuarioId: user._id,
|
||||
acao: 'alteracao_status',
|
||||
detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }),
|
||||
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
|
||||
data: Date.now()
|
||||
});
|
||||
|
||||
// Trigger Notifications
|
||||
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
|
||||
pedidoId: args.pedidoId,
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user