Merge pull request #44 from killer-cf/config-self-convex
Config self convex
This commit is contained in:
275
.agent/rules/convex-svelte-guidelines.md
Normal file
275
.agent/rules/convex-svelte-guidelines.md
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
trigger: glob
|
||||
globs: **/*.svelte, **/*.ts, **/*.svelte.ts
|
||||
---
|
||||
|
||||
# Convex + Svelte Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
These guidelines describe how to write **Convex** backend code **and** consume it from a **Svelte** (SvelteKit) frontend. The syntax for Convex functions stays exactly the same, but the way you import and call them from the client differs from a React/Next.js project. Below you will find the adapted sections from the original Convex style guide with Svelte‑specific notes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Function Syntax (Backend)
|
||||
|
||||
> **No change** – keep the new Convex function syntax.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
query,
|
||||
mutation,
|
||||
action,
|
||||
internalQuery,
|
||||
internalMutation,
|
||||
internalAction
|
||||
} from './_generated/server';
|
||||
import { v } from 'convex/values';
|
||||
|
||||
export const getUser = query({
|
||||
args: { userId: v.id('users') },
|
||||
returns: v.object({ name: v.string(), email: v.string() }),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user) throw new Error('User not found');
|
||||
return { name: user.name, email: user.email };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. HTTP Endpoints (Backend)
|
||||
|
||||
> **No change** – keep the same `convex/http.ts` file.
|
||||
|
||||
```typescript
|
||||
import { httpRouter } from 'convex/server';
|
||||
import { httpAction } from './_generated/server';
|
||||
|
||||
const http = httpRouter();
|
||||
|
||||
http.route({
|
||||
path: '/api/echo',
|
||||
method: 'POST',
|
||||
handler: httpAction(async (ctx, req) => {
|
||||
const body = await req.bytes();
|
||||
return new Response(body, { status: 200 });
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Validators (Backend)
|
||||
|
||||
> **No change** – keep the same validators (`v.string()`, `v.id()`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 4. Function Registration (Backend)
|
||||
|
||||
> **No change** – use `query`, `mutation`, `action` for public functions and `internal*` for private ones.
|
||||
|
||||
---
|
||||
|
||||
## 5. Function Calling from **Svelte**
|
||||
|
||||
### 5.1 Install the Convex client
|
||||
|
||||
```bash
|
||||
npm i convex @convex-dev/convex-svelte
|
||||
```
|
||||
|
||||
> The `@convex-dev/convex-svelte` package provides a thin wrapper that works with Svelte stores.
|
||||
|
||||
### 5.2 Initialise the client (e.g. in `src/lib/convex.ts`)
|
||||
|
||||
```typescript
|
||||
import { createConvexClient } from '@convex-dev/convex-svelte';
|
||||
|
||||
export const convex = createConvexClient({
|
||||
url: import.meta.env.VITE_CONVEX_URL // set in .env
|
||||
});
|
||||
```
|
||||
|
||||
### 5.3 Using queries in a component
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { convex } from '$lib/convex';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../convex/_generated/api';
|
||||
|
||||
let user: { name: string; email: string } | null = null;
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
user = await convex.query(api.users.getUser, { userId: 'some-id' });
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading…</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if user}
|
||||
<h2>{user.name}</h2>
|
||||
<p>{user.email}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### 5.4 Using mutations in a component
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { convex } from '$lib/convex';
|
||||
import { api } from '../convex/_generated/api';
|
||||
let name = '';
|
||||
let creating = false;
|
||||
let error: string | null = null;
|
||||
|
||||
async function createUser() {
|
||||
creating = true;
|
||||
error = null;
|
||||
try {
|
||||
const userId = await convex.mutation(api.users.createUser, { name });
|
||||
console.log('Created user', userId);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input bind:value={name} placeholder="Name" />
|
||||
<button on:click={createUser} disabled={creating}>Create</button>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
```
|
||||
|
||||
### 5.5 Using **actions** (Node‑only) from Svelte
|
||||
|
||||
Actions run in a Node environment, so they cannot be called directly from the browser. Use a **mutation** that internally calls the action, or expose a HTTP endpoint that triggers the action.
|
||||
|
||||
---
|
||||
|
||||
## 6. Scheduler / Cron (Backend)
|
||||
|
||||
> Same as original guide – define `crons.ts` and export the default `crons` object.
|
||||
|
||||
---
|
||||
|
||||
## 7. File Storage (Backend)
|
||||
|
||||
> Same as original guide – use `ctx.storage.getUrl()` and query `_storage` for metadata.
|
||||
|
||||
---
|
||||
|
||||
## 8. TypeScript Helpers (Backend)
|
||||
|
||||
> Keep using `Id<'table'>` from `./_generated/dataModel`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Svelte‑Specific Tips
|
||||
|
||||
| Topic | Recommendation |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Store‑based data** | If you need reactive data across many components, wrap `convex.query` in a Svelte store (`readable`, `writable`). |
|
||||
| **Error handling** | Use `try / catch` around every client call; surface the error in the UI. |
|
||||
| **SSR / SvelteKit** | Calls made in `load` functions run on the server; you can use `convex.query` there without worrying about the browser environment. |
|
||||
| **Environment variables** | Prefix with `VITE_` for client‑side access (`import.meta.env.VITE_CONVEX_URL`). |
|
||||
| **Testing** | Use the Convex mock client (`createMockConvexClient`) provided by `@convex-dev/convex-svelte` for unit tests. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Full Example (SvelteKit + Convex)
|
||||
|
||||
### 10.1 Backend (`convex/users.ts`)
|
||||
|
||||
```typescript
|
||||
import { mutation, query } from './_generated/server';
|
||||
import { v } from 'convex/values';
|
||||
|
||||
export const createUser = mutation({
|
||||
args: { name: v.string() },
|
||||
returns: v.id('users'),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert('users', { name: args.name });
|
||||
}
|
||||
});
|
||||
|
||||
export const getUser = query({
|
||||
args: { userId: v.id('users') },
|
||||
returns: v.object({ name: v.string() }),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user) throw new Error('Not found');
|
||||
return { name: user.name };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 10.2 Frontend (`src/routes/+page.svelte`)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { convex } from '$lib/convex';
|
||||
import { api } from '$lib/convex/_generated/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let name = '';
|
||||
let createdId: string | null = null;
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
|
||||
async function create() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
createdId = await convex.mutation(api.users.createUser, { name });
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input bind:value={name} placeholder="Your name" />
|
||||
<button on:click={create} disabled={loading}>Create user</button>
|
||||
{#if createdId}<p>Created user id: {createdId}</p>{/if}
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist for New Files
|
||||
|
||||
- ✅ All Convex functions use the **new syntax** (`query({ … })`).
|
||||
- ✅ Every public function has **argument** and **return** validators.
|
||||
- ✅ Svelte components import the generated `api` object from `convex/_generated/api`.
|
||||
- ✅ All client calls use the `convex` instance from `$lib/convex`.
|
||||
- ✅ Environment variable `VITE_CONVEX_URL` is defined in `.env`.
|
||||
- ✅ Errors are caught and displayed in the UI.
|
||||
- ✅ Types are imported from `convex/_generated/dataModel` when needed.
|
||||
|
||||
---
|
||||
|
||||
## 12. References
|
||||
|
||||
- Convex Docs – [Functions](https://docs.convex.dev/functions)
|
||||
- Convex Svelte SDK – [`@convex-dev/convex-svelte`](https://github.com/convex-dev/convex-svelte)
|
||||
- SvelteKit Docs – [Loading Data](https://kit.svelte.dev/docs/loading)
|
||||
|
||||
---
|
||||
|
||||
_Keep these guidelines alongside the existing `svelte-rules.md` so that contributors have a single source of truth for both frontend and backend conventions._
|
||||
30
.github/workflows/deploy.yml
vendored
Normal file
30
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Build Docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build-and-push-dockerfile-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
# Make sure to replace with your own namespace and repository
|
||||
tags: |
|
||||
namespace/example:latest
|
||||
platforms: linux/amd64
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# Use the official Bun image
|
||||
FROM oven/bun:1 AS base
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Create a non-root user for security
|
||||
RUN addgroup --system --gid 1001 sveltekit
|
||||
RUN adduser --system --uid 1001 sveltekit
|
||||
|
||||
# Copy package.json and bun.lockb (if available)
|
||||
COPY package.json bun.lockb* ./
|
||||
|
||||
# Install dependencies (including dev dependencies for build)
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy the source code
|
||||
COPY . .
|
||||
|
||||
# Prepare SvelteKit and build the application
|
||||
RUN bun run prepare
|
||||
RUN bun run build
|
||||
RUN bun run db:migrate
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1-slim AS production
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 sveltekit
|
||||
RUN adduser --system --uid 1001 sveltekit
|
||||
|
||||
# Copy built application from base stage
|
||||
COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/build ./build
|
||||
COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/package.json ./package.json
|
||||
COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/node_modules ./node_modules
|
||||
|
||||
# Copy any additional files needed for runtime
|
||||
COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/static ./static
|
||||
|
||||
# Switch to non-root user
|
||||
USER sveltekit
|
||||
|
||||
# Expose the port that the app runs on
|
||||
EXPOSE 5173
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5173
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD bun --version || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["bun", "./build/index.js"]
|
||||
@@ -23,6 +23,7 @@
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.38.1",
|
||||
"svelte-check": "^4.3.1",
|
||||
"svelte-dnd-action": "^0.9.67",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^7.1.2"
|
||||
|
||||
124
apps/web/src/lib/components/RelogioPrazo.svelte
Normal file
124
apps/web/src/lib/components/RelogioPrazo.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
const {
|
||||
dueDate,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
status,
|
||||
expectedDuration
|
||||
} = $props<{
|
||||
dueDate: number | undefined;
|
||||
startedAt: number | undefined;
|
||||
finishedAt: number | undefined;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'blocked';
|
||||
expectedDuration: number | undefined;
|
||||
}>();
|
||||
|
||||
let now = $state(Date.now());
|
||||
|
||||
// Atualizar a cada minuto
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 60000); // Atualizar a cada minuto
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
const tempoInfo = $derived.by(() => {
|
||||
// Para etapas concluídas
|
||||
if (status === 'completed' && finishedAt && startedAt) {
|
||||
const tempoExecucao = finishedAt - startedAt;
|
||||
const diasExecucao = Math.floor(tempoExecucao / (1000 * 60 * 60 * 24));
|
||||
const horasExecucao = Math.floor((tempoExecucao % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
|
||||
// Verificar se foi dentro ou fora do prazo
|
||||
const dentroDoPrazo = dueDate ? finishedAt <= dueDate : true;
|
||||
const diasAtrasado = !dentroDoPrazo && dueDate
|
||||
? Math.floor((finishedAt - dueDate) / (1000 * 60 * 60 * 24))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
tipo: 'concluida',
|
||||
dias: diasExecucao,
|
||||
horas: horasExecucao,
|
||||
dentroDoPrazo,
|
||||
diasAtrasado
|
||||
};
|
||||
}
|
||||
|
||||
// Para etapas em andamento
|
||||
if (status === 'in_progress' && startedAt && expectedDuration) {
|
||||
// Calcular prazo baseado em startedAt + expectedDuration
|
||||
const prazoCalculado = startedAt + expectedDuration * 24 * 60 * 60 * 1000;
|
||||
const diff = prazoCalculado - now;
|
||||
const dias = Math.floor(Math.abs(diff) / (1000 * 60 * 60 * 24));
|
||||
const horas = Math.floor((Math.abs(diff) % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
|
||||
return {
|
||||
tipo: 'andamento',
|
||||
atrasado: diff < 0,
|
||||
dias,
|
||||
horas
|
||||
};
|
||||
}
|
||||
|
||||
// Para etapas pendentes ou bloqueadas, não mostrar nada
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if tempoInfo}
|
||||
{@const info = tempoInfo}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if info.tipo === 'concluida'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 {info.dentroDoPrazo ? 'text-info' : 'text-error'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium {info.dentroDoPrazo ? 'text-info' : 'text-error'}">
|
||||
Concluída em {info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
|
||||
{info.horas} {info.horas === 1 ? 'hora' : 'horas'}
|
||||
{#if !info.dentroDoPrazo && info.diasAtrasado > 0}
|
||||
<span> ({info.diasAtrasado} {info.diasAtrasado === 1 ? 'dia' : 'dias'} fora do prazo)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else if info.tipo === 'andamento'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 {info.atrasado ? 'text-error' : 'text-success'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium {info.atrasado ? 'text-error' : 'text-success'}">
|
||||
{#if info.atrasado}
|
||||
{info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
|
||||
{info.horas} {info.horas === 1 ? 'hora' : 'horas'} atrasado
|
||||
{:else}
|
||||
{info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
|
||||
{info.horas} {info.horas === 1 ? 'hora' : 'horas'} para concluir
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Queries
|
||||
const setoresQuery = useQuery(api.setores.list, {});
|
||||
|
||||
// Estado do modal
|
||||
let showModal = $state(false);
|
||||
let editingSetor = $state<{
|
||||
_id: Id<'setores'>;
|
||||
nome: string;
|
||||
sigla: string;
|
||||
} | null>(null);
|
||||
|
||||
// Estado do formulário
|
||||
let nome = $state('');
|
||||
let sigla = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Modal de confirmação de exclusão
|
||||
let showDeleteModal = $state(false);
|
||||
let setorToDelete = $state<{ _id: Id<'setores'>; nome: string } | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
editingSetor = null;
|
||||
nome = '';
|
||||
sigla = '';
|
||||
error = null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(setor: { _id: Id<'setores'>; nome: string; sigla: string }) {
|
||||
editingSetor = setor;
|
||||
nome = setor.nome;
|
||||
sigla = setor.sigla;
|
||||
error = null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingSetor = null;
|
||||
nome = '';
|
||||
sigla = '';
|
||||
error = null;
|
||||
}
|
||||
|
||||
function openDeleteModal(setor: { _id: Id<'setores'>; nome: string }) {
|
||||
setorToDelete = setor;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
showDeleteModal = false;
|
||||
setorToDelete = null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!nome.trim() || !sigla.trim()) {
|
||||
error = 'Nome e sigla são obrigatórios';
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (editingSetor) {
|
||||
await client.mutation(api.setores.update, {
|
||||
id: editingSetor._id,
|
||||
nome: nome.trim(),
|
||||
sigla: sigla.trim().toUpperCase()
|
||||
});
|
||||
} else {
|
||||
await client.mutation(api.setores.create, {
|
||||
nome: nome.trim(),
|
||||
sigla: sigla.trim().toUpperCase()
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erro ao salvar setor';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!setorToDelete) return;
|
||||
|
||||
isSubmitting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.setores.remove, { id: setorToDelete._id });
|
||||
closeDeleteModal();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erro ao excluir setor';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
|
||||
<!-- Header -->
|
||||
<section
|
||||
class="border-primary/25 from-primary/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
|
||||
>
|
||||
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-3xl space-y-4">
|
||||
<span
|
||||
class="border-primary/40 bg-primary/10 text-primary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
|
||||
>
|
||||
Configurações
|
||||
</span>
|
||||
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
||||
Gestão de Setores
|
||||
</h1>
|
||||
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||
Gerencie os setores da organização. Setores são utilizados para organizar funcionários e
|
||||
definir responsabilidades em fluxos de trabalho.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<ActionGuard recurso="setores" acao="criar">
|
||||
<button class="btn btn-primary shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Setor
|
||||
</button>
|
||||
</ActionGuard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lista de Setores -->
|
||||
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
|
||||
{#if setoresQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if !setoresQuery.data || setoresQuery.data.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/30 h-16 w-16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhum setor cadastrado</h3>
|
||||
<p class="text-base-content/50 mt-2">Clique em "Novo Setor" para criar o primeiro setor.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sigla</th>
|
||||
<th>Nome</th>
|
||||
<th>Criado em</th>
|
||||
<th class="text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each setoresQuery.data as setor (setor._id)}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<span class="badge badge-primary badge-lg font-mono font-bold">
|
||||
{setor.sigla}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-medium">{setor.nome}</td>
|
||||
<td class="text-base-content/60 text-sm">{formatDate(setor.createdAt)}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<ActionGuard recurso="setores" acao="editar">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openEditModal(setor)}
|
||||
aria-label="Editar setor {setor.nome}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<ActionGuard recurso="setores" acao="excluir">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick={() => openDeleteModal(setor)}
|
||||
aria-label="Excluir setor {setor.nome}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Criação/Edição -->
|
||||
{#if showModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">
|
||||
{editingSetor ? 'Editar Setor' : 'Novo Setor'}
|
||||
</h3>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="mt-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="nome">
|
||||
<span class="label-text">Nome do Setor</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nome"
|
||||
bind:value={nome}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: Tecnologia da Informação"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="sigla">
|
||||
<span class="label-text">Sigla</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sigla"
|
||||
bind:value={sigla}
|
||||
class="input input-bordered w-full uppercase"
|
||||
placeholder="Ex: TI"
|
||||
maxlength="10"
|
||||
required
|
||||
/>
|
||||
<p class="label">
|
||||
<span class="label-text-alt text-base-content/60">Máximo 10 caracteres</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
{editingSetor ? 'Salvar' : 'Criar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Confirmação de Exclusão -->
|
||||
{#if showDeleteModal && setorToDelete}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold text-error">Confirmar Exclusão</h3>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="py-4">
|
||||
Tem certeza que deseja excluir o setor <strong>{setorToDelete.nome}</strong>?
|
||||
</p>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Esta ação não pode ser desfeita. Setores com funcionários ou passos de fluxo vinculados não
|
||||
podem ser excluídos.
|
||||
</p>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={closeDeleteModal} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-error" onclick={handleDelete} disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Excluir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeDeleteModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
433
apps/web/src/routes/(dashboard)/fluxos/+page.svelte
Normal file
433
apps/web/src/routes/(dashboard)/fluxos/+page.svelte
Normal file
@@ -0,0 +1,433 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Estado do filtro
|
||||
let statusFilter = $state<'draft' | 'published' | 'archived' | undefined>(undefined);
|
||||
|
||||
// Query de templates
|
||||
const templatesQuery = useQuery(
|
||||
api.flows.listTemplates,
|
||||
() => (statusFilter ? { status: statusFilter } : {})
|
||||
);
|
||||
|
||||
// Modal de criação
|
||||
let showCreateModal = $state(false);
|
||||
let newTemplateName = $state('');
|
||||
let newTemplateDescription = $state('');
|
||||
let isCreating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
// Modal de confirmação de exclusão
|
||||
let showDeleteModal = $state(false);
|
||||
let templateToDelete = $state<{ _id: Id<'flowTemplates'>; name: string } | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
newTemplateName = '';
|
||||
newTemplateDescription = '';
|
||||
createError = null;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
newTemplateName = '';
|
||||
newTemplateDescription = '';
|
||||
createError = null;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newTemplateName.trim()) {
|
||||
createError = 'O nome é obrigatório';
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating = true;
|
||||
createError = null;
|
||||
|
||||
try {
|
||||
const templateId = await client.mutation(api.flows.createTemplate, {
|
||||
name: newTemplateName.trim(),
|
||||
description: newTemplateDescription.trim() || undefined
|
||||
});
|
||||
closeCreateModal();
|
||||
// Navegar para o editor
|
||||
goto(`/fluxos/${templateId}/editor`);
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : 'Erro ao criar template';
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteModal(template: { _id: Id<'flowTemplates'>; name: string }) {
|
||||
templateToDelete = template;
|
||||
deleteError = null;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
showDeleteModal = false;
|
||||
templateToDelete = null;
|
||||
deleteError = null;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!templateToDelete) return;
|
||||
|
||||
isDeleting = true;
|
||||
deleteError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.deleteTemplate, { id: templateToDelete._id });
|
||||
closeDeleteModal();
|
||||
} catch (e) {
|
||||
deleteError = e instanceof Error ? e.message : 'Erro ao excluir template';
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(templateId: Id<'flowTemplates'>, newStatus: 'draft' | 'published' | 'archived') {
|
||||
try {
|
||||
await client.mutation(api.flows.updateTemplate, {
|
||||
id: templateId,
|
||||
status: newStatus
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Erro ao atualizar status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: 'draft' | 'published' | 'archived') {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return { class: 'badge-warning', label: 'Rascunho' };
|
||||
case 'published':
|
||||
return { class: 'badge-success', label: 'Publicado' };
|
||||
case 'archived':
|
||||
return { class: 'badge-neutral', label: 'Arquivado' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
|
||||
<!-- Header -->
|
||||
<section
|
||||
class="border-secondary/25 from-secondary/10 via-base-100 to-primary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
|
||||
>
|
||||
<div class="bg-secondary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-primary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-3xl space-y-4">
|
||||
<span
|
||||
class="border-secondary/40 bg-secondary/10 text-secondary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
|
||||
>
|
||||
Gestão de Fluxos
|
||||
</span>
|
||||
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
||||
Templates de Fluxo
|
||||
</h1>
|
||||
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||
Crie e gerencie templates de fluxo de trabalho. Templates definem os passos e
|
||||
responsabilidades que serão instanciados para projetos ou contratos.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<!-- Filtro de status -->
|
||||
<select
|
||||
class="select select-bordered"
|
||||
bind:value={statusFilter}
|
||||
>
|
||||
<option value={undefined}>Todos os status</option>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="published">Publicado</option>
|
||||
<option value="archived">Arquivado</option>
|
||||
</select>
|
||||
|
||||
<ActionGuard recurso="fluxos_templates" acao="criar">
|
||||
<button class="btn btn-secondary shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Template
|
||||
</button>
|
||||
</ActionGuard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lista de Templates -->
|
||||
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
|
||||
{#if templatesQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-secondary"></span>
|
||||
</div>
|
||||
{:else if !templatesQuery.data || templatesQuery.data.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/30 h-16 w-16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhum template encontrado</h3>
|
||||
<p class="text-base-content/50 mt-2">
|
||||
{statusFilter ? 'Não há templates com este status.' : 'Clique em "Novo Template" para criar o primeiro.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each templatesQuery.data as template (template._id)}
|
||||
{@const statusBadge = getStatusBadge(template.status)}
|
||||
<article
|
||||
class="card bg-base-200/50 hover:bg-base-200 border transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h2 class="card-title text-lg">{template.name}</h2>
|
||||
<span class="badge {statusBadge.class}">{statusBadge.label}</span>
|
||||
</div>
|
||||
|
||||
{#if template.description}
|
||||
<p class="text-base-content/60 text-sm line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="text-base-content/50 mt-2 flex flex-wrap gap-4 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
{template.stepsCount} passos
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{formatDate(template.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions mt-4 justify-between">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost btn-sm" aria-label="Alterar status">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow" role="menu">
|
||||
{#if template.status !== 'draft'}
|
||||
<li>
|
||||
<button onclick={() => handleStatusChange(template._id, 'draft')}>
|
||||
Voltar para Rascunho
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if template.status !== 'published'}
|
||||
<li>
|
||||
<button onclick={() => handleStatusChange(template._id, 'published')}>
|
||||
Publicar
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if template.status !== 'archived'}
|
||||
<li>
|
||||
<button onclick={() => handleStatusChange(template._id, 'archived')}>
|
||||
Arquivar
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<li class="mt-2 border-t pt-2">
|
||||
<button class="text-error" onclick={() => openDeleteModal(template)}>
|
||||
Excluir
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/fluxos/{template._id}/editor"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Editar
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Link para Instâncias -->
|
||||
<section class="flex justify-center">
|
||||
<a href="/licitacoes/fluxos" class="btn btn-outline btn-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
Ver Fluxos de Trabalho
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Criação -->
|
||||
{#if showCreateModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Novo Template de Fluxo</h3>
|
||||
|
||||
{#if createError}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{createError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="template-name">
|
||||
<span class="label-text">Nome do Template</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="template-name"
|
||||
bind:value={newTemplateName}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: Fluxo de Aprovação de Contrato"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="template-description">
|
||||
<span class="label-text">Descrição (opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="template-description"
|
||||
bind:value={newTemplateDescription}
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Descreva o propósito deste fluxo..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-secondary" disabled={isCreating}>
|
||||
{#if isCreating}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Criar e Editar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeCreateModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Confirmação de Exclusão -->
|
||||
{#if showDeleteModal && templateToDelete}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold text-error">Confirmar Exclusão</h3>
|
||||
|
||||
{#if deleteError}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{deleteError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="py-4">
|
||||
Tem certeza que deseja excluir o template <strong>{templateToDelete.name}</strong>?
|
||||
</p>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Esta ação não pode ser desfeita. Templates com instâncias vinculadas não podem ser excluídos.
|
||||
</p>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={closeDeleteModal} disabled={isDeleting}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-error" onclick={handleDelete} disabled={isDeleting}>
|
||||
{#if isDeleting}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Excluir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeDeleteModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
722
apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte
Normal file
722
apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte
Normal file
@@ -0,0 +1,722 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { resolve } from '$app/paths';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
const instanceId = $derived($page.params.id as Id<'flowInstances'>);
|
||||
|
||||
// Query da instância com passos
|
||||
const instanceQuery = useQuery(api.flows.getInstanceWithSteps, () => ({ id: instanceId }));
|
||||
|
||||
// Query de usuários (para reatribuição) - será filtrado por setor no modal
|
||||
const usuariosQuery = useQuery(api.usuarios.listar, {});
|
||||
|
||||
// Query de usuários por setor para atribuição
|
||||
let usuariosPorSetorQuery = $state<ReturnType<typeof useQuery<typeof api.flows.getUsuariosBySetorForAssignment>> | null>(null);
|
||||
|
||||
// Estado de operações
|
||||
let isProcessing = $state(false);
|
||||
let processingError = $state<string | null>(null);
|
||||
|
||||
// Modal de reatribuição
|
||||
let showReassignModal = $state(false);
|
||||
let stepToReassign = $state<{ _id: Id<'flowInstanceSteps'>; stepName: string } | null>(null);
|
||||
let newAssigneeId = $state<Id<'usuarios'> | ''>('');
|
||||
|
||||
// Modal de notas
|
||||
let showNotesModal = $state(false);
|
||||
let stepForNotes = $state<{ _id: Id<'flowInstanceSteps'>; stepName: string; notes: string } | null>(null);
|
||||
let editedNotes = $state('');
|
||||
|
||||
// Modal de upload
|
||||
let showUploadModal = $state(false);
|
||||
let stepForUpload = $state<{ _id: Id<'flowInstanceSteps'>; stepName: string } | null>(null);
|
||||
let uploadFile = $state<File | null>(null);
|
||||
let isUploading = $state(false);
|
||||
|
||||
// Modal de confirmação de cancelamento
|
||||
let showCancelModal = $state(false);
|
||||
|
||||
async function handleStartStep(stepId: Id<'flowInstanceSteps'>) {
|
||||
isProcessing = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.updateStepStatus, {
|
||||
instanceStepId: stepId,
|
||||
status: 'in_progress'
|
||||
});
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao iniciar passo';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompleteStep(stepId: Id<'flowInstanceSteps'>) {
|
||||
isProcessing = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.completeStep, {
|
||||
instanceStepId: stepId
|
||||
});
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao completar passo';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlockStep(stepId: Id<'flowInstanceSteps'>) {
|
||||
isProcessing = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.updateStepStatus, {
|
||||
instanceStepId: stepId,
|
||||
status: 'blocked'
|
||||
});
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao bloquear passo';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openReassignModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string; assignedToId?: Id<'usuarios'> }) {
|
||||
stepToReassign = step;
|
||||
newAssigneeId = step.assignedToId ?? '';
|
||||
showReassignModal = true;
|
||||
}
|
||||
|
||||
function closeReassignModal() {
|
||||
showReassignModal = false;
|
||||
stepToReassign = null;
|
||||
newAssigneeId = '';
|
||||
}
|
||||
|
||||
async function handleReassign() {
|
||||
if (!stepToReassign || !newAssigneeId) return;
|
||||
|
||||
isProcessing = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.reassignStep, {
|
||||
instanceStepId: stepToReassign._id,
|
||||
assignedToId: newAssigneeId as Id<'usuarios'>
|
||||
});
|
||||
closeReassignModal();
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao reatribuir passo';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openNotesModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string; notes?: string }) {
|
||||
stepForNotes = { ...step, notes: step.notes ?? '' };
|
||||
editedNotes = step.notes ?? '';
|
||||
showNotesModal = true;
|
||||
}
|
||||
|
||||
function closeNotesModal() {
|
||||
showNotesModal = false;
|
||||
stepForNotes = null;
|
||||
editedNotes = '';
|
||||
}
|
||||
|
||||
async function handleSaveNotes() {
|
||||
if (!stepForNotes) return;
|
||||
|
||||
isProcessing = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.updateStepNotes, {
|
||||
instanceStepId: stepForNotes._id,
|
||||
notes: editedNotes
|
||||
});
|
||||
closeNotesModal();
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao salvar notas';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openUploadModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string }) {
|
||||
stepForUpload = step;
|
||||
uploadFile = null;
|
||||
showUploadModal = true;
|
||||
}
|
||||
|
||||
function closeUploadModal() {
|
||||
showUploadModal = false;
|
||||
stepForUpload = null;
|
||||
uploadFile = null;
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
uploadFile = input.files[0];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!stepForUpload || !uploadFile) return;
|
||||
|
||||
isUploading = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
// Gerar URL de upload
|
||||
const uploadUrl = await client.mutation(api.flows.generateUploadUrl, {});
|
||||
|
||||
// Fazer upload do arquivo
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': uploadFile.type },
|
||||
body: uploadFile
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha no upload do arquivo');
|
||||
}
|
||||
|
||||
const { storageId } = await response.json();
|
||||
|
||||
// Registrar o documento
|
||||
await client.mutation(api.flows.registerDocument, {
|
||||
flowInstanceStepId: stepForUpload._id,
|
||||
storageId,
|
||||
name: uploadFile.name
|
||||
});
|
||||
|
||||
closeUploadModal();
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao fazer upload';
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDocument(documentId: Id<'flowInstanceDocuments'>) {
|
||||
isProcessing = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.deleteDocument, { id: documentId });
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao excluir documento';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelInstance() {
|
||||
isProcessing = true;
|
||||
processingError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.cancelInstance, { id: instanceId });
|
||||
showCancelModal = false;
|
||||
} catch (e) {
|
||||
processingError = e instanceof Error ? e.message : 'Erro ao cancelar instância';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: 'pending' | 'in_progress' | 'completed' | 'blocked') {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { class: 'badge-ghost', label: 'Pendente', icon: 'clock' };
|
||||
case 'in_progress':
|
||||
return { class: 'badge-info', label: 'Em Progresso', icon: 'play' };
|
||||
case 'completed':
|
||||
return { class: 'badge-success', label: 'Concluído', icon: 'check' };
|
||||
case 'blocked':
|
||||
return { class: 'badge-error', label: 'Bloqueado', icon: 'x' };
|
||||
}
|
||||
}
|
||||
|
||||
function getInstanceStatusBadge(status: 'active' | 'completed' | 'cancelled') {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return { class: 'badge-info', label: 'Em Andamento' };
|
||||
case 'completed':
|
||||
return { class: 'badge-success', label: 'Concluído' };
|
||||
case 'cancelled':
|
||||
return { class: 'badge-error', label: 'Cancelado' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number | undefined): string {
|
||||
if (!timestamp) return '-';
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function isStepCurrent(stepId: Id<'flowInstanceSteps'>): boolean {
|
||||
return instanceQuery.data?.instance.currentStepId === stepId;
|
||||
}
|
||||
|
||||
function isOverdue(dueDate: number | undefined): boolean {
|
||||
if (!dueDate) return false;
|
||||
return Date.now() > dueDate;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
|
||||
{#if instanceQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-24">
|
||||
<span class="loading loading-spinner loading-lg text-info"></span>
|
||||
</div>
|
||||
{:else if !instanceQuery.data}
|
||||
<div class="flex flex-col items-center justify-center py-24 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="text-base-content/30 h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Fluxo não encontrado</h3>
|
||||
<a href={resolve('/(dashboard)/licitacoes/fluxos')} class="btn btn-ghost mt-4">Voltar para lista</a>
|
||||
</div>
|
||||
{:else}
|
||||
{@const instance = instanceQuery.data.instance}
|
||||
{@const steps = instanceQuery.data.steps}
|
||||
{@const statusBadge = getInstanceStatusBadge(instance.status)}
|
||||
|
||||
<!-- Header -->
|
||||
<section
|
||||
class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
|
||||
>
|
||||
<div class="bg-info/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href={resolve('/(dashboard)/licitacoes/fluxos')} class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Voltar
|
||||
</a>
|
||||
<span class="badge {statusBadge.class} badge-lg">{statusBadge.label}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="max-w-3xl space-y-4">
|
||||
<h1 class="text-base-content text-3xl leading-tight font-black sm:text-4xl">
|
||||
{instance.templateName ?? 'Fluxo'}
|
||||
</h1>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#if instance.contratoId}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-outline">Contrato</span>
|
||||
<span class="text-base-content/70 font-medium">{instance.contratoId}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Gerente: {instance.managerName ?? '-'}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Iniciado: {formatDate(instance.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if instance.status === 'active'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="cancelar">
|
||||
<button class="btn btn-error btn-outline" onclick={() => showCancelModal = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Cancelar Fluxo
|
||||
</button>
|
||||
</ActionGuard>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Erro global -->
|
||||
{#if processingError}
|
||||
<div class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{processingError}</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => processingError = null}>Fechar</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Timeline de Passos -->
|
||||
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
|
||||
<h2 class="mb-6 text-xl font-bold">Timeline do Fluxo</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#each steps as step, index (step._id)}
|
||||
{@const stepStatus = getStatusBadge(step.status)}
|
||||
{@const isCurrent = isStepCurrent(step._id)}
|
||||
{@const overdue = step.status !== 'completed' && isOverdue(step.dueDate)}
|
||||
|
||||
<div class="relative flex gap-6 {index < steps.length - 1 ? 'pb-6' : ''}">
|
||||
<!-- Linha conectora -->
|
||||
{#if index < steps.length - 1}
|
||||
<div class="absolute left-5 top-10 bottom-0 w-0.5 {step.status === 'completed' ? 'bg-success' : 'bg-base-300'}"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Indicador de status -->
|
||||
<div class="z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full {step.status === 'completed' ? 'bg-success text-success-content' : isCurrent ? 'bg-info text-info-content' : step.status === 'blocked' ? 'bg-error text-error-content' : 'bg-base-300 text-base-content'}">
|
||||
{#if step.status === 'completed'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else if step.status === 'blocked'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="text-sm font-bold">{index + 1}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo do passo -->
|
||||
<div class="flex-1 rounded-xl border {isCurrent ? 'border-info bg-info/5' : 'bg-base-200/50'} p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold">{step.stepName}</h3>
|
||||
<span class="badge {stepStatus.class} badge-sm">{stepStatus.label}</span>
|
||||
{#if overdue}
|
||||
<span class="badge badge-warning badge-sm">Atrasado</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if step.stepDescription}
|
||||
<p class="text-base-content/60 mt-1 text-sm">{step.stepDescription}</p>
|
||||
{/if}
|
||||
<div class="text-base-content/50 mt-2 flex flex-wrap gap-4 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5" />
|
||||
</svg>
|
||||
{step.setorNome ?? 'Setor não definido'}
|
||||
</span>
|
||||
{#if step.assignedToName}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{step.assignedToName}
|
||||
</span>
|
||||
{/if}
|
||||
{#if step.dueDate}
|
||||
<span class="flex items-center gap-1 {overdue ? 'text-warning' : ''}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Prazo: {formatDate(step.dueDate)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações do passo -->
|
||||
{#if instance.status === 'active'}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if step.status === 'pending'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Iniciar
|
||||
</button>
|
||||
</ActionGuard>
|
||||
{:else if step.status === 'in_progress'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-success btn-sm"
|
||||
onclick={() => handleCompleteStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Concluir
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
onclick={() => handleBlockStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Bloquear
|
||||
</button>
|
||||
</ActionGuard>
|
||||
{:else if step.status === 'blocked'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Desbloquear
|
||||
</button>
|
||||
</ActionGuard>
|
||||
{/if}
|
||||
|
||||
<ActionGuard recurso="fluxos_instancias" acao="atribuir_usuario">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openReassignModal(step)}
|
||||
aria-label="Reatribuir responsável"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openNotesModal(step)}
|
||||
aria-label="Editar notas"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ActionGuard recurso="fluxos_documentos" acao="upload">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openUploadModal(step)}
|
||||
aria-label="Upload de documento"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notas -->
|
||||
{#if step.notes}
|
||||
<div class="bg-base-300/50 mt-4 rounded-lg p-3">
|
||||
<p class="text-base-content/70 text-sm whitespace-pre-wrap">{step.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Documentos -->
|
||||
{#if step.documents && step.documents.length > 0}
|
||||
<div class="mt-4">
|
||||
<h4 class="text-base-content/70 mb-2 text-xs font-semibold uppercase">Documentos</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each step.documents as doc (doc._id)}
|
||||
<div class="badge badge-outline gap-2 py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{doc.name}
|
||||
<ActionGuard recurso="fluxos_documentos" acao="excluir">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => handleDeleteDocument(doc._id)}
|
||||
aria-label="Excluir documento {doc.name}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</ActionGuard>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Datas de início/fim -->
|
||||
{#if step.startedAt || step.finishedAt}
|
||||
<div class="text-base-content/40 mt-4 flex gap-4 text-xs">
|
||||
{#if step.startedAt}
|
||||
<span>Iniciado: {formatDate(step.startedAt)}</span>
|
||||
{/if}
|
||||
{#if step.finishedAt}
|
||||
<span>Concluído: {formatDate(step.finishedAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Modal de Reatribuição -->
|
||||
{#if showReassignModal && stepToReassign}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Reatribuir Responsável</h3>
|
||||
<p class="text-base-content/60 mt-2">
|
||||
Selecione o novo responsável pelo passo <strong>{stepToReassign.stepName}</strong>
|
||||
</p>
|
||||
|
||||
<div class="form-control mt-4">
|
||||
<label class="label" for="assignee-select">
|
||||
<span class="label-text">Responsável</span>
|
||||
</label>
|
||||
<select
|
||||
id="assignee-select"
|
||||
bind:value={newAssigneeId}
|
||||
class="select select-bordered w-full"
|
||||
>
|
||||
<option value="">Selecione um usuário</option>
|
||||
{#if usuariosQuery.data}
|
||||
{#each usuariosQuery.data as usuario (usuario._id)}
|
||||
<option value={usuario._id}>{usuario.nome}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={closeReassignModal} disabled={isProcessing}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={handleReassign} disabled={isProcessing || !newAssigneeId}>
|
||||
{#if isProcessing}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Reatribuir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeReassignModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Notas -->
|
||||
{#if showNotesModal && stepForNotes}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Notas do Passo</h3>
|
||||
<p class="text-base-content/60 mt-2">
|
||||
Adicione ou edite notas para o passo <strong>{stepForNotes.stepName}</strong>
|
||||
</p>
|
||||
|
||||
<div class="form-control mt-4">
|
||||
<label class="label" for="notes-textarea">
|
||||
<span class="label-text">Notas</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="notes-textarea"
|
||||
bind:value={editedNotes}
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="5"
|
||||
placeholder="Adicione observações, comentários ou informações relevantes..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={closeNotesModal} disabled={isProcessing}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={handleSaveNotes} disabled={isProcessing}>
|
||||
{#if isProcessing}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeNotesModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Upload -->
|
||||
{#if showUploadModal && stepForUpload}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Upload de Documento</h3>
|
||||
<p class="text-base-content/60 mt-2">
|
||||
Anexe um documento ao passo <strong>{stepForUpload.stepName}</strong>
|
||||
</p>
|
||||
|
||||
<div class="form-control mt-4">
|
||||
<label class="label" for="file-input">
|
||||
<span class="label-text">Arquivo</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="file-input"
|
||||
class="file-input file-input-bordered w-full"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if uploadFile}
|
||||
<p class="text-base-content/60 mt-2 text-sm">
|
||||
Arquivo selecionado: <strong>{uploadFile.name}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={closeUploadModal} disabled={isUploading}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={handleUpload} disabled={isUploading || !uploadFile}>
|
||||
{#if isUploading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Enviar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeUploadModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Cancelamento -->
|
||||
{#if showCancelModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold text-error">Cancelar Fluxo</h3>
|
||||
<p class="py-4">
|
||||
Tem certeza que deseja cancelar este fluxo?
|
||||
</p>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Esta ação não pode ser desfeita. Todos os passos pendentes serão marcados como cancelados.
|
||||
</p>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={() => showCancelModal = false} disabled={isProcessing}>
|
||||
Voltar
|
||||
</button>
|
||||
<button class="btn btn-error" onclick={handleCancelInstance} disabled={isProcessing}>
|
||||
{#if isProcessing}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Cancelar Fluxo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={() => showCancelModal = false} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
800
apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
Normal file
800
apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
Normal file
@@ -0,0 +1,800 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { resolve } from '$app/paths';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
const client = useConvexClient();
|
||||
const templateId = $derived($page.params.id as Id<'flowTemplates'>);
|
||||
|
||||
// Queries
|
||||
const templateQuery = useQuery(api.flows.getTemplate, () => ({ id: templateId }));
|
||||
const stepsQuery = useQuery(api.flows.listStepsByTemplate, () => ({ flowTemplateId: templateId }));
|
||||
const setoresQuery = useQuery(api.setores.list, {});
|
||||
|
||||
// Query de sub-etapas (reativa baseada no step selecionado)
|
||||
const subEtapasQuery = useQuery(
|
||||
api.flows.listarSubEtapas,
|
||||
() => selectedStepId ? { flowStepId: selectedStepId } : 'skip'
|
||||
);
|
||||
|
||||
// Estado local para drag and drop
|
||||
let localSteps = $state<NonNullable<typeof stepsQuery.data>>([]);
|
||||
let isDragging = $state(false);
|
||||
|
||||
// Sincronizar com query
|
||||
$effect(() => {
|
||||
if (stepsQuery.data && !isDragging) {
|
||||
localSteps = [...stepsQuery.data];
|
||||
}
|
||||
});
|
||||
|
||||
// Estado do passo selecionado
|
||||
let selectedStepId = $state<Id<'flowSteps'> | null>(null);
|
||||
const selectedStep = $derived(localSteps?.find((s) => s._id === selectedStepId));
|
||||
|
||||
// Modal de novo passo
|
||||
let showNewStepModal = $state(false);
|
||||
let newStepName = $state('');
|
||||
let newStepDescription = $state('');
|
||||
let newStepDuration = $state(1);
|
||||
let newStepSetorId = $state<Id<'setores'> | ''>('');
|
||||
let isCreatingStep = $state(false);
|
||||
let stepError = $state<string | null>(null);
|
||||
|
||||
// Estado de edição
|
||||
let editingStep = $state<{
|
||||
name: string;
|
||||
description: string;
|
||||
expectedDuration: number;
|
||||
setorId: Id<'setores'>;
|
||||
requiredDocuments: string[];
|
||||
} | null>(null);
|
||||
let isSavingStep = $state(false);
|
||||
|
||||
// Estado de sub-etapas
|
||||
let showSubEtapaModal = $state(false);
|
||||
let subEtapaNome = $state('');
|
||||
let subEtapaDescricao = $state('');
|
||||
let isCriandoSubEtapa = $state(false);
|
||||
let subEtapaError = $state<string | null>(null);
|
||||
|
||||
// Inicializar edição quando selecionar passo
|
||||
$effect(() => {
|
||||
if (selectedStep) {
|
||||
editingStep = {
|
||||
name: selectedStep.name,
|
||||
description: selectedStep.description ?? '',
|
||||
expectedDuration: selectedStep.expectedDuration,
|
||||
setorId: selectedStep.setorId,
|
||||
requiredDocuments: selectedStep.requiredDocuments ?? []
|
||||
};
|
||||
} else {
|
||||
editingStep = null;
|
||||
}
|
||||
});
|
||||
|
||||
function openNewStepModal() {
|
||||
newStepName = '';
|
||||
newStepDescription = '';
|
||||
newStepDuration = 1;
|
||||
newStepSetorId = setoresQuery.data?.[0]?._id ?? '';
|
||||
stepError = null;
|
||||
showNewStepModal = true;
|
||||
}
|
||||
|
||||
function closeNewStepModal() {
|
||||
showNewStepModal = false;
|
||||
}
|
||||
|
||||
async function handleCreateStep() {
|
||||
if (!newStepName.trim()) {
|
||||
stepError = 'O nome é obrigatório';
|
||||
return;
|
||||
}
|
||||
if (!newStepSetorId) {
|
||||
stepError = 'Selecione um setor';
|
||||
return;
|
||||
}
|
||||
|
||||
isCreatingStep = true;
|
||||
stepError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.createStep, {
|
||||
flowTemplateId: templateId,
|
||||
name: newStepName.trim(),
|
||||
description: newStepDescription.trim() || undefined,
|
||||
expectedDuration: newStepDuration,
|
||||
setorId: newStepSetorId as Id<'setores'>
|
||||
});
|
||||
closeNewStepModal();
|
||||
} catch (e) {
|
||||
stepError = e instanceof Error ? e.message : 'Erro ao criar passo';
|
||||
} finally {
|
||||
isCreatingStep = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveStep() {
|
||||
if (!selectedStepId || !editingStep) return;
|
||||
|
||||
isSavingStep = true;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.updateStep, {
|
||||
id: selectedStepId,
|
||||
name: editingStep.name,
|
||||
description: editingStep.description || undefined,
|
||||
expectedDuration: editingStep.expectedDuration,
|
||||
setorId: editingStep.setorId,
|
||||
requiredDocuments: editingStep.requiredDocuments.length > 0 ? editingStep.requiredDocuments : undefined
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Erro ao salvar passo:', e);
|
||||
} finally {
|
||||
isSavingStep = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteStep() {
|
||||
if (!selectedStepId) return;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.deleteStep, { id: selectedStepId });
|
||||
selectedStepId = null;
|
||||
} catch (e) {
|
||||
console.error('Erro ao excluir passo:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveStepUp(index: number) {
|
||||
if (index === 0 || !localSteps) return;
|
||||
|
||||
const previousSteps = [...localSteps];
|
||||
const newSteps = [...localSteps];
|
||||
[newSteps[index - 1], newSteps[index]] = [newSteps[index], newSteps[index - 1]];
|
||||
localSteps = newSteps;
|
||||
isDragging = true;
|
||||
|
||||
const stepIds = newSteps.map((s) => s._id);
|
||||
try {
|
||||
await client.mutation(api.flows.reorderSteps, {
|
||||
flowTemplateId: templateId,
|
||||
stepIds
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao reordenar passos:', err);
|
||||
// Reverter em caso de erro
|
||||
localSteps = previousSteps;
|
||||
} finally {
|
||||
isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveStepDown(index: number) {
|
||||
if (!localSteps || index === localSteps.length - 1) return;
|
||||
|
||||
const previousSteps = [...localSteps];
|
||||
const newSteps = [...localSteps];
|
||||
[newSteps[index], newSteps[index + 1]] = [newSteps[index + 1], newSteps[index]];
|
||||
localSteps = newSteps;
|
||||
isDragging = true;
|
||||
|
||||
const stepIds = newSteps.map((s) => s._id);
|
||||
try {
|
||||
await client.mutation(api.flows.reorderSteps, {
|
||||
flowTemplateId: templateId,
|
||||
stepIds
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao reordenar passos:', err);
|
||||
// Reverter em caso de erro
|
||||
localSteps = previousSteps;
|
||||
} finally {
|
||||
isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
try {
|
||||
await client.mutation(api.flows.updateTemplate, {
|
||||
id: templateId,
|
||||
status: 'published'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Erro ao publicar:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function addRequiredDocument() {
|
||||
if (editingStep) {
|
||||
editingStep.requiredDocuments = [...editingStep.requiredDocuments, ''];
|
||||
}
|
||||
}
|
||||
|
||||
function removeRequiredDocument(index: number) {
|
||||
if (editingStep) {
|
||||
editingStep.requiredDocuments = editingStep.requiredDocuments.filter((_, i) => i !== index);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRequiredDocument(index: number, value: string) {
|
||||
if (editingStep) {
|
||||
editingStep.requiredDocuments = editingStep.requiredDocuments.map((doc, i) =>
|
||||
i === index ? value : doc
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Funções de sub-etapas
|
||||
function openSubEtapaModal() {
|
||||
subEtapaNome = '';
|
||||
subEtapaDescricao = '';
|
||||
subEtapaError = null;
|
||||
showSubEtapaModal = true;
|
||||
}
|
||||
|
||||
function closeSubEtapaModal() {
|
||||
showSubEtapaModal = false;
|
||||
subEtapaNome = '';
|
||||
subEtapaDescricao = '';
|
||||
subEtapaError = null;
|
||||
}
|
||||
|
||||
async function handleCriarSubEtapa() {
|
||||
if (!selectedStepId || !subEtapaNome.trim()) {
|
||||
subEtapaError = 'O nome é obrigatório';
|
||||
return;
|
||||
}
|
||||
|
||||
isCriandoSubEtapa = true;
|
||||
subEtapaError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.criarSubEtapa, {
|
||||
flowStepId: selectedStepId,
|
||||
name: subEtapaNome.trim(),
|
||||
description: subEtapaDescricao.trim() || undefined
|
||||
});
|
||||
closeSubEtapaModal();
|
||||
} catch (e) {
|
||||
subEtapaError = e instanceof Error ? e.message : 'Erro ao criar sub-etapa';
|
||||
} finally {
|
||||
isCriandoSubEtapa = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletarSubEtapa(subEtapaId: Id<'flowSubSteps'>) {
|
||||
if (!confirm('Tem certeza que deseja excluir esta sub-etapa?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.mutation(api.flows.deletarSubEtapa, { subEtapaId });
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Erro ao deletar sub-etapa');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAtualizarStatusSubEtapa(subEtapaId: Id<'flowSubSteps'>, novoStatus: 'pending' | 'in_progress' | 'completed' | 'blocked') {
|
||||
try {
|
||||
await client.mutation(api.flows.atualizarSubEtapa, {
|
||||
subEtapaId,
|
||||
status: novoStatus
|
||||
});
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Erro ao atualizar status');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<!-- Header -->
|
||||
<header class="bg-base-100 border-b px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href={resolve('/(dashboard)/fluxos')} class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Voltar
|
||||
</a>
|
||||
<div>
|
||||
{#if templateQuery.isLoading}
|
||||
<div class="h-6 w-48 animate-pulse rounded bg-base-300"></div>
|
||||
{:else if templateQuery.data}
|
||||
<h1 class="text-xl font-bold">{templateQuery.data.name}</h1>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
{templateQuery.data.description ?? 'Sem descrição'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if templateQuery.data?.status === 'draft'}
|
||||
<button
|
||||
class="btn btn-success btn-sm"
|
||||
onclick={handlePublish}
|
||||
disabled={!localSteps || localSteps.length === 0}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Publicar
|
||||
</button>
|
||||
{:else if templateQuery.data?.status === 'published'}
|
||||
<span class="badge badge-success">Publicado</span>
|
||||
{:else if templateQuery.data?.status === 'archived'}
|
||||
<span class="badge badge-neutral">Arquivado</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Conteúdo Principal -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Lista de Passos (Kanban) -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Passos do Fluxo</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick={openNewStepModal}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Novo Passo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if stepsQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-secondary"></span>
|
||||
</div>
|
||||
{:else if !localSteps || localSteps.length === 0}
|
||||
<div class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed py-12 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="text-base-content/30 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p class="text-base-content/60 mt-4">Nenhum passo definido</p>
|
||||
<p class="text-base-content/40 text-sm">Clique em "Novo Passo" para adicionar o primeiro passo</p>
|
||||
</div>
|
||||
{:else if localSteps && localSteps.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each localSteps as step, index (step._id)}
|
||||
<div
|
||||
class="card w-full border text-left transition-all duration-200 {selectedStepId === step._id ? 'border-secondary bg-secondary/10 ring-2 ring-secondary' : 'bg-base-100 hover:bg-base-200'}"
|
||||
animate:flip={{ duration: 200 }}
|
||||
>
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="bg-secondary/20 text-secondary flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
class="min-w-0 flex-1 cursor-pointer"
|
||||
onclick={() => selectedStepId = step._id}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectedStepId = step._id;
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<h3 class="font-semibold">{step.name}</h3>
|
||||
{#if step.description}
|
||||
<p class="text-base-content/60 mt-1 truncate text-sm">{step.description}</p>
|
||||
{/if}
|
||||
<div class="text-base-content/50 mt-2 flex flex-wrap gap-3 text-xs">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5" />
|
||||
</svg>
|
||||
{step.setorNome ?? 'Setor não definido'}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{step.expectedDuration} dia{step.expectedDuration > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => moveStepUp(index)}
|
||||
disabled={index === 0 || isDragging}
|
||||
aria-label="Mover passo para cima"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => moveStepDown(index)}
|
||||
disabled={index === localSteps.length - 1 || isDragging}
|
||||
aria-label="Mover passo para baixo"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar de Edição -->
|
||||
<aside class="bg-base-200 w-96 shrink-0 overflow-auto border-l p-6">
|
||||
{#if selectedStep && editingStep}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Editar Passo</h3>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => selectedStepId = null}
|
||||
aria-label="Fechar edição"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="step-name">
|
||||
<span class="label-text">Nome</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="step-name"
|
||||
bind:value={editingStep.name}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="step-description">
|
||||
<span class="label-text">Descrição</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="step-description"
|
||||
bind:value={editingStep.description}
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="step-duration">
|
||||
<span class="label-text">Duração Esperada (dias)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="step-duration"
|
||||
bind:value={editingStep.expectedDuration}
|
||||
class="input input-bordered w-full"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="step-setor">
|
||||
<span class="label-text">Setor Responsável</span>
|
||||
</label>
|
||||
<select
|
||||
id="step-setor"
|
||||
bind:value={editingStep.setorId}
|
||||
class="select select-bordered w-full"
|
||||
>
|
||||
{#if setoresQuery.data}
|
||||
{#each setoresQuery.data as setor (setor._id)}
|
||||
<option value={setor._id}>{setor.nome} ({setor.sigla})</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<span class="label">
|
||||
<span class="label-text">Documentos Obrigatórios</span>
|
||||
</span>
|
||||
<div class="space-y-2">
|
||||
{#each editingStep.requiredDocuments as doc, index (index)}
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={doc}
|
||||
oninput={(e) => updateRequiredDocument(index, e.currentTarget.value)}
|
||||
class="input input-bordered input-sm flex-1"
|
||||
placeholder="Nome do documento"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick={() => removeRequiredDocument(index)}
|
||||
aria-label="Remover documento"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm w-full"
|
||||
onclick={addRequiredDocument}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Adicionar Documento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-etapas -->
|
||||
<div class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-semibold">Sub-etapas</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={openSubEtapaModal}
|
||||
aria-label="Adicionar sub-etapa"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#if subEtapasQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
{:else if subEtapasQuery.data && subEtapasQuery.data.length > 0}
|
||||
{#each subEtapasQuery.data as subEtapa (subEtapa._id)}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{subEtapa.name}</div>
|
||||
{#if subEtapa.description}
|
||||
<div class="text-base-content/60 text-xs">{subEtapa.description}</div>
|
||||
{/if}
|
||||
<div class="mt-1">
|
||||
<span class="badge badge-xs {subEtapa.status === 'completed' ? 'badge-success' : subEtapa.status === 'in_progress' ? 'badge-info' : subEtapa.status === 'blocked' ? 'badge-error' : 'badge-ghost'}">
|
||||
{subEtapa.status === 'completed' ? 'Concluída' : subEtapa.status === 'in_progress' ? 'Em Andamento' : subEtapa.status === 'blocked' ? 'Bloqueada' : 'Pendente'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<select
|
||||
class="select select-xs select-bordered"
|
||||
value={subEtapa.status}
|
||||
onchange={(e) => handleAtualizarStatusSubEtapa(subEtapa._id, e.currentTarget.value as 'pending' | 'in_progress' | 'completed' | 'blocked')}
|
||||
>
|
||||
<option value="pending">Pendente</option>
|
||||
<option value="in_progress">Em Andamento</option>
|
||||
<option value="completed">Concluída</option>
|
||||
<option value="blocked">Bloqueada</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => handleDeletarSubEtapa(subEtapa._id)}
|
||||
aria-label="Deletar sub-etapa"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-base-content/40 rounded-lg border border-dashed border-base-300 bg-base-200/50 p-4 text-center text-sm">
|
||||
Nenhuma sub-etapa adicionada
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button
|
||||
class="btn btn-error btn-outline flex-1"
|
||||
onclick={handleDeleteStep}
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary flex-1"
|
||||
onclick={handleSaveStep}
|
||||
disabled={isSavingStep}
|
||||
>
|
||||
{#if isSavingStep}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="text-base-content/30 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
|
||||
</svg>
|
||||
<p class="text-base-content/60 mt-4">Selecione um passo</p>
|
||||
<p class="text-base-content/40 text-sm">Clique em um passo para editar seus detalhes</p>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Novo Passo -->
|
||||
{#if showNewStepModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Novo Passo</h3>
|
||||
|
||||
{#if stepError}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{stepError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleCreateStep(); }} class="mt-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="new-step-name">
|
||||
<span class="label-text">Nome do Passo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-step-name"
|
||||
bind:value={newStepName}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: Análise Jurídica"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="new-step-description">
|
||||
<span class="label-text">Descrição (opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="new-step-description"
|
||||
bind:value={newStepDescription}
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Descreva o que deve ser feito neste passo..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="new-step-duration">
|
||||
<span class="label-text">Duração Esperada (dias)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="new-step-duration"
|
||||
bind:value={newStepDuration}
|
||||
class="input input-bordered w-full"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="new-step-setor">
|
||||
<span class="label-text">Setor Responsável</span>
|
||||
</label>
|
||||
<select
|
||||
id="new-step-setor"
|
||||
bind:value={newStepSetorId}
|
||||
class="select select-bordered w-full"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um setor</option>
|
||||
{#if setoresQuery.data}
|
||||
{#each setoresQuery.data as setor (setor._id)}
|
||||
<option value={setor._id}>{setor.nome} ({setor.sigla})</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeNewStepModal} disabled={isCreatingStep}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-secondary" disabled={isCreatingStep}>
|
||||
{#if isCreatingStep}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Criar Passo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeNewStepModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Nova Sub-etapa -->
|
||||
{#if showSubEtapaModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Nova Sub-etapa</h3>
|
||||
|
||||
{#if subEtapaError}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{subEtapaError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleCriarSubEtapa(); }} class="mt-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="sub-etapa-nome">
|
||||
<span class="label-text">Nome da Sub-etapa</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sub-etapa-nome"
|
||||
bind:value={subEtapaNome}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: Revisar documentação"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="sub-etapa-descricao">
|
||||
<span class="label-text">Descrição (opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="sub-etapa-descricao"
|
||||
bind:value={subEtapaDescricao}
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Descreva a sub-etapa..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeSubEtapaModal} disabled={isCriandoSubEtapa}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-secondary" disabled={isCriandoSubEtapa}>
|
||||
{#if isCriandoSubEtapa}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Criar Sub-etapa
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeSubEtapaModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
373
apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte
Normal file
373
apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte
Normal file
@@ -0,0 +1,373 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Estado dos filtros
|
||||
let statusFilter = $state<'active' | 'completed' | 'cancelled' | undefined>(undefined);
|
||||
|
||||
// Query de instâncias
|
||||
const instancesQuery = useQuery(
|
||||
api.flows.listInstances,
|
||||
() => (statusFilter ? { status: statusFilter } : {})
|
||||
);
|
||||
|
||||
// Query de templates publicados (para o modal de criação)
|
||||
const publishedTemplatesQuery = useQuery(api.flows.listTemplates, { status: 'published' });
|
||||
|
||||
// Modal de criação
|
||||
let showCreateModal = $state(false);
|
||||
let selectedTemplateId = $state<Id<'flowTemplates'> | ''>('');
|
||||
let targetType = $state('');
|
||||
let targetId = $state('');
|
||||
let managerId = $state<Id<'usuarios'> | ''>('');
|
||||
let isCreating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
// Query de usuários (para seleção de gerente)
|
||||
const usuariosQuery = useQuery(api.usuarios.listar, {});
|
||||
|
||||
function openCreateModal() {
|
||||
selectedTemplateId = '';
|
||||
targetType = '';
|
||||
targetId = '';
|
||||
managerId = '';
|
||||
createError = null;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!selectedTemplateId || !targetType.trim() || !targetId.trim() || !managerId) {
|
||||
createError = 'Todos os campos são obrigatórios';
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating = true;
|
||||
createError = null;
|
||||
|
||||
try {
|
||||
const instanceId = await client.mutation(api.flows.instantiateFlow, {
|
||||
flowTemplateId: selectedTemplateId as Id<'flowTemplates'>,
|
||||
targetType: targetType.trim(),
|
||||
targetId: targetId.trim(),
|
||||
managerId: managerId as Id<'usuarios'>
|
||||
});
|
||||
closeCreateModal();
|
||||
goto(`/licitacoes/fluxos/${instanceId}`);
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : 'Erro ao criar instância';
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: 'active' | 'completed' | 'cancelled') {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return { class: 'badge-info', label: 'Em Andamento' };
|
||||
case 'completed':
|
||||
return { class: 'badge-success', label: 'Concluído' };
|
||||
case 'cancelled':
|
||||
return { class: 'badge-error', label: 'Cancelado' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getProgressPercentage(completed: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return Math.round((completed / total) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
|
||||
<!-- Header -->
|
||||
<section
|
||||
class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
|
||||
>
|
||||
<div class="bg-info/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-3xl space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/fluxos" class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Templates
|
||||
</a>
|
||||
<span
|
||||
class="border-info/40 bg-info/10 text-info inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
|
||||
>
|
||||
Execução
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
||||
Instâncias de Fluxo
|
||||
</h1>
|
||||
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||
Acompanhe e gerencie as execuções de fluxos de trabalho. Visualize o progresso,
|
||||
documentos e responsáveis de cada etapa.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<!-- Filtro de status -->
|
||||
<select
|
||||
class="select select-bordered"
|
||||
bind:value={statusFilter}
|
||||
>
|
||||
<option value={undefined}>Todos os status</option>
|
||||
<option value="active">Em Andamento</option>
|
||||
<option value="completed">Concluído</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
|
||||
<ActionGuard recurso="fluxos_instancias" acao="criar">
|
||||
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Nova Instância
|
||||
</button>
|
||||
</ActionGuard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lista de Instâncias -->
|
||||
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
|
||||
{#if instancesQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-info"></span>
|
||||
</div>
|
||||
{:else if !instancesQuery.data || instancesQuery.data.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/30 h-16 w-16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhuma instância encontrada</h3>
|
||||
<p class="text-base-content/50 mt-2">
|
||||
{statusFilter ? 'Não há instâncias com este status.' : 'Clique em "Nova Instância" para iniciar um fluxo.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>Alvo</th>
|
||||
<th>Gerente</th>
|
||||
<th>Progresso</th>
|
||||
<th>Status</th>
|
||||
<th>Iniciado em</th>
|
||||
<th class="text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each instancesQuery.data as instance (instance._id)}
|
||||
{@const statusBadge = getStatusBadge(instance.status)}
|
||||
{@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="font-medium">{instance.templateName ?? 'Template desconhecido'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm">
|
||||
<span class="badge badge-outline badge-sm">{instance.targetType}</span>
|
||||
<span class="text-base-content/60 ml-1">{instance.targetId}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-base-content/70">{instance.managerName ?? '-'}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-info w-20"
|
||||
value={progressPercent}
|
||||
max="100"
|
||||
></progress>
|
||||
<span class="text-xs text-base-content/60">
|
||||
{instance.progress.completed}/{instance.progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {statusBadge.class}">{statusBadge.label}</span>
|
||||
</td>
|
||||
<td class="text-base-content/60 text-sm">{formatDate(instance.startedAt)}</td>
|
||||
<td class="text-right">
|
||||
<a
|
||||
href="/licitacoes/fluxos/{instance._id}"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Ver
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Criação -->
|
||||
{#if showCreateModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">Nova Instância de Fluxo</h3>
|
||||
|
||||
{#if createError}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{createError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="template-select">
|
||||
<span class="label-text">Template de Fluxo</span>
|
||||
</label>
|
||||
<select
|
||||
id="template-select"
|
||||
bind:value={selectedTemplateId}
|
||||
class="select select-bordered w-full"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um template</option>
|
||||
{#if publishedTemplatesQuery.data}
|
||||
{#each publishedTemplatesQuery.data as template (template._id)}
|
||||
<option value={template._id}>{template.name}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<p class="label">
|
||||
<span class="label-text-alt text-base-content/60">Apenas templates publicados podem ser instanciados</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="target-type">
|
||||
<span class="label-text">Tipo do Alvo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="target-type"
|
||||
bind:value={targetType}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: contrato, projeto"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="target-id">
|
||||
<span class="label-text">Identificador do Alvo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="target-id"
|
||||
bind:value={targetId}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: CT-2024-001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="manager-select">
|
||||
<span class="label-text">Gerente Responsável</span>
|
||||
</label>
|
||||
<select
|
||||
id="manager-select"
|
||||
bind:value={managerId}
|
||||
class="select select-bordered w-full"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um gerente</option>
|
||||
{#if usuariosQuery.data}
|
||||
{#each usuariosQuery.data as usuario (usuario._id)}
|
||||
<option value={usuario._id}>{usuario.nome}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-info" disabled={isCreating}>
|
||||
{#if isCreating}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Iniciar Fluxo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeCreateModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { FileText, ClipboardCopy, Building2 } from 'lucide-svelte';
|
||||
import { FileText, ClipboardCopy, Building2, Workflow, ChevronRight } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
||||
import type { Component } from 'svelte';
|
||||
</script>
|
||||
|
||||
<ProtectedRoute>
|
||||
@@ -16,7 +17,16 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- Cabeçalho -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-primary mb-2">Licitações</h1>
|
||||
<p class="text-lg text-base-content/70">
|
||||
Gerencie empresas, contratos e processos licitatórios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cards Principais -->
|
||||
<div class="grid gap-4 md:grid-cols-3 mb-8">
|
||||
<a
|
||||
href={resolve('/licitacoes/empresas')}
|
||||
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
|
||||
@@ -75,5 +85,86 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção Fluxos -->
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||
<div class="card-body">
|
||||
<!-- Cabeçalho da Categoria -->
|
||||
<div class="flex items-start gap-6 mb-6">
|
||||
<div class="p-4 bg-secondary/20 rounded-2xl">
|
||||
<Workflow class="h-12 w-12 text-secondary" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="card-title text-2xl mb-2 text-secondary">
|
||||
Fluxos de Trabalho
|
||||
</h2>
|
||||
<p class="text-base-content/70">Gerencie templates e fluxos de trabalho para contratos e processos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid de Opções -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a
|
||||
href={resolve('/licitacoes/fluxos')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-secondary/10 to-secondary/20 p-6 hover:border-secondary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-secondary group-hover:text-white transition-colors duration-300"
|
||||
>
|
||||
<Workflow
|
||||
class="h-5 w-5 text-secondary group-hover:text-white transition-colors duration-300"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<ChevronRight
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-secondary transition-colors duration-300"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-secondary transition-colors duration-300"
|
||||
>
|
||||
Meus Fluxos
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
Visualize e gerencie os fluxos de trabalho em execução
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={resolve('/fluxos')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-secondary/10 to-secondary/20 p-6 hover:border-secondary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-secondary group-hover:text-white transition-colors duration-300"
|
||||
>
|
||||
<FileText
|
||||
class="h-5 w-5 text-secondary group-hover:text-white transition-colors duration-300"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<ChevronRight
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-secondary transition-colors duration-300"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-secondary transition-colors duration-300"
|
||||
>
|
||||
Templates
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
Crie e edite templates de fluxos de trabalho
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
365
apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
Normal file
365
apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
Normal file
@@ -0,0 +1,365 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Estado dos filtros
|
||||
let statusFilter = $state<'active' | 'completed' | 'cancelled' | undefined>(undefined);
|
||||
|
||||
// Query de instâncias
|
||||
const instancesQuery = useQuery(
|
||||
api.flows.listInstances,
|
||||
() => (statusFilter ? { status: statusFilter } : {})
|
||||
);
|
||||
|
||||
// Query de templates publicados (para o modal de criação)
|
||||
const publishedTemplatesQuery = useQuery(api.flows.listTemplates, { status: 'published' });
|
||||
|
||||
// Modal de criação
|
||||
let showCreateModal = $state(false);
|
||||
let selectedTemplateId = $state<Id<'flowTemplates'> | ''>('');
|
||||
let contratoId = $state<Id<'contratos'> | ''>('');
|
||||
let managerId = $state<Id<'usuarios'> | ''>('');
|
||||
let isCreating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
// Query de usuários (para seleção de gerente)
|
||||
const usuariosQuery = useQuery(api.usuarios.listar, {});
|
||||
|
||||
// Query de contratos (para seleção)
|
||||
const contratosQuery = useQuery(api.contratos.listar, {});
|
||||
|
||||
function openCreateModal() {
|
||||
selectedTemplateId = '';
|
||||
contratoId = '';
|
||||
managerId = '';
|
||||
createError = null;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!selectedTemplateId || !managerId) {
|
||||
createError = 'Template e gerente são obrigatórios';
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating = true;
|
||||
createError = null;
|
||||
|
||||
try {
|
||||
const instanceId = await client.mutation(api.flows.instantiateFlow, {
|
||||
flowTemplateId: selectedTemplateId as Id<'flowTemplates'>,
|
||||
contratoId: contratoId ? (contratoId as Id<'contratos'>) : undefined,
|
||||
managerId: managerId as Id<'usuarios'>
|
||||
});
|
||||
closeCreateModal();
|
||||
goto(`/licitacoes/fluxos/${instanceId}`);
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : 'Erro ao criar fluxo';
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: 'active' | 'completed' | 'cancelled') {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return { class: 'badge-info', label: 'Em Andamento' };
|
||||
case 'completed':
|
||||
return { class: 'badge-success', label: 'Concluído' };
|
||||
case 'cancelled':
|
||||
return { class: 'badge-error', label: 'Cancelado' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getProgressPercentage(completed: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return Math.round((completed / total) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
|
||||
<!-- Header -->
|
||||
<section
|
||||
class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
|
||||
>
|
||||
<div class="bg-info/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-3xl space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/fluxos" class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Templates
|
||||
</a>
|
||||
<span
|
||||
class="border-info/40 bg-info/10 text-info inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
|
||||
>
|
||||
Execução
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
||||
Fluxos de Trabalho
|
||||
</h1>
|
||||
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||
Acompanhe e gerencie os fluxos de trabalho. Visualize o progresso,
|
||||
documentos e responsáveis de cada etapa.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<!-- Filtro de status -->
|
||||
<select
|
||||
class="select select-bordered"
|
||||
bind:value={statusFilter}
|
||||
>
|
||||
<option value={undefined}>Todos os status</option>
|
||||
<option value="active">Em Andamento</option>
|
||||
<option value="completed">Concluído</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
|
||||
<ActionGuard recurso="fluxos_instancias" acao="criar">
|
||||
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Fluxo
|
||||
</button>
|
||||
</ActionGuard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lista de Instâncias -->
|
||||
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
|
||||
{#if instancesQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-info"></span>
|
||||
</div>
|
||||
{:else if !instancesQuery.data || instancesQuery.data.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/30 h-16 w-16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhum fluxo encontrado</h3>
|
||||
<p class="text-base-content/50 mt-2">
|
||||
{statusFilter ? 'Não há fluxos com este status.' : 'Clique em "Novo Fluxo" para iniciar um fluxo.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>Contrato</th>
|
||||
<th>Gerente</th>
|
||||
<th>Progresso</th>
|
||||
<th>Status</th>
|
||||
<th>Iniciado em</th>
|
||||
<th class="text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each instancesQuery.data as instance (instance._id)}
|
||||
{@const statusBadge = getStatusBadge(instance.status)}
|
||||
{@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="font-medium">{instance.templateName ?? 'Template desconhecido'}</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if instance.contratoId}
|
||||
<span class="badge badge-outline badge-sm">{instance.contratoId}</span>
|
||||
{:else}
|
||||
<span class="text-base-content/40 text-sm">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-base-content/70">{instance.managerName ?? '-'}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-info w-20"
|
||||
value={progressPercent}
|
||||
max="100"
|
||||
></progress>
|
||||
<span class="text-xs text-base-content/60">
|
||||
{instance.progress.completed}/{instance.progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {statusBadge.class}">{statusBadge.label}</span>
|
||||
</td>
|
||||
<td class="text-base-content/60 text-sm">{formatDate(instance.startedAt)}</td>
|
||||
<td class="text-right">
|
||||
<a
|
||||
href="/licitacoes/fluxos/{instance._id}"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Ver
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Criação -->
|
||||
{#if showCreateModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">Novo Fluxo de Trabalho</h3>
|
||||
|
||||
{#if createError}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{createError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="template-select">
|
||||
<span class="label-text">Template de Fluxo</span>
|
||||
</label>
|
||||
<select
|
||||
id="template-select"
|
||||
bind:value={selectedTemplateId}
|
||||
class="select select-bordered w-full"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um template</option>
|
||||
{#if publishedTemplatesQuery.data}
|
||||
{#each publishedTemplatesQuery.data as template (template._id)}
|
||||
<option value={template._id}>{template.name}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<p class="label">
|
||||
<span class="label-text-alt text-base-content/60">Apenas templates publicados podem ser instanciados</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="contrato-select">
|
||||
<span class="label-text">Contrato (Opcional)</span>
|
||||
</label>
|
||||
<select
|
||||
id="contrato-select"
|
||||
bind:value={contratoId}
|
||||
class="select select-bordered w-full"
|
||||
>
|
||||
<option value="">Nenhum contrato</option>
|
||||
{#if contratosQuery.data}
|
||||
{#each contratosQuery.data as contrato (contrato._id)}
|
||||
<option value={contrato._id}>{contrato.numero ?? contrato._id}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<p class="label">
|
||||
<span class="label-text-alt text-base-content/60">Opcional: vincule este fluxo a um contrato específico</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="manager-select">
|
||||
<span class="label-text">Gerente Responsável</span>
|
||||
</label>
|
||||
<select
|
||||
id="manager-select"
|
||||
bind:value={managerId}
|
||||
class="select select-bordered w-full"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um gerente</option>
|
||||
{#if usuariosQuery.data}
|
||||
{#each usuariosQuery.data as usuario (usuario._id)}
|
||||
<option value={usuario._id}>{usuario.nome}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-info" disabled={isCreating}>
|
||||
{#if isCreating}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Iniciar Fluxo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeCreateModal} aria-label="Fechar modal"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
1251
apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
Normal file
1251
apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Award, Building2 } from "lucide-svelte";
|
||||
import { Trophy, Award, Building2, Workflow } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
@@ -56,6 +56,23 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={resolve('/fluxos')}
|
||||
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-secondary"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="p-2 bg-secondary/10 rounded-lg">
|
||||
<Workflow class="h-6 w-6 text-secondary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Fluxos de Trabalho</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Gerencie templates e instâncias de fluxos de trabalho para programas e projetos esportivos.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { SimboloTipo } from '@sgse-app/backend/convex/schema';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import PrintModal from '$lib/components/PrintModal.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let list: Array<any> = [];
|
||||
let filtered: Array<any> = [];
|
||||
let selectedId: string | null = null;
|
||||
let openMenuId: string | null = null;
|
||||
let funcionarioParaImprimir: any = null;
|
||||
// Estado reativo
|
||||
let list = $state<Array<{
|
||||
_id: Id<'funcionarios'>;
|
||||
nome: string;
|
||||
matricula?: string;
|
||||
cpf: string;
|
||||
cidade?: string;
|
||||
uf?: string;
|
||||
simboloTipo?: SimboloTipo;
|
||||
}>>([]);
|
||||
let filtered = $state<typeof list>([]);
|
||||
let openMenuId = $state<string | null>(null);
|
||||
let funcionarioParaImprimir = $state<unknown>(null);
|
||||
|
||||
let filtroNome = '';
|
||||
let filtroCPF = '';
|
||||
let filtroMatricula = '';
|
||||
let filtroTipo: SimboloTipo | '' = '';
|
||||
// Estado do modal de setores
|
||||
let showSetoresModal = $state(false);
|
||||
let funcionarioParaSetores = $state<{ _id: Id<'funcionarios'>; nome: string } | null>(null);
|
||||
let setoresSelecionados = $state<Id<'setores'>[]>([]);
|
||||
let isSavingSetores = $state(false);
|
||||
let setoresError = $state<string | null>(null);
|
||||
|
||||
// Queries
|
||||
const todosSetoresQuery = useQuery(api.setores.list, {});
|
||||
|
||||
let filtroNome = $state('');
|
||||
let filtroCPF = $state('');
|
||||
let filtroMatricula = $state('');
|
||||
let filtroTipo = $state<SimboloTipo | ''>('');
|
||||
|
||||
function applyFilters() {
|
||||
const nome = filtroNome.toLowerCase();
|
||||
@@ -33,18 +52,15 @@
|
||||
}
|
||||
|
||||
async function load() {
|
||||
list = await client.query(api.funcionarios.getAll, {} as any);
|
||||
const data = await client.query(api.funcionarios.getAll, {});
|
||||
list = data ?? [];
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function editSelected() {
|
||||
if (selectedId) goto(resolve(`/recursos-humanos/funcionarios/${selectedId}/editar`));
|
||||
}
|
||||
|
||||
async function openPrintModal(funcionarioId: string) {
|
||||
try {
|
||||
const data = await client.query(api.funcionarios.getFichaCompleta, {
|
||||
id: funcionarioId as any
|
||||
id: funcionarioId as Id<'funcionarios'>
|
||||
});
|
||||
funcionarioParaImprimir = data;
|
||||
} catch (err) {
|
||||
@@ -62,12 +78,64 @@
|
||||
function toggleMenu(id: string) {
|
||||
openMenuId = openMenuId === id ? null : id;
|
||||
}
|
||||
$: needsScroll = filtered.length > 8;
|
||||
|
||||
async function openSetoresModal(funcionarioId: Id<'funcionarios'>, nome: string) {
|
||||
funcionarioParaSetores = { _id: funcionarioId, nome };
|
||||
setoresSelecionados = [];
|
||||
setoresError = null;
|
||||
showSetoresModal = true;
|
||||
openMenuId = null;
|
||||
|
||||
// Carregar setores do funcionário
|
||||
try {
|
||||
const setores = await client.query(api.setores.getSetoresByFuncionario, {
|
||||
funcionarioId
|
||||
});
|
||||
setoresSelecionados = setores.map((s) => s._id);
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar setores do funcionário:', err);
|
||||
setoresError = 'Erro ao carregar setores do funcionário';
|
||||
}
|
||||
}
|
||||
|
||||
function closeSetoresModal() {
|
||||
showSetoresModal = false;
|
||||
funcionarioParaSetores = null;
|
||||
setoresSelecionados = [];
|
||||
setoresError = null;
|
||||
}
|
||||
|
||||
function toggleSetor(setorId: Id<'setores'>) {
|
||||
if (setoresSelecionados.includes(setorId)) {
|
||||
setoresSelecionados = setoresSelecionados.filter((id) => id !== setorId);
|
||||
} else {
|
||||
setoresSelecionados = [...setoresSelecionados, setorId];
|
||||
}
|
||||
}
|
||||
|
||||
async function salvarSetores() {
|
||||
if (!funcionarioParaSetores) return;
|
||||
|
||||
isSavingSetores = true;
|
||||
setoresError = null;
|
||||
|
||||
try {
|
||||
await client.mutation(api.setores.atualizarSetoresFuncionario, {
|
||||
funcionarioId: funcionarioParaSetores._id,
|
||||
setorIds: setoresSelecionados
|
||||
});
|
||||
closeSetoresModal();
|
||||
} catch (err) {
|
||||
setoresError = err instanceof Error ? err.message : 'Erro ao salvar setores';
|
||||
} finally {
|
||||
isSavingSetores = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-4 max-w-7xl flex flex-col" style="height: calc(100vh - 8rem); min-height: 600px;">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumbs mb-4 text-sm flex-shrink-0">
|
||||
<div class="breadcrumbs mb-4 text-sm shrink-0">
|
||||
<ul>
|
||||
<li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||
<li>Funcionários</li>
|
||||
@@ -75,7 +143,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="mb-6 flex-shrink-0">
|
||||
<div class="mb-6 shrink-0">
|
||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="rounded-xl bg-blue-500/20 p-3">
|
||||
@@ -118,7 +186,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 mb-4 shadow-xl flex-shrink-0">
|
||||
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 mb-4 shadow-xl shrink-0">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4 text-lg">
|
||||
<svg
|
||||
@@ -232,7 +300,7 @@
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="overflow-x-auto flex-1 overflow-y-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead class="sticky top-0 z-10 shadow-md bg-gradient-to-r from-base-300 to-base-200">
|
||||
<thead class="sticky top-0 z-10 shadow-md bg-linear-to-r from-base-300 to-base-200">
|
||||
<tr>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Nome</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">CPF</th>
|
||||
@@ -277,7 +345,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filtered as f}
|
||||
{#each filtered as f (f._id)}
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<td class="whitespace-nowrap font-medium">{f.nome}</td>
|
||||
<td class="whitespace-nowrap">{f.cpf}</td>
|
||||
@@ -314,20 +382,28 @@
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl"
|
||||
>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}`} class="hover:bg-primary/10">
|
||||
<a href={resolve(`/recursos-humanos/funcionarios/${f._id}`)} class="hover:bg-primary/10">
|
||||
Ver Detalhes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`} class="hover:bg-primary/10">
|
||||
<a href={resolve(`/recursos-humanos/funcionarios/${f._id}/editar`)} class="hover:bg-primary/10">
|
||||
Editar
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`} class="hover:bg-primary/10">
|
||||
<a href={resolve(`/recursos-humanos/funcionarios/${f._id}/documentos`)} class="hover:bg-primary/10">
|
||||
Ver Documentos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => openSetoresModal(f._id, f.nome)}
|
||||
class="hover:bg-primary/10"
|
||||
>
|
||||
Atribuir Setores
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={() => openPrintModal(f._id)} class="hover:bg-primary/10">
|
||||
Imprimir Ficha
|
||||
@@ -347,7 +423,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Informação sobre resultados -->
|
||||
<div class="text-base-content/70 mt-3 text-center text-sm flex-shrink-0 border-t border-base-300 pt-3 bg-base-100/50">
|
||||
<div class="text-base-content/70 mt-3 text-center text-sm shrink-0 border-t border-base-300 pt-3 bg-base-100/50">
|
||||
Exibindo <span class="font-semibold">{filtered.length}</span> de <span class="font-semibold">{list.length}</span> funcionário(s)
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,4 +435,85 @@
|
||||
onClose={() => (funcionarioParaImprimir = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Atribuição de Setores -->
|
||||
{#if showSetoresModal && funcionarioParaSetores}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">Atribuir Setores</h3>
|
||||
<p class="text-base-content/60 mt-2">
|
||||
Selecione os setores para <strong>{funcionarioParaSetores.nome}</strong>
|
||||
</p>
|
||||
|
||||
{#if setoresError}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{setoresError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 max-h-96 overflow-y-auto">
|
||||
{#if todosSetoresQuery.isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if todosSetoresQuery.data && todosSetoresQuery.data.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each todosSetoresQuery.data as setor (setor._id)}
|
||||
{@const isSelected = setoresSelecionados.includes(setor._id)}
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-base-200 {isSelected ? 'border-primary bg-primary/5' : 'border-base-300'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={isSelected}
|
||||
onchange={() => toggleSetor(setor._id)}
|
||||
aria-label="Selecionar setor {setor.nome}"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{setor.nome}</div>
|
||||
<div class="text-base-content/60 text-sm">Sigla: {setor.sigla}</div>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-base-content/60 py-8 text-center">
|
||||
<p>Nenhum setor cadastrado</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={closeSetoresModal} disabled={isSavingSetores}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={salvarSetores} disabled={isSavingSetores}>
|
||||
{#if isSavingSetores}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-backdrop"
|
||||
onclick={closeSetoresModal}
|
||||
aria-label="Fechar modal"
|
||||
></button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
| 'teams'
|
||||
| 'userPlus'
|
||||
| 'clock'
|
||||
| 'video';
|
||||
| 'video'
|
||||
| 'building';
|
||||
type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning';
|
||||
|
||||
type TiRouteId =
|
||||
@@ -30,7 +31,8 @@
|
||||
| '/(dashboard)/ti/monitoramento'
|
||||
| '/(dashboard)/ti/configuracoes-ponto'
|
||||
| '/(dashboard)/ti/configuracoes-relogio'
|
||||
| '/(dashboard)/ti/configuracoes-jitsi';
|
||||
| '/(dashboard)/ti/configuracoes-jitsi'
|
||||
| '/(dashboard)/configuracoes/setores';
|
||||
|
||||
type FeatureCard = {
|
||||
title: string;
|
||||
@@ -211,6 +213,13 @@
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round'
|
||||
}
|
||||
],
|
||||
building: [
|
||||
{
|
||||
d: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -349,6 +358,15 @@
|
||||
{ label: 'Relatórios', variant: 'outline' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Gestão de Setores',
|
||||
description:
|
||||
'Gerencie os setores da organização. Setores são utilizados para organizar funcionários e definir responsabilidades em fluxos de trabalho.',
|
||||
ctaLabel: 'Gerenciar Setores',
|
||||
href: '/(dashboard)/configuracoes/setores',
|
||||
palette: 'accent',
|
||||
icon: 'building'
|
||||
},
|
||||
{
|
||||
title: 'Documentação',
|
||||
description:
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sgse-app",
|
||||
@@ -18,6 +19,7 @@
|
||||
"jiti": "^2.6.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"svelte-dnd-action": "^0.9.67",
|
||||
"turbo": "^2.5.8",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
},
|
||||
@@ -66,6 +68,7 @@
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.38.1",
|
||||
"svelte-check": "^4.3.1",
|
||||
"svelte-dnd-action": "^0.9.67",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^7.1.2",
|
||||
@@ -1264,6 +1267,8 @@
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="],
|
||||
|
||||
"svelte-dnd-action": ["svelte-dnd-action@0.9.67", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-yEJQZ9SFy3O4mnOdtjwWyotRsWRktNf4W8k67zgiLiMtMNQnwCyJHBjkGMgZMDh8EGZ4gr88l+GebBWoHDwo+g=="],
|
||||
|
||||
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA=="],
|
||||
|
||||
"svelte-sonner": ["svelte-sonner@1.0.5", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg=="],
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"jiti": "^2.6.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"svelte-dnd-action": "^0.9.67",
|
||||
"turbo": "^2.5.8",
|
||||
"typescript-eslint": "^8.46.3"
|
||||
},
|
||||
|
||||
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -35,6 +35,7 @@ import type * as email from "../email.js";
|
||||
import type * as empresas from "../empresas.js";
|
||||
import type * as enderecosMarcacao from "../enderecosMarcacao.js";
|
||||
import type * as ferias from "../ferias.js";
|
||||
import type * as flows from "../flows.js";
|
||||
import type * as funcionarioEnderecos from "../funcionarioEnderecos.js";
|
||||
import type * as funcionarios from "../funcionarios.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
@@ -51,6 +52,7 @@ import type * as roles from "../roles.js";
|
||||
import type * as saldoFerias from "../saldoFerias.js";
|
||||
import type * as security from "../security.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as setores from "../setores.js";
|
||||
import type * as simbolos from "../simbolos.js";
|
||||
import type * as templatesMensagens from "../templatesMensagens.js";
|
||||
import type * as times from "../times.js";
|
||||
@@ -93,6 +95,7 @@ declare const fullApi: ApiFromModules<{
|
||||
empresas: typeof empresas;
|
||||
enderecosMarcacao: typeof enderecosMarcacao;
|
||||
ferias: typeof ferias;
|
||||
flows: typeof flows;
|
||||
funcionarioEnderecos: typeof funcionarioEnderecos;
|
||||
funcionarios: typeof funcionarios;
|
||||
healthCheck: typeof healthCheck;
|
||||
@@ -109,6 +112,7 @@ declare const fullApi: ApiFromModules<{
|
||||
saldoFerias: typeof saldoFerias;
|
||||
security: typeof security;
|
||||
seed: typeof seed;
|
||||
setores: typeof setores;
|
||||
simbolos: typeof simbolos;
|
||||
templatesMensagens: typeof templatesMensagens;
|
||||
times: typeof times;
|
||||
|
||||
2161
packages/backend/convex/flows.ts
Normal file
2161
packages/backend/convex/flows.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -295,6 +295,106 @@ const PERMISSOES_BASE = {
|
||||
recurso: 'gestao_pessoas',
|
||||
acao: 'ver',
|
||||
descricao: 'Acessar telas do módulo de gestão de pessoas'
|
||||
},
|
||||
// Setores
|
||||
{
|
||||
nome: 'setores.listar',
|
||||
recurso: 'setores',
|
||||
acao: 'listar',
|
||||
descricao: 'Listar setores'
|
||||
},
|
||||
{
|
||||
nome: 'setores.criar',
|
||||
recurso: 'setores',
|
||||
acao: 'criar',
|
||||
descricao: 'Criar novos setores'
|
||||
},
|
||||
{
|
||||
nome: 'setores.editar',
|
||||
recurso: 'setores',
|
||||
acao: 'editar',
|
||||
descricao: 'Editar setores'
|
||||
},
|
||||
{
|
||||
nome: 'setores.excluir',
|
||||
recurso: 'setores',
|
||||
acao: 'excluir',
|
||||
descricao: 'Excluir setores'
|
||||
},
|
||||
// Flow Templates
|
||||
{
|
||||
nome: 'fluxos.templates.listar',
|
||||
recurso: 'fluxos_templates',
|
||||
acao: 'listar',
|
||||
descricao: 'Listar templates de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.templates.criar',
|
||||
recurso: 'fluxos_templates',
|
||||
acao: 'criar',
|
||||
descricao: 'Criar templates de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.templates.editar',
|
||||
recurso: 'fluxos_templates',
|
||||
acao: 'editar',
|
||||
descricao: 'Editar templates de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.templates.excluir',
|
||||
recurso: 'fluxos_templates',
|
||||
acao: 'excluir',
|
||||
descricao: 'Excluir templates de fluxo'
|
||||
},
|
||||
// Flow Instances
|
||||
{
|
||||
nome: 'fluxos.instancias.listar',
|
||||
recurso: 'fluxos_instancias',
|
||||
acao: 'listar',
|
||||
descricao: 'Listar instâncias de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.instancias.criar',
|
||||
recurso: 'fluxos_instancias',
|
||||
acao: 'criar',
|
||||
descricao: 'Criar instâncias de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.instancias.ver',
|
||||
recurso: 'fluxos_instancias',
|
||||
acao: 'ver',
|
||||
descricao: 'Visualizar detalhes de instâncias de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.instancias.atualizar_status',
|
||||
recurso: 'fluxos_instancias',
|
||||
acao: 'atualizar_status',
|
||||
descricao: 'Atualizar status de instâncias de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.instancias.atribuir',
|
||||
recurso: 'fluxos_instancias',
|
||||
acao: 'atribuir',
|
||||
descricao: 'Atribuir responsáveis em instâncias de fluxo'
|
||||
},
|
||||
// Flow Documents
|
||||
{
|
||||
nome: 'fluxos.documentos.listar',
|
||||
recurso: 'fluxos_documentos',
|
||||
acao: 'listar',
|
||||
descricao: 'Listar documentos de fluxo'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.documentos.upload',
|
||||
recurso: 'fluxos_documentos',
|
||||
acao: 'upload',
|
||||
descricao: 'Fazer upload de documentos em fluxos'
|
||||
},
|
||||
{
|
||||
nome: 'fluxos.documentos.excluir',
|
||||
recurso: 'fluxos_documentos',
|
||||
acao: 'excluir',
|
||||
descricao: 'Excluir documentos de fluxos'
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
|
||||
@@ -120,6 +120,31 @@ export const reportStatus = v.union(
|
||||
v.literal("falhou")
|
||||
);
|
||||
|
||||
// Status de templates de fluxo
|
||||
export const flowTemplateStatus = v.union(
|
||||
v.literal("draft"),
|
||||
v.literal("published"),
|
||||
v.literal("archived")
|
||||
);
|
||||
export type FlowTemplateStatus = Infer<typeof flowTemplateStatus>;
|
||||
|
||||
// Status de instâncias de fluxo
|
||||
export const flowInstanceStatus = v.union(
|
||||
v.literal("active"),
|
||||
v.literal("completed"),
|
||||
v.literal("cancelled")
|
||||
);
|
||||
export type FlowInstanceStatus = Infer<typeof flowInstanceStatus>;
|
||||
|
||||
// Status de passos de instância de fluxo
|
||||
export const flowInstanceStepStatus = v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("in_progress"),
|
||||
v.literal("completed"),
|
||||
v.literal("blocked")
|
||||
);
|
||||
export type FlowInstanceStepStatus = Infer<typeof flowInstanceStepStatus>;
|
||||
|
||||
export const situacaoContrato = v.union(
|
||||
v.literal("em_execucao"),
|
||||
v.literal("rescendido"),
|
||||
@@ -128,6 +153,129 @@ export const situacaoContrato = v.union(
|
||||
);
|
||||
|
||||
export default defineSchema({
|
||||
// Setores da organização
|
||||
setores: defineTable({
|
||||
nome: v.string(),
|
||||
sigla: v.string(),
|
||||
criadoPor: v.id("usuarios"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_nome", ["nome"])
|
||||
.index("by_sigla", ["sigla"]),
|
||||
|
||||
// Relação muitos-para-muitos entre funcionários e setores
|
||||
funcionarioSetores: defineTable({
|
||||
funcionarioId: v.id("funcionarios"),
|
||||
setorId: v.id("setores"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_funcionarioId", ["funcionarioId"])
|
||||
.index("by_setorId", ["setorId"])
|
||||
.index("by_funcionarioId_and_setorId", ["funcionarioId", "setorId"]),
|
||||
|
||||
// Templates de fluxo
|
||||
flowTemplates: defineTable({
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
status: flowTemplateStatus,
|
||||
createdBy: v.id("usuarios"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_status", ["status"])
|
||||
.index("by_createdBy", ["createdBy"]),
|
||||
|
||||
// Passos de template de fluxo
|
||||
flowSteps: defineTable({
|
||||
flowTemplateId: v.id("flowTemplates"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
position: v.number(),
|
||||
expectedDuration: v.number(), // em dias
|
||||
setorId: v.id("setores"),
|
||||
defaultAssigneeId: v.optional(v.id("usuarios")),
|
||||
requiredDocuments: v.optional(v.array(v.string())),
|
||||
})
|
||||
.index("by_flowTemplateId", ["flowTemplateId"])
|
||||
.index("by_flowTemplateId_and_position", ["flowTemplateId", "position"]),
|
||||
|
||||
// Instâncias de fluxo
|
||||
flowInstances: defineTable({
|
||||
flowTemplateId: v.id("flowTemplates"),
|
||||
contratoId: v.optional(v.id("contratos")),
|
||||
managerId: v.id("usuarios"),
|
||||
status: flowInstanceStatus,
|
||||
startedAt: v.number(),
|
||||
finishedAt: v.optional(v.number()),
|
||||
currentStepId: v.optional(v.id("flowInstanceSteps")),
|
||||
})
|
||||
.index("by_flowTemplateId", ["flowTemplateId"])
|
||||
.index("by_contratoId", ["contratoId"])
|
||||
.index("by_managerId", ["managerId"])
|
||||
.index("by_status", ["status"]),
|
||||
|
||||
// Passos de instância de fluxo
|
||||
flowInstanceSteps: defineTable({
|
||||
flowInstanceId: v.id("flowInstances"),
|
||||
flowStepId: v.id("flowSteps"),
|
||||
setorId: v.id("setores"),
|
||||
assignedToId: v.optional(v.id("usuarios")),
|
||||
status: flowInstanceStepStatus,
|
||||
startedAt: v.optional(v.number()),
|
||||
finishedAt: v.optional(v.number()),
|
||||
notes: v.optional(v.string()),
|
||||
notesUpdatedBy: v.optional(v.id("usuarios")),
|
||||
notesUpdatedAt: v.optional(v.number()),
|
||||
dueDate: v.optional(v.number()),
|
||||
})
|
||||
.index("by_flowInstanceId", ["flowInstanceId"])
|
||||
.index("by_flowInstanceId_and_status", ["flowInstanceId", "status"])
|
||||
.index("by_setorId", ["setorId"])
|
||||
.index("by_assignedToId", ["assignedToId"]),
|
||||
|
||||
// Documentos de instância de fluxo
|
||||
flowInstanceDocuments: defineTable({
|
||||
flowInstanceStepId: v.id("flowInstanceSteps"),
|
||||
uploadedById: v.id("usuarios"),
|
||||
storageId: v.id("_storage"),
|
||||
name: v.string(),
|
||||
uploadedAt: v.number(),
|
||||
})
|
||||
.index("by_flowInstanceStepId", ["flowInstanceStepId"])
|
||||
.index("by_uploadedById", ["uploadedById"]),
|
||||
|
||||
// Sub-etapas de fluxo (para templates e instâncias)
|
||||
flowSubSteps: defineTable({
|
||||
flowStepId: v.optional(v.id("flowSteps")), // Para templates
|
||||
flowInstanceStepId: v.optional(v.id("flowInstanceSteps")), // Para instâncias
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("in_progress"),
|
||||
v.literal("completed"),
|
||||
v.literal("blocked")
|
||||
),
|
||||
position: v.number(),
|
||||
createdBy: v.id("usuarios"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_flowStepId", ["flowStepId"])
|
||||
.index("by_flowInstanceStepId", ["flowInstanceStepId"]),
|
||||
|
||||
// Notas de steps e sub-etapas
|
||||
flowStepNotes: defineTable({
|
||||
flowStepId: v.optional(v.id("flowSteps")),
|
||||
flowInstanceStepId: v.optional(v.id("flowInstanceSteps")),
|
||||
flowSubStepId: v.optional(v.id("flowSubSteps")),
|
||||
texto: v.string(),
|
||||
criadoPor: v.id("usuarios"),
|
||||
criadoEm: v.number(),
|
||||
arquivos: v.array(v.id("_storage")),
|
||||
})
|
||||
.index("by_flowStepId", ["flowStepId"])
|
||||
.index("by_flowInstanceStepId", ["flowInstanceStepId"])
|
||||
.index("by_flowSubStepId", ["flowSubStepId"]),
|
||||
|
||||
contratos: defineTable({
|
||||
contratadaId: v.id("empresas"),
|
||||
objeto: v.string(),
|
||||
@@ -897,7 +1045,8 @@ export default defineSchema({
|
||||
v.literal("mencao"),
|
||||
v.literal("grupo_criado"),
|
||||
v.literal("adicionado_grupo"),
|
||||
v.literal("alerta_seguranca")
|
||||
v.literal("alerta_seguranca"),
|
||||
v.literal("etapa_fluxo_concluida")
|
||||
),
|
||||
conversaId: v.optional(v.id("conversas")),
|
||||
mensagemId: v.optional(v.id("mensagens")),
|
||||
|
||||
318
packages/backend/convex/setores.ts
Normal file
318
packages/backend/convex/setores.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { query, mutation } from './_generated/server';
|
||||
import { v } from 'convex/values';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
|
||||
/**
|
||||
* Listar todos os setores
|
||||
*/
|
||||
export const list = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id('setores'),
|
||||
_creationTime: v.number(),
|
||||
nome: v.string(),
|
||||
sigla: v.string(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
createdAt: v.number()
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
const setores = await ctx.db.query('setores').order('asc').collect();
|
||||
return setores;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obter um setor pelo ID
|
||||
*/
|
||||
export const getById = query({
|
||||
args: { id: v.id('setores') },
|
||||
returns: v.union(
|
||||
v.object({
|
||||
_id: v.id('setores'),
|
||||
_creationTime: v.number(),
|
||||
nome: v.string(),
|
||||
sigla: v.string(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
createdAt: v.number()
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const setor = await ctx.db.get(args.id);
|
||||
return setor;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Criar um novo setor
|
||||
*/
|
||||
export const create = mutation({
|
||||
args: {
|
||||
nome: v.string(),
|
||||
sigla: v.string()
|
||||
},
|
||||
returns: v.id('setores'),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
// Verificar se já existe setor com mesmo nome ou sigla
|
||||
const existenteNome = await ctx.db
|
||||
.query('setores')
|
||||
.withIndex('by_nome', (q) => q.eq('nome', args.nome))
|
||||
.first();
|
||||
if (existenteNome) {
|
||||
throw new Error('Já existe um setor com este nome');
|
||||
}
|
||||
|
||||
const existenteSigla = await ctx.db
|
||||
.query('setores')
|
||||
.withIndex('by_sigla', (q) => q.eq('sigla', args.sigla))
|
||||
.first();
|
||||
if (existenteSigla) {
|
||||
throw new Error('Já existe um setor com esta sigla');
|
||||
}
|
||||
|
||||
const setorId = await ctx.db.insert('setores', {
|
||||
nome: args.nome,
|
||||
sigla: args.sigla.toUpperCase(),
|
||||
criadoPor: usuario._id,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
return setorId;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualizar um setor existente
|
||||
*/
|
||||
export const update = mutation({
|
||||
args: {
|
||||
id: v.id('setores'),
|
||||
nome: v.string(),
|
||||
sigla: v.string()
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const setor = await ctx.db.get(args.id);
|
||||
if (!setor) {
|
||||
throw new Error('Setor não encontrado');
|
||||
}
|
||||
|
||||
// Verificar se já existe outro setor com mesmo nome
|
||||
const existenteNome = await ctx.db
|
||||
.query('setores')
|
||||
.withIndex('by_nome', (q) => q.eq('nome', args.nome))
|
||||
.first();
|
||||
if (existenteNome && existenteNome._id !== args.id) {
|
||||
throw new Error('Já existe um setor com este nome');
|
||||
}
|
||||
|
||||
// Verificar se já existe outro setor com mesma sigla
|
||||
const existenteSigla = await ctx.db
|
||||
.query('setores')
|
||||
.withIndex('by_sigla', (q) => q.eq('sigla', args.sigla))
|
||||
.first();
|
||||
if (existenteSigla && existenteSigla._id !== args.id) {
|
||||
throw new Error('Já existe um setor com esta sigla');
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.id, {
|
||||
nome: args.nome,
|
||||
sigla: args.sigla.toUpperCase()
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obter funcionários de um setor específico
|
||||
*/
|
||||
export const getFuncionariosBySetor = query({
|
||||
args: { setorId: v.id('setores') },
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id('funcionarios'),
|
||||
_creationTime: v.number(),
|
||||
nome: v.string(),
|
||||
matricula: v.optional(v.string()),
|
||||
email: v.string(),
|
||||
cpf: v.string()
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Buscar todas as relações funcionarioSetores para este setor
|
||||
const funcionarioSetores = await ctx.db
|
||||
.query('funcionarioSetores')
|
||||
.withIndex('by_setorId', (q) => q.eq('setorId', args.setorId))
|
||||
.collect();
|
||||
|
||||
// Buscar os funcionários correspondentes
|
||||
const funcionarios = [];
|
||||
for (const relacao of funcionarioSetores) {
|
||||
const funcionario = await ctx.db.get(relacao.funcionarioId);
|
||||
if (funcionario) {
|
||||
funcionarios.push({
|
||||
_id: funcionario._id,
|
||||
_creationTime: funcionario._creationTime,
|
||||
nome: funcionario.nome,
|
||||
matricula: funcionario.matricula,
|
||||
email: funcionario.email,
|
||||
cpf: funcionario.cpf
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return funcionarios;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obter setores de um funcionário
|
||||
*/
|
||||
export const getSetoresByFuncionario = query({
|
||||
args: { funcionarioId: v.id('funcionarios') },
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id('setores'),
|
||||
_creationTime: v.number(),
|
||||
nome: v.string(),
|
||||
sigla: v.string(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
createdAt: v.number()
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Buscar todas as relações funcionarioSetores para este funcionário
|
||||
const funcionarioSetores = await ctx.db
|
||||
.query('funcionarioSetores')
|
||||
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
|
||||
.collect();
|
||||
|
||||
// Buscar os setores correspondentes
|
||||
const setores = [];
|
||||
for (const relacao of funcionarioSetores) {
|
||||
const setor = await ctx.db.get(relacao.setorId);
|
||||
if (setor) {
|
||||
setores.push(setor);
|
||||
}
|
||||
}
|
||||
|
||||
return setores;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualizar setores de um funcionário
|
||||
*/
|
||||
export const atualizarSetoresFuncionario = mutation({
|
||||
args: {
|
||||
funcionarioId: v.id('funcionarios'),
|
||||
setorIds: v.array(v.id('setores'))
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
// Verificar se o funcionário existe
|
||||
const funcionario = await ctx.db.get(args.funcionarioId);
|
||||
if (!funcionario) {
|
||||
throw new Error('Funcionário não encontrado');
|
||||
}
|
||||
|
||||
// Verificar se todos os setores existem
|
||||
for (const setorId of args.setorIds) {
|
||||
const setor = await ctx.db.get(setorId);
|
||||
if (!setor) {
|
||||
throw new Error(`Setor ${setorId} não encontrado`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remover todas as relações existentes do funcionário
|
||||
const funcionarioSetoresExistentes = await ctx.db
|
||||
.query('funcionarioSetores')
|
||||
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
|
||||
.collect();
|
||||
|
||||
for (const relacao of funcionarioSetoresExistentes) {
|
||||
await ctx.db.delete(relacao._id);
|
||||
}
|
||||
|
||||
// Criar novas relações para os setores selecionados
|
||||
const now = Date.now();
|
||||
for (const setorId of args.setorIds) {
|
||||
// Verificar se já existe relação (evitar duplicatas)
|
||||
const existe = await ctx.db
|
||||
.query('funcionarioSetores')
|
||||
.withIndex('by_funcionarioId_and_setorId', (q) =>
|
||||
q.eq('funcionarioId', args.funcionarioId).eq('setorId', setorId)
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!existe) {
|
||||
await ctx.db.insert('funcionarioSetores', {
|
||||
funcionarioId: args.funcionarioId,
|
||||
setorId,
|
||||
createdAt: now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Excluir um setor
|
||||
*/
|
||||
export const remove = mutation({
|
||||
args: { id: v.id('setores') },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const setor = await ctx.db.get(args.id);
|
||||
if (!setor) {
|
||||
throw new Error('Setor não encontrado');
|
||||
}
|
||||
|
||||
// Verificar se há funcionários vinculados
|
||||
const funcionariosVinculados = await ctx.db
|
||||
.query('funcionarioSetores')
|
||||
.withIndex('by_setorId', (q) => q.eq('setorId', args.id))
|
||||
.first();
|
||||
if (funcionariosVinculados) {
|
||||
throw new Error('Não é possível excluir um setor com funcionários vinculados');
|
||||
}
|
||||
|
||||
// Verificar se há passos de fluxo vinculados
|
||||
const passosVinculados = await ctx.db
|
||||
.query('flowSteps')
|
||||
.collect();
|
||||
const temPassosVinculados = passosVinculados.some((p) => p.setorId === args.id);
|
||||
if (temPassosVinculados) {
|
||||
throw new Error('Não é possível excluir um setor vinculado a passos de fluxo');
|
||||
}
|
||||
|
||||
await ctx.db.delete(args.id);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user