Initial commit

This commit is contained in:
JuruSysadmin 2026-01-07 17:50:15 -03:00
commit f8c5d6c88e
142 changed files with 24624 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
/drizzle
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

376
README.md Normal file
View File

@ -0,0 +1,376 @@
# 🚀 Ferramenta de Gestão de Projetos
Uma aplicação completa para gerenciamento de projetos com sistema de temporizador, kanban board, organizações e controle de tempo gasto em tarefas.
## 📋 Índice
- [Funcionalidades](#-funcionalidades)
- [Tecnologias Utilizadas](#-tecnologias-utilizadas)
- [Pré-requisitos](#-pré-requisitos)
- [Instalação](#-instalação)
- [Configuração](#-configuração)
- [Como Usar](#-como-usar)
- [Estrutura do Projeto](#-estrutura-do-projeto)
- [API Endpoints](#-api-endpoints)
- [Deploy](#-deploy)
## ✨ Funcionalidades
### 🔐 **Sistema de Autenticação**
- Login via GitHub OAuth
- Sessões persistentes
- Controle de acesso baseado em autenticação
- Logout seguro
### 🏢 **Gestão de Organizações**
- Criar e gerenciar organizações
- Adicionar/remover membros
- Controle de permissões (Owner, Admin, Member)
- Seleção de organização ativa
- Persistência da organização selecionada
### 📊 **Dashboard Principal**
- Visão geral de projetos ativos
- Estatísticas em tempo real:
- Projetos ativos
- Cards ativos
- Cards concluídos
- Horas estimadas vs. horas produzidas
- Filtros por status e busca por nome
- Indicadores visuais de progresso
### 📋 **Gestão de Projetos**
- Criar novos projetos
- Definir datas de início e fim
- Configurar URLs de repositório
- Adicionar membros aos projetos
- Editar informações do projeto
- Visualizar estatísticas detalhadas
### 🎯 **Sistema Kanban**
- **5 Status**: A fazer, Em progresso, Em revisão, Testando, Concluído
- **Drag & Drop**: Arrastar cards entre colunas
- **Cores por Status**: Identificação visual rápida
- **Ordenação**: Reorganizar cards dentro das colunas
- **Criação de Cards**: Modal com campos essenciais
- **Edição de Cards**: Interface completa para modificação
### ⏱️ **Sistema de Temporizador**
- **Controle Único**: Apenas um card ativo por vez
- **Temporizador Global**: Exibição centralizada no header
- **Controles**: Play, Pause, Stop
- **Persistência**: Tempo salvo automaticamente no banco
- **Indicadores Visuais**: Card ativo destacado
- **Notificações**: Feedback ao finalizar atividades
- **Histórico**: Visualização de tempo gasto por card
### 📝 **Gestão de Cards**
- **Campos Obrigatórios**: Nome, descrição, status, horas estimadas
- **Adição Manual de Tempo**: Inserir tempo gasto manualmente
- **Histórico de Tempo**: Visualizar todos os registros de tempo
- **Edição Completa**: Modificar todos os campos
- **Validações**: Controle de dados obrigatórios
### 👥 **Gestão de Membros**
- Adicionar usuários aos projetos
- Visualizar membros com avatares
- Controle de permissões por projeto
- Integração com sistema de organizações
### 📈 **Estatísticas e Relatórios**
- Tempo total gasto por projeto
- Comparação entre horas estimadas e reais
- Indicadores visuais de progresso
- Estatísticas por organização
## 🛠️ Tecnologias Utilizadas
### **Frontend**
- **Next.js 15.4.1** - Framework React
- **React 19.1.0** - Biblioteca de interface
- **TypeScript** - Tipagem estática
- **Tailwind CSS** - Framework de estilização
- **Radix UI** - Componentes acessíveis
- **Lucide React** - Ícones
- **Sonner** - Notificações
### **Backend**
- **Next.js API Routes** - API REST
- **Better Auth** - Autenticação
- **Drizzle ORM** - ORM para banco de dados
- **PostgreSQL** - Banco de dados principal
### **Banco de Dados**
- **Neon Database** - PostgreSQL serverless
- **Drizzle Kit** - Migrações e schema
### **Ferramentas**
- **ESLint** - Linting
- **Prettier** - Formatação de código
- **Git** - Controle de versão
## 📋 Pré-requisitos
- **Node.js** 18+
- **npm** ou **yarn**
- **PostgreSQL** (ou Neon Database)
- **Conta GitHub** (para OAuth)
## 🚀 Instalação
1. **Clone o repositório**
```bash
git clone <url-do-repositorio>
cd ferramenta-projeto
```
2. **Instale as dependências**
```bash
npm install
```
3. **Configure as variáveis de ambiente**
```bash
cp .env.example .env.local
```
4. **Configure o banco de dados**
```bash
npm run db:generate
npm run db:push
```
5. **Inicie o servidor de desenvolvimento**
```bash
npm run dev
```
## ⚙️ Configuração
### Variáveis de Ambiente
Crie um arquivo `.env.local` com as seguintes variáveis:
```env
# Database
DATABASE_URL="postgresql://user:password@host:port/database"
# GitHub OAuth
GITHUB_CLIENT_ID="seu_github_client_id"
GITHUB_CLIENT_SECRET="seu_github_client_secret"
# Auth
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="http://localhost:3000"
# CORS (opcional)
NEXT_PUBLIC_CORS_ORIGIN="http://localhost:3000"
```
### Configuração do GitHub OAuth
1. Acesse [GitHub Developer Settings](https://github.com/settings/developers)
2. Crie uma nova OAuth App
3. Configure:
- **Homepage URL**: `http://localhost:3000`
- **Authorization callback URL**: `http://localhost:3000/api/auth/callback/github`
4. Copie o Client ID e Client Secret para o `.env.local`
## 📖 Como Usar
### 1. **Primeiro Acesso**
1. Acesse `http://localhost:3000`
2. Clique em "Entrar com GitHub"
3. Autorize o acesso à sua conta GitHub
4. Você será redirecionado para o dashboard
### 2. **Criar Organização**
1. No dashboard, clique em "Organizações"
2. Clique em "Nova Organização"
3. Preencha nome e descrição
4. Clique em "Criar"
### 3. **Criar Projeto**
1. Selecione uma organização
2. Clique em "Novo Projeto"
3. Preencha os dados do projeto
4. Clique em "Criar"
### 4. **Gerenciar Cards**
1. Acesse um projeto
2. Clique em "Novo Card" para criar
3. Preencha nome, descrição, status e horas estimadas
4. Use drag & drop para mover cards entre colunas
### 5. **Usar o Temporizador**
1. Clique no botão ▶️ em qualquer card
2. O temporizador aparecerá no header
3. Use ⏸️ para pausar e ▶️ para retomar
4. Use ⏹️ para finalizar e salvar o tempo
### 6. **Editar Cards**
1. Clique em qualquer card para editar
2. Modifique os campos necessários
3. Adicione tempo manualmente se necessário
4. Visualize o histórico de tempo na aba "Tempo"
## 📁 Estrutura do Projeto
```
src/
├── app/ # App Router (Next.js 13+)
│ ├── (controle)/ # Rotas protegidas
│ │ ├── dashboard/ # Dashboard principal
│ │ ├── organizations/ # Gestão de organizações
│ │ └── project/[id]/ # Página do projeto
│ ├── api/ # API Routes
│ │ ├── auth/ # Autenticação
│ │ ├── createCard/ # Criar cards
│ │ ├── getProject/ # Buscar projetos
│ │ ├── organizations/ # Gestão de organizações
│ │ ├── saveTimeSpent/ # Salvar tempo
│ │ ├── updateCard/ # Atualizar cards
│ │ └── users/ # Gestão de usuários
│ ├── login/ # Página de login
│ └── page.tsx # Página inicial
├── components/ # Componentes reutilizáveis
│ ├── ui/ # Componentes base (Radix UI)
│ └── *.tsx # Componentes customizados
├── context/ # Contextos React
│ ├── authContext.tsx # Contexto de autenticação
│ ├── organizationContext.tsx # Contexto de organizações
│ └── timerContext.tsx # Contexto do temporizador
├── db/ # Configuração do banco
│ ├── index.ts # Conexão com banco
│ └── schema.ts # Schema do banco
├── lib/ # Utilitários e configurações
├── types/ # Tipos TypeScript
└── utils/ # Funções utilitárias
```
## 🔌 API Endpoints
### **Autenticação**
- `POST /api/auth/signin` - Login
- `POST /api/auth/signout` - Logout
- `GET /api/auth/get-session` - Verificar sessão
### **Organizações**
- `GET /api/organizations` - Listar organizações
- `POST /api/organizations` - Criar organização
- `PUT /api/organizations/[id]` - Editar organização
- `DELETE /api/organizations/[id]` - Remover organização
- `GET /api/organizations/[id]/members` - Listar membros
- `POST /api/organizations/[id]/members` - Adicionar membro
- `DELETE /api/organizations/[id]/members/[userId]` - Remover membro
### **Projetos**
- `GET /api/getProject` - Listar projetos
- `POST /api/getProject` - Criar projeto
- `GET /api/getProject/[id]` - Buscar projeto específico
### **Cards**
- `POST /api/createCard` - Criar card
- `PUT /api/updateCard` - Atualizar card
- `PUT /api/updateCardStatus` - Atualizar status
- `PUT /api/updateCardOrder` - Atualizar ordem
### **Tempo**
- `POST /api/saveTimeSpent` - Salvar tempo gasto
- `GET /api/getTimeSpentHistory/[cardId]` - Histórico de tempo
### **Usuários**
- `GET /api/users` - Listar usuários
## 🚀 Deploy
### **Vercel (Recomendado)**
1. **Conecte o repositório ao Vercel**
2. **Configure as variáveis de ambiente**:
- `DATABASE_URL`
- `GITHUB_CLIENT_ID`
- `GITHUB_CLIENT_SECRET`
- `BETTER_AUTH_URL`
- `NEXT_PUBLIC_API_URL`
3. **Deploy automático**
```bash
git push origin main
```
### **Outras Plataformas**
O projeto pode ser deployado em qualquer plataforma que suporte Next.js:
- **Netlify**
- **Railway**
- **Heroku**
- **DigitalOcean App Platform**
## 🛠️ Comandos Úteis
```bash
# Desenvolvimento
npm run dev # Iniciar servidor de desenvolvimento
npm run build # Build para produção
npm run start # Iniciar servidor de produção
npm run lint # Executar ESLint
# Banco de dados
npm run db:generate # Gerar migrações
npm run db:push # Aplicar migrações
npm run db:studio # Abrir Drizzle Studio
```
## 🤝 Contribuição
1. Fork o projeto
2. Crie uma branch para sua feature (`git checkout -b feature/AmazingFeature`)
3. Commit suas mudanças (`git commit -m 'Add some AmazingFeature'`)
4. Push para a branch (`git push origin feature/AmazingFeature`)
5. Abra um Pull Request
## 📄 Licença
Este projeto está sob a licença MIT. Veja o arquivo `LICENSE` para mais detalhes.
## 🆘 Suporte
Para suporte, abra uma issue no repositório ou entre em contato através dos canais disponíveis.
---
**Desenvolvido com ❤️ para facilitar a gestão de projetos e controle de tempo.**

65
auth-schema.ts Normal file
View File

@ -0,0 +1,65 @@
import { boolean, pgEnum, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const userRoleEnum = pgEnum('user_role', ['admin', 'user']);
export const userTable = pgTable('user_table', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified')
.$defaultFn(() => false)
.notNull(),
role: userRoleEnum('role').default('user'),
isActive: boolean('is_active').default(true),
image: text('image'),
createdAt: timestamp('created_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp('updated_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const sessionTable = pgTable('session_table', {
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => userTable.id, { onDelete: 'cascade' }),
});
export const accountTable = pgTable('account_table', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => userTable.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const verification = pgTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').$defaultFn(
() => /* @__PURE__ */ new Date()
),
updatedAt: timestamp('updated_at').$defaultFn(
() => /* @__PURE__ */ new Date()
),
});

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

18
drizzle.config.ts Normal file
View File

@ -0,0 +1,18 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
schemaFilter: [process.env.NEXT_PUBLIC_DB_SCHEMA!],
dbCredentials: {
host: process.env.NEXT_PUBLIC_DB_HOST!,
port: Number(process.env.NEXT_PUBLIC_DB_PORT!),
user: process.env.NEXT_PUBLIC_DB_USER!,
password: process.env.NEXT_PUBLIC_DB_PASS!,
database: process.env.NEXT_PUBLIC_DB_NAME!,
ssl: { rejectUnauthorized: false },
},
});

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

41
next.config.ts Normal file
View File

@ -0,0 +1,41 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
],
},
// Configurações de CORS para APIs
async headers() {
return [
{
// Aplicar a todas as rotas da API
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{
key: 'Access-Control-Allow-Methods',
value: 'GET,POST,PUT,DELETE,OPTIONS,PATCH',
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization, X-Requested-With',
},
],
},
];
},
};
export default nextConfig;

9456
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

67
package.json Normal file
View File

@ -0,0 +1,67 @@
{
"name": "ferramenta-projeto",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 8063",
"start:dev": "dotenv -e .env.dev -- next dev -p 8063",
"build": "next build",
"start": "next start -p 8063",
"lint": "next lint",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:push:dev": "dotenv -e .env.dev -- drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:studio:dev": "dotenv -e .env.dev -- drizzle-kit studio"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@neondatabase/serverless": "^1.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"better-auth": "^1.3.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.4",
"lucide-react": "^0.540.0",
"next": "^15.5.9",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-resizable-panels": "^3.0.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tunnel-rat": "^0.1.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.15.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.18.1",
"eslint": "^9",
"eslint-config-next": "15.4.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
public/loading.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
public/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,13 @@
-- Script para criar o schema de desenvolvimento
-- Execute este script no banco 'projeto' para criar o schema 'dev'
-- Criar o schema dev se não existir
CREATE SCHEMA IF NOT EXISTS dev;
-- Definir o schema padrão para a sessão (opcional)
-- SET search_path TO dev, public;
-- Verificar se o schema foi criado
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name = 'dev';

View File

@ -0,0 +1,181 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Project } from '@/types/Project';
import { User } from '@/types/User';
import { Loader2, PlusIcon, Users } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
interface AddProjectMemberDialogProps {
project: Project;
availableUsers: User[];
onMemberAdded: () => void;
}
export function AddProjectMemberDialog({
project,
availableUsers,
onMemberAdded,
}: AddProjectMemberDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState('');
// Filtrar usuários que ainda não estão no projeto
const currentProjectUserIds = project.users?.map((user) => user.id) || [];
const availableUsersForProject = availableUsers.filter(
(user) => !currentProjectUserIds.includes(user.id)
);
const handleAddMember = async () => {
if (!selectedUser) {
toast.error('Selecione um usuário para adicionar');
return;
}
setIsLoading(true);
try {
const response = await fetch(`/api/getProject/${project.id}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: selectedUser,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erro ao adicionar membro');
}
await response.json();
toast.success('Membro adicionado com sucesso!');
// Resetar seleção
setSelectedUser('');
// Fechar dialog
setIsOpen(false);
// Chamar callback para atualizar a lista
onMemberAdded();
} catch (error) {
console.error('Erro ao adicionar membro:', error);
toast.error(
error instanceof Error ? error.message : 'Erro ao adicionar membro'
);
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setSelectedUser('');
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="absolute top-2 right-12 z-10"
>
<Users className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Adicionar Membro ao Projeto</DialogTitle>
<p className="text-sm text-muted-foreground">
Adicione um novo membro ao projeto {project.name}
</p>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Selecionar Usuário</label>
<Select value={selectedUser} onValueChange={setSelectedUser}>
<SelectTrigger>
<SelectValue placeholder="Escolha um usuário para adicionar" />
</SelectTrigger>
<SelectContent>
{availableUsersForProject.length === 0 ? (
<SelectItem value="no-users" disabled>
Nenhum usuário disponível
</SelectItem>
) : (
availableUsersForProject.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} - {user.email}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{availableUsersForProject.length === 0 && (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">
Todos os usuários da organização estão neste projeto.
</p>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={isLoading}>
Cancelar
</Button>
</DialogClose>
<Button
onClick={handleAddMember}
disabled={
isLoading ||
!selectedUser ||
availableUsersForProject.length === 0
}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Adicionando...
</>
) : (
<>
<PlusIcon className="w-4 h-4 mr-2" />
Adicionar Membro
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,429 @@
'use client';
import { Calendario } from '@/components/Calendario';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Organization, User } from '@/types/User';
import { Loader2, PlusIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
interface CreateProjectDialogProps {
selectedOrganization: Organization | null;
uniqueUsers: User[];
onCreateProject: () => void;
}
interface ProjectFormData {
name: string;
key: string;
description: string;
projectLeader: string;
status: string;
repository: string;
startDate: Date | undefined;
endDate: Date | undefined;
}
interface FormErrors {
name?: string;
key?: string;
description?: string;
projectLeader?: string;
status?: string;
repository?: string;
startDate?: string;
endDate?: string;
}
export function CreateProjectDialog({
selectedOrganization,
uniqueUsers,
onCreateProject,
}: CreateProjectDialogProps) {
const startDateRef = useRef<{ getValue: () => Date | undefined }>(null);
const endDateRef = useRef<{ getValue: () => Date | undefined }>(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState<ProjectFormData>({
name: '',
key: '',
description: '',
projectLeader: '',
status: '1', // Ativo por padrão
repository: '',
startDate: undefined,
endDate: undefined,
});
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// Validação do nome
if (!formData.name.trim()) {
newErrors.name = 'Nome do projeto é obrigatório';
} else if (formData.name.trim().length < 3) {
newErrors.name = 'Nome deve ter pelo menos 3 caracteres';
}
// Validação da chave
if (!formData.key.trim()) {
newErrors.key = 'Chave do projeto é obrigatória';
} else if (formData.key.trim().length < 2) {
newErrors.key = 'Chave deve ter pelo menos 2 caracteres';
} else if (!/^[A-Z0-9]+$/.test(formData.key.trim())) {
newErrors.key = 'Chave deve conter apenas letras maiúsculas e números';
}
// Validação da descrição
if (!formData.description.trim()) {
newErrors.description = 'Descrição do projeto é obrigatória';
} else if (formData.description.trim().length < 10) {
newErrors.description = 'Descrição deve ter pelo menos 10 caracteres';
}
// Validação do líder do projeto
if (!formData.projectLeader) {
newErrors.projectLeader = 'Líder do projeto é obrigatório';
}
// Validação do status
if (!formData.status) {
newErrors.status = 'Status do projeto é obrigatório';
}
// Validação do repositório
if (!formData.repository) {
newErrors.repository = 'Repositório é obrigatório';
}
// Validação das datas
const startDate = startDateRef.current?.getValue();
const endDate = endDateRef.current?.getValue();
if (!startDate) {
newErrors.startDate = 'Data de início é obrigatória';
}
if (startDate && endDate && startDate > endDate) {
newErrors.endDate = 'Data de término deve ser posterior à data de início';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleInputChange = (field: keyof ProjectFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Limpar erro do campo quando o usuário começar a digitar
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleCreateProject = async () => {
if (!validateForm()) {
toast.error('Por favor, corrija os erros no formulário');
return;
}
if (!selectedOrganization) {
toast.error('Selecione uma organização no cabeçalho para criar projetos');
return;
}
const startDate = startDateRef.current?.getValue();
const endDate = endDateRef.current?.getValue();
if (!startDate) {
toast.error('Data de início é obrigatória');
return;
}
setIsLoading(true);
try {
const projectData = {
name: formData.name.trim(),
description: formData.description.trim(),
projectUrl: '', // Será preenchido quando a integração com GitHub estiver pronta
initialDate: startDate.toISOString(),
finalDate: endDate ? endDate.toISOString() : null,
organizationId: selectedOrganization.id,
keyRepository: formData.key.trim(),
status: formData.status === '1' ? 'active' : 'inactive',
repositoryUrl: formData.repository || '',
};
const response = await fetch('/api/getProject', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(projectData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erro ao criar projeto');
}
await response.json();
toast.success('Projeto criado com sucesso!');
// Resetar formulário
setFormData({
name: '',
key: '',
description: '',
projectLeader: '',
status: '1',
repository: '',
startDate: undefined,
endDate: undefined,
});
// Fechar dialog
setIsOpen(false);
// Chamar callback para atualizar a lista de projetos
onCreateProject();
} catch (error) {
console.error('Erro ao criar projeto:', error);
toast.error(
error instanceof Error ? error.message : 'Erro ao criar projeto'
);
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
// Resetar erros quando fechar o dialog
setErrors({});
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="w-4 h-4 mr-2" />
Novo Projeto
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Novo Projeto</DialogTitle>
<p className="text-sm text-muted-foreground">
Crie um novo projeto para começar a trabalhar!
</p>
</DialogHeader>
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-name">Nome do Projeto *</Label>
<Input
id="project-name"
placeholder="Nome do Projeto"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className={errors.name ? 'border-red-500' : ''}
/>
{errors.name && (
<span className="text-sm text-red-500">{errors.name}</span>
)}
</div>
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-key">Chave do Projeto *</Label>
<Input
id="project-key"
placeholder="Ex: DEV"
value={formData.key}
onChange={(e) =>
handleInputChange('key', e.target.value.toUpperCase())
}
className={errors.key ? 'border-red-500' : ''}
/>
{errors.key && (
<span className="text-sm text-red-500">{errors.key}</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="w-full flex flex-col gap-2">
<Label htmlFor="project-description">
Descrição do Projeto *
</Label>
<Textarea
id="project-description"
placeholder="Descreva o objetivo e escopo do projeto..."
value={formData.description}
onChange={(e) =>
handleInputChange('description', e.target.value)
}
className={errors.description ? 'border-red-500' : ''}
rows={3}
/>
{errors.description && (
<span className="text-sm text-red-500">
{errors.description}
</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-full">
<Label>Organização</Label>
<div className="p-3 border rounded-md bg-muted/50">
<span className="text-sm font-medium">
{selectedOrganization?.name ||
'Nenhuma organização selecionada'}
</span>
</div>
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-leader">Líder do Projeto *</Label>
<Select
value={formData.projectLeader}
onValueChange={(value) =>
handleInputChange('projectLeader', value)
}
>
<SelectTrigger
id="project-leader"
className={errors.projectLeader ? 'border-red-500' : ''}
>
<SelectValue placeholder="Selecione o Líder do Projeto" />
</SelectTrigger>
<SelectContent>
{uniqueUsers.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} - {user.email}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.projectLeader && (
<span className="text-sm text-red-500">
{errors.projectLeader}
</span>
)}
</div>
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-status">Status do Projeto *</Label>
<Select
value={formData.status}
onValueChange={(value) => handleInputChange('status', value)}
>
<SelectTrigger
id="project-status"
className={errors.status ? 'border-red-500' : ''}
>
<SelectValue placeholder="Selecione o Status do Projeto" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Ativo</SelectItem>
<SelectItem value="2">Inativo</SelectItem>
</SelectContent>
</Select>
{errors.status && (
<span className="text-sm text-red-500">{errors.status}</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-full">
<Label htmlFor="project-repository">Repositório *</Label>
<Select
value={formData.repository}
onValueChange={(value) =>
handleInputChange('repository', value)
}
>
<SelectTrigger
id="project-repository"
className={errors.repository ? 'border-red-500' : ''}
>
<SelectValue placeholder="Selecione o Repositório para o projeto" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder">
Carregando repositórios...
</SelectItem>
</SelectContent>
</Select>
{errors.repository && (
<span className="text-sm text-red-500">
{errors.repository}
</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-1/2">
<Calendario ref={startDateRef} label="Data de Início *" />
{errors.startDate && (
<span className="text-sm text-red-500">{errors.startDate}</span>
)}
</div>
<div className="flex flex-col gap-2 w-1/2">
<Calendario ref={endDateRef} label="Data de Término" />
{errors.endDate && (
<span className="text-sm text-red-500">{errors.endDate}</span>
)}
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={isLoading}>
Cancelar
</Button>
</DialogClose>
<Button onClick={handleCreateProject} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Criando...
</>
) : (
'Criar Projeto'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,277 @@
'use client';
import { useOrganization } from '@/context/organizationContext';
import useAuth from '@/hook/useAuth';
import { useOrganizationSync } from '@/hooks/use-mobile';
import { authClient } from '@/lib/auth-client';
import { Project } from '@/types/Project';
import { OrganizationMember, User } from '@/types/User';
import { useEffect, useRef, useState } from 'react';
import { CreateProjectDialog } from './CreateProjectDialog';
import { DashboardFilters } from './DashboardFilters';
import { DashboardHeader } from './DashboardHeader';
import { DashboardProjects } from './DashboardProjects';
import { DashboardStats } from './DashboardStats';
export function DashboardClient() {
const { isAuthenticated } = useAuth();
const { selectedOrganization } = useOrganization();
const isOrganizationInitialized = useOrganizationSync();
const [projects, setProjects] = useState<Project[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [uniqueUsers, setUniqueUsers] = useState<User[]>([]);
// Ref para controlar se os dados foram carregados para a organização atual
const lastLoadedOrgId = useRef<string | null>(null);
// Função auxiliar para calcular estatísticas com valores padrão
const calculateStats = () => {
if (!projects || projects.length === 0) {
return {
totalProjects: 0,
activeProjects: 0,
activeCards: 0,
completedCards: 0,
estimatedHours: 0,
spentHours: 0,
};
}
return {
totalProjects: projects.length,
activeProjects: projects.filter((project) => project.status === 'active')
.length,
activeCards: projects.reduce((acc, project) => {
return (
acc +
(project.cards?.filter((card) => card.status !== 'Concluido')
.length || 0)
);
}, 0),
completedCards: projects.reduce((acc, project) => {
return (
acc +
(project.cards?.filter((card) => card.status === 'Concluido')
.length || 0)
);
}, 0),
estimatedHours: projects.reduce((acc, project) => {
return (
acc +
(project.cards?.reduce((cardAcc, card) => {
return cardAcc + (card.hours || 0);
}, 0) || 0)
);
}, 0),
spentHours: projects.reduce((acc, project) => {
return (
acc +
(project.cards?.reduce((cardAcc, card) => {
return cardAcc + (card.timeSpent || 0);
}, 0) || 0)
);
}, 0),
};
};
// Função para obter usuários únicos de todos os projetos
const getUniqueUsers = () => {
if (!projects) return [];
const allUsers = new Map();
projects.forEach((project) => {
project.users?.forEach((user) => {
if (!allUsers.has(user.id)) {
allUsers.set(user.id, user);
}
});
});
return Array.from(allUsers.values());
};
// Função para buscar membros da organização
const getOrganizationMembers = async (organizationId: string) => {
try {
const response = await fetch(
`/api/organizations/${organizationId}/members`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data || [];
} catch (error) {
console.error('Erro ao buscar membros da organização:', error);
return [];
}
};
// Função para obter membros da organização selecionada
const getOrganizationUsers = async () => {
if (!selectedOrganization) return [];
try {
const members = await getOrganizationMembers(selectedOrganization.id);
return members
.map((member: OrganizationMember) => member.user)
.filter(Boolean);
} catch (error) {
console.error('Erro ao buscar usuários da organização:', error);
return [];
}
};
// Função para buscar projetos
const getProjects = async (): Promise<Project[]> => {
try {
const url = new URL('/api/getProject', window.location.origin);
if (selectedOrganization?.id) {
url.searchParams.set('organizationId', selectedOrganization.id);
}
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data || [];
} catch (error) {
console.error('Erro ao buscar projetos:', error);
return [];
}
};
// Função principal para carregar dados
const loadDashboardData = async (forceRefresh = false) => {
try {
// Verificar se já carregamos dados para esta organização
if (
!forceRefresh &&
lastLoadedOrgId.current === selectedOrganization?.id
) {
return;
}
if (!isRefreshing) {
setIsLoading(true);
}
const session = await authClient.getSession();
if (session?.data?.user && selectedOrganization) {
const projectsData = await getProjects();
setProjects(projectsData);
// Buscar usuários da organização
const orgUsers = await getOrganizationUsers();
setUniqueUsers(orgUsers);
// Marcar que carregamos dados para esta organização
lastLoadedOrgId.current = selectedOrganization.id;
} else {
// Limpar dados se não há organização selecionada
setProjects(null);
setUniqueUsers([]);
lastLoadedOrgId.current = null;
}
} catch (error) {
console.error('Erro ao buscar dados do usuário:', error);
// Em caso de erro, limpar dados para evitar mostrar dados incorretos
setProjects(null);
setUniqueUsers([]);
lastLoadedOrgId.current = null;
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
};
// Carregar dados quando a autenticação ou organização mudar
useEffect(() => {
if (isAuthenticated && selectedOrganization && isOrganizationInitialized) {
loadDashboardData();
} else if (
isAuthenticated &&
!selectedOrganization &&
isOrganizationInitialized
) {
// Se está autenticado mas não há organização selecionada, limpar dados
setProjects(null);
setUniqueUsers([]);
lastLoadedOrgId.current = null;
setIsLoading(false);
setIsRefreshing(false);
} else if (!isAuthenticated) {
setIsLoading(false);
setIsRefreshing(false);
}
}, [isAuthenticated, selectedOrganization?.id, isOrganizationInitialized]);
// Detectar mudanças na organização e mostrar estado de atualização
useEffect(() => {
if (selectedOrganization && isAuthenticated && isOrganizationInitialized) {
setIsRefreshing(true);
loadDashboardData(true); // Forçar recarregamento
}
}, [selectedOrganization?.id, isOrganizationInitialized]);
const stats = calculateStats();
const handleCreateProject = async () => {
// Recarregar projetos após criar um novo
try {
setIsRefreshing(true);
await loadDashboardData(true);
} catch (error) {
console.error('Erro ao recarregar projetos:', error);
}
};
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-2">
<header className="flex justify-between items-center">
<DashboardHeader isRefreshing={isRefreshing} />
<CreateProjectDialog
selectedOrganization={selectedOrganization}
uniqueUsers={uniqueUsers}
onCreateProject={handleCreateProject}
/>
</header>
<main className="flex flex-col gap-6">
{/* Cards de Estatísticas */}
<DashboardStats
stats={stats}
isLoading={isLoading}
selectedOrganization={selectedOrganization}
/>
{/* Filtros e Busca */}
<DashboardFilters
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
selectedOrganization={selectedOrganization}
/>
{/* Lista de Projetos */}
<DashboardProjects
projects={projects}
isLoading={isLoading}
searchTerm={searchTerm}
statusFilter={statusFilter}
selectedOrganization={selectedOrganization}
uniqueUsers={uniqueUsers}
onProjectUpdated={handleCreateProject}
/>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
'use client';
import BarraBusca from '@/components/BarraBusca';
import Seletor from '@/components/Seletor';
import { Organization } from '@/types/User';
interface DashboardFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
statusFilter: string;
onStatusFilterChange: (value: string) => void;
selectedOrganization: Organization | null;
}
export function DashboardFilters({
searchTerm,
onSearchChange,
statusFilter,
onStatusFilterChange,
selectedOrganization,
}: DashboardFiltersProps) {
if (!selectedOrganization) return null;
return (
<div className="flex gap-2">
<BarraBusca
searchTerm={searchTerm}
onSearchChange={onSearchChange}
placeHolder="Buscar projeto..."
/>
<div className="flex gap-2">
<Seletor
placheHolder="Status"
value={statusFilter}
onValueChange={onStatusFilterChange}
options={[
{ value: 'all', label: 'Todos' },
{ value: 'active', label: 'Ativo' },
{ value: 'inactive', label: 'Inativo' },
]}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
interface DashboardHeaderProps {
isRefreshing?: boolean;
}
export function DashboardHeader({ isRefreshing }: DashboardHeaderProps) {
return (
<div>
<h1 className="text-3xl font-bold text-foreground">
Dashboard de Projetos
</h1>
<p className="text-md text-muted-foreground">
Gerencie seus projetos, sprints e tarefas!
{isRefreshing && (
<span className="ml-2 text-sm text-blue-500">
Atualizando dados da organização...
</span>
)}
</p>
</div>
);
}

View File

@ -0,0 +1,54 @@
import ProjectList from '@/components/ProjectList';
import { Project } from '@/types/Project';
import { Organization, User } from '@/types/User';
interface DashboardProjectsProps {
projects: Project[] | null;
isLoading: boolean;
searchTerm: string;
statusFilter: string;
selectedOrganization: Organization | null;
uniqueUsers: User[];
onProjectUpdated: () => void;
}
export function DashboardProjects({
projects,
isLoading,
searchTerm,
statusFilter,
selectedOrganization,
uniqueUsers,
onProjectUpdated,
}: DashboardProjectsProps) {
return (
<div>
<h2 className="text-xl font-semibold text-foreground mb-4">
Meus Projetos
{selectedOrganization && (
<span className="text-sm font-normal text-muted-foreground ml-2">
- {selectedOrganization.name}
</span>
)}
</h2>
{!selectedOrganization ? (
<div className="text-center py-8">
<p className="text-muted-foreground">
Selecione uma organização no cabeçalho para visualizar os projetos.
</p>
</div>
) : (
<ProjectList
projects={projects}
isLoading={isLoading}
searchTerm={searchTerm}
statusFilter={statusFilter}
selectedOrganization={selectedOrganization}
uniqueUsers={uniqueUsers}
onProjectUpdated={onProjectUpdated}
/>
)}
</div>
);
}

View File

@ -0,0 +1,81 @@
import CardDash from '@/components/CardDash';
import { Organization } from '@/types/User';
import { BarChart3Icon } from 'lucide-react';
interface DashboardStatsProps {
stats: {
totalProjects: number;
activeProjects: number;
activeCards: number;
completedCards: number;
estimatedHours: number;
spentHours: number;
};
isLoading: boolean;
selectedOrganization: Organization | null;
}
export function DashboardStats({
stats,
isLoading,
selectedOrganization,
}: DashboardStatsProps) {
// Verificar se os dados estão sendo carregados para a organização correta
const isDataLoading = isLoading || !selectedOrganization;
return (
<div className="space-y-4">
{/* Indicador da organização atual */}
{selectedOrganization && (
<div className="text-sm text-muted-foreground">
Estatísticas para:{' '}
<span className="font-medium text-foreground">
{selectedOrganization.name}
</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<CardDash
title="Projetos Ativos"
icon={<BarChart3Icon className="w-4 h-4" />}
value={stats.activeProjects}
description="Projetos Ativados"
calculate={() => stats.activeProjects}
isLoading={isDataLoading}
/>
<CardDash
title="Cards Ativos"
icon={<BarChart3Icon className="w-4 h-4" />}
value={stats.activeCards}
description="Cards Ativos"
isLoading={isDataLoading}
/>
<CardDash
title="Cards Concluidos"
icon={<BarChart3Icon className="w-4 h-4" />}
value={stats.completedCards}
description="Cards Concluido"
isLoading={isDataLoading}
/>
<CardDash
title="Horas Estimadas"
icon={<BarChart3Icon className="w-4 h-4" />}
value={stats.estimatedHours}
description="Horas Estimadas"
isLoading={isDataLoading}
/>
<CardDash
title="Horas Produzidas"
icon={<BarChart3Icon className="w-4 h-4" />}
caculavel={true}
valueProject={stats.estimatedHours}
value={stats.spentHours}
description="Horas Produzidas"
isLoading={isDataLoading}
formatAsTime={true}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,550 @@
'use client';
import { Calendario } from '@/components/Calendario';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Project } from '@/types/Project';
import { Organization, User } from '@/types/User';
import { Loader2, PencilIcon, TrashIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
interface EditProjectDialogProps {
project: Project;
selectedOrganization: Organization | null;
uniqueUsers: User[];
onProjectUpdated: () => void;
}
interface ProjectFormData {
name: string;
key: string;
description: string;
projectLeader: string;
status: string;
repository: string;
startDate: Date | undefined;
endDate: Date | undefined;
}
interface FormErrors {
name?: string;
key?: string;
description?: string;
projectLeader?: string;
status?: string;
repository?: string;
startDate?: string;
endDate?: string;
}
export function EditProjectDialog({
project,
selectedOrganization,
uniqueUsers,
onProjectUpdated,
}: EditProjectDialogProps) {
const startDateRef = useRef<{ getValue: () => Date | undefined }>(null);
const endDateRef = useRef<{ getValue: () => Date | undefined }>(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [projectNameToDelete, setProjectNameToDelete] = useState('');
const [isDeletingProject, setIsDeletingProject] = useState(false);
const [formData, setFormData] = useState<ProjectFormData>({
name: '',
key: '',
description: '',
projectLeader: '',
status: '1',
repository: '',
startDate: undefined,
endDate: undefined,
});
// Carregar dados do projeto quando o dialog abrir
useEffect(() => {
if (isOpen && project) {
setFormData({
name: project.name || '',
key: project.keyRepository || '',
description: project.description || '',
projectLeader: project.users?.[0]?.id || '',
status: project.status === 'active' ? '1' : '2',
repository: project.repositoryUrl || '',
startDate: project.initialDate
? new Date(project.initialDate)
: undefined,
endDate: project.finalDate ? new Date(project.finalDate) : undefined,
});
}
}, [isOpen, project]);
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// Validação do nome
if (!formData.name.trim()) {
newErrors.name = 'Nome do projeto é obrigatório';
} else if (formData.name.trim().length < 3) {
newErrors.name = 'Nome deve ter pelo menos 3 caracteres';
}
// Validação da chave
if (!formData.key.trim()) {
newErrors.key = 'Chave do projeto é obrigatória';
} else if (formData.key.trim().length < 2) {
newErrors.key = 'Chave deve ter pelo menos 2 caracteres';
} else if (!/^[A-Z0-9]+$/.test(formData.key.trim())) {
newErrors.key = 'Chave deve conter apenas letras maiúsculas e números';
}
// Validação da descrição
if (!formData.description.trim()) {
newErrors.description = 'Descrição do projeto é obrigatória';
} else if (formData.description.trim().length < 10) {
newErrors.description = 'Descrição deve ter pelo menos 10 caracteres';
}
// Validação do líder do projeto
if (!formData.projectLeader) {
newErrors.projectLeader = 'Líder do projeto é obrigatório';
}
// Validação do status
if (!formData.status) {
newErrors.status = 'Status do projeto é obrigatório';
}
// Validação do repositório
if (!formData.repository) {
newErrors.repository = 'Repositório é obrigatório';
}
// Validação das datas
const startDate = startDateRef.current?.getValue();
const endDate = endDateRef.current?.getValue();
if (!startDate) {
newErrors.startDate = 'Data de início é obrigatória';
}
if (startDate && endDate && startDate > endDate) {
newErrors.endDate = 'Data de término deve ser posterior à data de início';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleInputChange = (field: keyof ProjectFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Limpar erro do campo quando o usuário começar a digitar
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleUpdateProject = async () => {
if (!validateForm()) {
toast.error('Por favor, corrija os erros no formulário');
return;
}
if (!selectedOrganization) {
toast.error(
'Selecione uma organização no cabeçalho para editar projetos'
);
return;
}
const startDate = startDateRef.current?.getValue();
const endDate = endDateRef.current?.getValue();
if (!startDate) {
toast.error('Data de início é obrigatória');
return;
}
setIsLoading(true);
try {
const projectData = {
name: formData.name.trim(),
description: formData.description.trim(),
projectUrl: '', // Será preenchido quando a integração com GitHub estiver pronta
initialDate: startDate.toISOString(),
finalDate: endDate ? endDate.toISOString() : null,
organizationId: selectedOrganization.id,
keyRepository: formData.key.trim(),
status: formData.status === '1' ? 'active' : 'inactive',
repositoryUrl: formData.repository || '',
};
const response = await fetch(`/api/getProject/${project.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(projectData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erro ao atualizar projeto');
}
await response.json();
toast.success('Projeto atualizado com sucesso!');
// Fechar dialog
setIsOpen(false);
// Chamar callback para atualizar a lista de projetos
onProjectUpdated();
} catch (error) {
console.error('Erro ao atualizar projeto:', error);
toast.error(
error instanceof Error ? error.message : 'Erro ao atualizar projeto'
);
} finally {
setIsLoading(false);
}
};
const handleDeleteProject = async () => {
if (projectNameToDelete !== project.name) {
return;
}
setIsDeletingProject(true);
try {
const response = await fetch(`/api/getProject/${project.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erro ao deletar projeto');
}
await response.json();
toast.success('Projeto deletado com sucesso!');
// Fechar dialogs
setIsDeleteDialogOpen(false);
setIsOpen(false);
setProjectNameToDelete('');
// Chamar callback para atualizar a lista de projetos
onProjectUpdated();
} catch (error) {
console.error('Erro ao deletar projeto:', error);
toast.error(
error instanceof Error ? error.message : 'Erro ao deletar projeto'
);
} finally {
setIsDeletingProject(false);
}
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
// Resetar erros quando fechar o dialog
setErrors({});
}
};
return (
<>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="absolute top-2 right-2 z-10 dark:bg-card dark:hover:bg-muted/90"
>
<PencilIcon className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Editar Projeto</DialogTitle>
<p className="text-sm text-muted-foreground">
Edite as informações do projeto {project.name}
</p>
</DialogHeader>
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-name">Nome do Projeto *</Label>
<Input
id="project-name"
placeholder="Nome do Projeto"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className={errors.name ? 'border-red-500' : ''}
/>
{errors.name && (
<span className="text-sm text-red-500">{errors.name}</span>
)}
</div>
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-key">Chave do Projeto *</Label>
<Input
id="project-key"
placeholder="Ex: DEV"
value={formData.key}
onChange={(e) =>
handleInputChange('key', e.target.value.toUpperCase())
}
className={errors.key ? 'border-red-500' : ''}
/>
{errors.key && (
<span className="text-sm text-red-500">{errors.key}</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="w-full flex flex-col gap-2">
<Label htmlFor="project-description">
Descrição do Projeto *
</Label>
<Textarea
id="project-description"
placeholder="Descreva o objetivo e escopo do projeto..."
value={formData.description}
onChange={(e) =>
handleInputChange('description', e.target.value)
}
className={errors.description ? 'border-red-500' : ''}
rows={3}
/>
{errors.description && (
<span className="text-sm text-red-500">
{errors.description}
</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-full">
<Label>Organização</Label>
<div className="p-3 border rounded-md bg-muted/50">
<span className="text-sm font-medium">
{selectedOrganization?.name ||
'Nenhuma organização selecionada'}
</span>
</div>
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-leader">Líder do Projeto *</Label>
<Select
value={formData.projectLeader}
onValueChange={(value) =>
handleInputChange('projectLeader', value)
}
>
<SelectTrigger
id="project-leader"
className={errors.projectLeader ? 'border-red-500' : ''}
>
<SelectValue placeholder="Selecione o Líder do Projeto" />
</SelectTrigger>
<SelectContent>
{uniqueUsers.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} - {user.email}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.projectLeader && (
<span className="text-sm text-red-500">
{errors.projectLeader}
</span>
)}
</div>
<div className="flex flex-col gap-2 w-1/2">
<Label htmlFor="project-status">Status do Projeto *</Label>
<Select
value={formData.status}
onValueChange={(value) => handleInputChange('status', value)}
>
<SelectTrigger
id="project-status"
className={errors.status ? 'border-red-500' : ''}
>
<SelectValue placeholder="Selecione o Status do Projeto" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Ativo</SelectItem>
<SelectItem value="2">Inativo</SelectItem>
</SelectContent>
</Select>
{errors.status && (
<span className="text-sm text-red-500">{errors.status}</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-full">
<Label htmlFor="project-repository">Repositório *</Label>
<Select
value={formData.repository}
onValueChange={(value) =>
handleInputChange('repository', value)
}
>
<SelectTrigger
id="project-repository"
className={errors.repository ? 'border-red-500' : ''}
>
<SelectValue placeholder="Selecione o Repositório para o projeto" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder">
Carregando repositórios...
</SelectItem>
</SelectContent>
</Select>
{errors.repository && (
<span className="text-sm text-red-500">
{errors.repository}
</span>
)}
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-2 w-1/2">
<Calendario ref={startDateRef} label="Data de Início *" />
{errors.startDate && (
<span className="text-sm text-red-500">
{errors.startDate}
</span>
)}
</div>
<div className="flex flex-col gap-2 w-1/2">
<Calendario ref={endDateRef} label="Data de Término" />
{errors.endDate && (
<span className="text-sm text-red-500">{errors.endDate}</span>
)}
</div>
</div>
</div>
<DialogFooter className="flex justify-between">
<Dialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<DialogTrigger asChild>
<Button variant="destructive" size="sm" className="mr-auto">
<TrashIcon className="w-4 h-4 mr-2" />
Excluir Projeto
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Excluir projeto</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Esta ação não pode ser desfeita. Para confirmar a exclusão
do projeto, digite o nome exato:{' '}
<strong>{project.name}</strong>
</p>
<Label htmlFor="project-name-delete">Nome do projeto:</Label>
<Input
id="project-name-delete"
type="text"
className="w-full"
value={projectNameToDelete}
onChange={(e) => {
setProjectNameToDelete(e.target.value);
}}
placeholder="Digite o nome do projeto"
/>
</div>
<DialogFooter>
<Button
variant="destructive"
size="sm"
disabled={
projectNameToDelete !== project.name || isDeletingProject
}
onClick={handleDeleteProject}
>
{isDeletingProject ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Excluindo...
</>
) : (
'Excluir'
)}
</Button>
<DialogClose asChild>
<Button variant="outline" size="sm">
Cancelar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex gap-2">
<DialogClose asChild>
<Button variant="outline" disabled={isLoading}>
Cancelar
</Button>
</DialogClose>
<Button onClick={handleUpdateProject} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Atualizando...
</>
) : (
'Atualizar Projeto'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,279 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Project } from '@/types/Project';
import { User } from '@/types/User';
import { Loader2, PlusIcon, Trash2, Users } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
interface ProjectMembersDialogProps {
project: Project;
availableUsers: User[];
onMembersChanged: () => void;
}
export function ProjectMembersDialog({
project,
availableUsers,
onMembersChanged,
}: ProjectMembersDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRemoving, setIsRemoving] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState('');
// Filtrar usuários que ainda não estão no projeto
const currentProjectUserIds = project.users?.map((user) => user.id) || [];
const availableUsersForProject = availableUsers.filter(
(user) => !currentProjectUserIds.includes(user.id)
);
const handleAddMember = async () => {
if (!selectedUser) {
toast.error('Selecione um usuário para adicionar');
return;
}
setIsLoading(true);
try {
const response = await fetch(`/api/getProject/${project.id}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: selectedUser,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erro ao adicionar membro');
}
await response.json();
toast.success('Membro adicionado com sucesso!');
// Resetar seleção
setSelectedUser('');
// Chamar callback para atualizar a lista
onMembersChanged();
} catch (error) {
console.error('Erro ao adicionar membro:', error);
toast.error(
error instanceof Error ? error.message : 'Erro ao adicionar membro'
);
} finally {
setIsLoading(false);
}
};
const handleRemoveMember = async (userId: string) => {
setIsRemoving(userId);
try {
const response = await fetch(
`/api/getProject/${project.id}/members?userId=${userId}`,
{
method: 'DELETE',
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erro ao remover membro');
}
await response.json();
toast.success('Membro removido com sucesso!');
// Chamar callback para atualizar a lista
onMembersChanged();
} catch (error) {
console.error('Erro ao remover membro:', error);
toast.error(
error instanceof Error ? error.message : 'Erro ao remover membro'
);
} finally {
setIsRemoving(null);
}
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setSelectedUser('');
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="absolute top-2 right-12 z-10"
>
<Users className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gerenciar Membros do Projeto</DialogTitle>
<p className="text-sm text-muted-foreground">
Visualize e gerencie os membros do projeto {project.name}
</p>
</DialogHeader>
<div className="flex flex-col gap-6">
{/* Lista de Membros Atuais */}
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">
Membros Atuais ({project.users?.length || 0})
</h3>
{!project.users || project.users.length === 0 ? (
<div className="text-center py-4 border rounded-md">
<p className="text-sm text-muted-foreground">
Nenhum membro no projeto ainda.
</p>
</div>
) : (
<div className="space-y-2">
{project.users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex items-center gap-2">
<div>
<Avatar>
<AvatarImage
src={user.image || undefined}
alt={user.name}
/>
<AvatarFallback>
{(() => {
const names = user.name.trim().split(' ');
if (names.length >= 2) {
return `${names[0]
.charAt(0)
.toUpperCase()}${names[1]
.charAt(0)
.toUpperCase()}`;
}
return names[0].charAt(0).toUpperCase();
})()}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-sm text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleRemoveMember(user.id)}
disabled={isRemoving === user.id}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{isRemoving === user.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
))}
</div>
)}
</div>
{/* Adicionar Novo Membro */}
{availableUsersForProject.length > 0 && (
<div className="flex flex-col gap-3 border-t pt-4">
<h3 className="text-sm font-medium">Adicionar Novo Membro</h3>
<div className="flex gap-2">
<div className="flex-1">
<Select value={selectedUser} onValueChange={setSelectedUser}>
<SelectTrigger>
<SelectValue placeholder="Escolha um usuário para adicionar" />
</SelectTrigger>
<SelectContent>
{availableUsersForProject.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} - {user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAddMember}
disabled={isLoading || !selectedUser}
size="sm"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Adicionando...
</>
) : (
<>
<PlusIcon className="w-4 h-4 mr-2" />
Adicionar
</>
)}
</Button>
</div>
</div>
)}
{availableUsersForProject.length === 0 &&
project.users &&
project.users.length > 0 && (
<div className="text-center py-4 border rounded-md">
<p className="text-sm text-muted-foreground">
Todos os usuários da organização estão neste projeto.
</p>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Fechar</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,44 @@
# Dashboard Components
Esta pasta contém os componentes recomponentizados do dashboard, organizados para otimizar a performance e separar responsabilidades entre server-side e client-side.
## Estrutura de Componentes
### Server-Side Components (SSR)
- **DashboardHeader**: Cabeçalho com título e descrição
- **DashboardStats**: Cards de estatísticas dos projetos
- **DashboardProjects**: Seção de listagem de projetos
### Client-Side Components (CSR)
- **DashboardClient**: Componente principal que gerencia estado e requisições
- **DashboardFilters**: Filtros de busca e status
- **CreateProjectDialog**: Diálogo para criação de novos projetos
## Benefícios da Recomponentização
1. **Performance**: Componentes server-side são renderizados no servidor, reduzindo o JavaScript no cliente
2. **SEO**: Melhor indexação para conteúdo estático
3. **Manutenibilidade**: Código mais organizado e responsabilidades bem definidas
4. **Reutilização**: Componentes podem ser facilmente reutilizados em outras partes da aplicação
## Como Usar
O componente principal `DashboardClient` gerencia todo o estado e as requisições de API, enquanto os outros componentes são responsáveis apenas pela renderização da UI.
```tsx
// page.tsx (Server Component)
import { DashboardClient } from './components/DashboardClient';
export default function DashboardPage() {
return <DashboardClient />;
}
```
## Fluxo de Dados
1. `DashboardClient` faz as requisições de API e gerencia o estado
2. Os dados são passados como props para os componentes filhos
3. Componentes server-side renderizam o conteúdo estático
4. Componentes client-side lidam com interações do usuário

View File

@ -0,0 +1,8 @@
export { CreateProjectDialog } from './CreateProjectDialog';
export { DashboardClient } from './DashboardClient';
export { DashboardFilters } from './DashboardFilters';
export { DashboardHeader } from './DashboardHeader';
export { DashboardProjects } from './DashboardProjects';
export { DashboardStats } from './DashboardStats';
export { EditProjectDialog } from './EditProjectDialog';
export { ProjectMembersDialog } from './ProjectMembersDialog';

View File

@ -0,0 +1,5 @@
import { DashboardClient } from './components/DashboardClient';
export default function DashboardPage() {
return <DashboardClient />;
}

View File

@ -0,0 +1,17 @@
import Header from '@/components/Header';
import { AuthProvider } from '@/context/authContext';
import { OrganizationProvider } from '@/context/organizationContext';
import { TimerProvider } from '@/context/timerContext';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<OrganizationProvider>
<TimerProvider>
<Header />
{children}
</TimerProvider>
</OrganizationProvider>
</AuthProvider>
);
}

View File

@ -0,0 +1,103 @@
# Organizações - Estrutura de Componentes
Esta pasta contém a funcionalidade de gerenciamento de organizações, organizada em uma estrutura modular e reutilizável.
## Estrutura de Arquivos
```
organizations/
├── components/
│ ├── OrganizationCard.tsx # Card individual de organização
│ ├── MembersList.tsx # Lista de membros de uma organização
│ ├── AddMemberPopover.tsx # Popover para adicionar membros
│ ├── CreateOrganizationDialog.tsx # Modal de criação de organização
│ ├── OrganizationsList.tsx # Lista de todas as organizações
│ └── index.ts # Exportações dos componentes
├── hooks/
│ ├── useOrganizations.ts # Hook customizado para gerenciar estado
│ └── index.ts # Exportações dos hooks
├── page.tsx # Página principal (refatorada)
└── README.md # Esta documentação
```
## Componentes
### OrganizationCard
Exibe um card individual de organização com:
- Nome da organização
- Lista de membros
- Botões de ação (adicionar membro, editar, remover)
### MembersList
Gerencia a exibição da lista de membros de uma organização:
- Estado de carregamento
- Lista vazia
- Lista com membros e botões de remoção
### AddMemberPopover
Popover para adicionar novos membros:
- Seleção de usuário
- Seleção de função (admin/member)
- Validações e estados de carregamento
### CreateOrganizationDialog
Modal para criação de novas organizações:
- Formulário com nome e descrição
- Validações
- Estados de carregamento
### OrganizationsList
Container principal que renderiza a lista de organizações:
- Estado vazio
- Lista de cards de organizações
## Hooks
### useOrganizations
Hook customizado que centraliza toda a lógica de estado e operações:
- Estados de carregamento
- Dados das organizações e membros
- Funções de CRUD
- Gerenciamento de formulários
## Benefícios da Refatoração
1. **Separação de Responsabilidades**: Cada componente tem uma responsabilidade específica
2. **Reutilização**: Componentes podem ser reutilizados em outras partes da aplicação
3. **Manutenibilidade**: Código mais fácil de manter e testar
4. **Performance**: Melhor otimização de re-renderizações
5. **Legibilidade**: Código mais limpo e organizado
## Uso
A página principal (`page.tsx`) agora é muito mais simples e foca apenas na estrutura de layout, delegando toda a lógica para os hooks e componentes especializados.
```tsx
// Exemplo de uso do hook
const {
organizations,
isLoading,
handleCreateOrganization,
handleAddMember,
// ... outros estados e funções
} = useOrganizations();
```
## Próximos Passos
- Implementar testes unitários para os componentes
- Adicionar funcionalidades de edição e remoção de organizações
- Implementar cache de dados para melhor performance
- Adicionar validações mais robustas

View File

@ -0,0 +1,239 @@
'use client';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { User } from '@/types/User';
import { Loader2, UserPlusIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
interface AddMemberPopoverProps {
organizationId: string;
onAddMember: (organizationId: string) => Promise<void>;
users: User[];
isLoadingUsers: boolean;
isAddingMember: boolean;
selectedUser: string;
setSelectedUser: (value: string) => void;
selectedRole: 'admin' | 'member';
setSelectedRole: (value: 'admin' | 'member') => void;
openPopoverId: string | null;
setOpenPopoverId: (value: string | null) => void;
currentMemberIds: string[];
projectCount?: number;
emailListChanged: number;
}
export function AddMemberPopover({
organizationId,
onAddMember,
users,
isLoadingUsers,
isAddingMember,
selectedUser,
setSelectedUser,
selectedRole,
setSelectedRole,
openPopoverId,
setOpenPopoverId,
currentMemberIds,
projectCount,
emailListChanged,
}: AddMemberPopoverProps) {
const [allowedUsers, setAllowedUsers] = useState<User[]>([]);
const [isValidatingEmails, setIsValidatingEmails] = useState(false);
// Filtrar usuários que têm emails permitidos para esta organização
useEffect(() => {
const validateUserEmails = async () => {
if (users.length === 0) {
setAllowedUsers([]);
return;
}
setIsValidatingEmails(true);
try {
const userIds = users.map((user) => user.id);
const response = await fetch('/api/validate-user-emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
organizationId,
userIds,
}),
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
const validUserIds = data.data || [];
const validUsers = users.filter((user) =>
validUserIds.includes(user.id)
);
setAllowedUsers(validUsers);
} else {
console.error('Erro ao validar emails de usuários');
setAllowedUsers([]);
}
} catch (error) {
console.error('Erro ao validar emails de usuários:', error);
setAllowedUsers([]);
} finally {
setIsValidatingEmails(false);
}
};
validateUserEmails();
}, [users, organizationId, emailListChanged]);
// Filtrar usuários que já são membros da organização
const availableUsers = allowedUsers.filter(
(user) => !currentMemberIds.includes(user.id)
);
return (
<Popover
open={openPopoverId === organizationId}
onOpenChange={(open) => setOpenPopoverId(open ? organizationId : null)}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<UserPlusIcon className="w-4 h-4 mr-2" />
Adicionar Membro
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Adicionar Membro</h4>
<p className="text-sm text-muted-foreground">
Selecione um usuário para adicionar à organização. O membro será
automaticamente adicionado a todos os projetos existentes.
{projectCount !== undefined && (
<span className="block mt-1 text-xs text-blue-600">
Esta organização possui {projectCount} projeto
{projectCount !== 1 ? 's' : ''}.
</span>
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-select">Usuário:</Label>
{isLoadingUsers || isValidatingEmails ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-muted/50">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm text-muted-foreground">
{isLoadingUsers
? 'Carregando usuários...'
: 'Validando emails...'}
</span>
</div>
) : availableUsers.length === 0 ? (
<div className="p-3 border rounded-md bg-muted/50">
<span className="text-sm text-muted-foreground">
Nenhum usuário disponível para adicionar
</span>
<p className="text-xs text-muted-foreground mt-1">
Apenas usuários com emails do domínio jurunense.com.br ou
emails externos permitidos podem ser adicionados.
</p>
</div>
) : (
<Select
value={selectedUser}
onValueChange={setSelectedUser}
disabled={isAddingMember}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um usuário" />
</SelectTrigger>
<SelectContent>
{availableUsers.map((user) => (
<SelectItem key={user.id} value={user.id}>
<div className="flex items-center gap-2">
{user.image && (
<img
src={user.image}
alt={user.name}
className="w-4 h-4 rounded-full"
/>
)}
<span>{user.name}</span>
<span className="text-xs text-muted-foreground">
({user.email})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label htmlFor="role-select">Função:</Label>
<Select
value={selectedRole}
onValueChange={(value: 'admin' | 'member') =>
setSelectedRole(value)
}
disabled={isAddingMember}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Membro</SelectItem>
<SelectItem value="admin">Administrador</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2 pt-2">
<Button
onClick={() => onAddMember(organizationId)}
disabled={
!selectedUser ||
isAddingMember ||
availableUsers.length === 0 ||
isValidatingEmails
}
className="flex-1"
>
{isAddingMember ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Adicionando...
</>
) : isValidatingEmails ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Validando...
</>
) : availableUsers.length === 0 ? (
'Nenhum usuário disponível'
) : (
'Adicionar a todos os projetos'
)}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import { OrganizationAllowedEmailsManager } from '@/components/OrganizationAllowedEmailsManager';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface AllowedEmailsDialogProps {
isOpen: boolean;
onClose: () => void;
organizationId: string;
organizationName: string;
onEmailListChanged?: () => void;
}
export function AllowedEmailsDialog({
isOpen,
onClose,
organizationId,
organizationName,
onEmailListChanged,
}: AllowedEmailsDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gerenciar Emails Permitidos</DialogTitle>
<DialogDescription>
Configure quais emails externos podem se registrar na organização{' '}
<strong>{organizationName}</strong>
</DialogDescription>
</DialogHeader>
<OrganizationAllowedEmailsManager
organizationId={organizationId}
organizationName={organizationName}
onEmailListChanged={onEmailListChanged}
/>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,102 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2, PlusIcon } from 'lucide-react';
interface CreateOrganizationDialogProps {
isDialogOpen: boolean;
setIsDialogOpen: (value: boolean) => void;
formData: { name: string; description: string };
setFormData: (value: { name: string; description: string }) => void;
isCreating: boolean;
onSubmit: (e: React.FormEvent) => Promise<void>;
}
export function CreateOrganizationDialog({
isDialogOpen,
setIsDialogOpen,
formData,
setFormData,
isCreating,
onSubmit,
}: CreateOrganizationDialogProps) {
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="w-4 h-4" />
Nova organização
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={onSubmit}>
<DialogHeader>
<DialogTitle>Criar organização</DialogTitle>
</DialogHeader>
<div className="space-y-4 my-4">
<div className="flex flex-col gap-2">
<Label htmlFor="name">Nome:</Label>
<Input
id="name"
type="text"
name="name"
placeholder="Nome da organização"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
disabled={isCreating}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="description">Descrição:</Label>
<Textarea
id="description"
name="description"
placeholder="Descrição da organização"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
disabled={isCreating}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={isCreating}>
Cancelar
</Button>
</DialogClose>
<Button
type="submit"
disabled={isCreating || !formData.name.trim()}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Criando...
</>
) : (
'Criar organização'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import { Button } from '@/components/ui/button';
import { OrganizationMember } from '@/types/User';
import { Loader2, TrashIcon } from 'lucide-react';
import { RoleSelector } from './RoleSelector';
interface MembersListProps {
members: OrganizationMember[];
isLoadingMembers: boolean;
organizationId: string;
currentUserRole?: 'owner' | 'admin' | 'member';
onRemoveMember: (organizationId: string, memberId: string) => Promise<void>;
onRoleChange: (
memberId: string,
newRole: 'admin' | 'member'
) => Promise<void>;
}
export function MembersList({
members,
isLoadingMembers,
organizationId,
currentUserRole,
onRemoveMember,
onRoleChange,
}: MembersListProps) {
if (isLoadingMembers) {
return (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm text-muted-foreground">
Carregando membros...
</span>
</div>
);
}
if (members.length === 0) {
return (
<p className="text-sm text-muted-foreground">Nenhum membro encontrado</p>
);
}
return (
<ul className="space-y-1">
{members.map((member) => {
const canEditRole =
currentUserRole === 'owner' || currentUserRole === 'admin';
const canRemoveMember =
currentUserRole === 'owner' ||
(currentUserRole === 'admin' && member.role !== 'owner');
return (
<li
key={member.id}
className="flex items-center justify-between p-2 bg-muted/50 rounded"
>
<div className="flex items-center gap-3">
<span className="font-medium">
{member.user?.name || 'Usuário sem nome'}
</span>
<RoleSelector
currentRole={member.role}
memberId={member.userId}
canEdit={canEditRole}
onRoleChange={onRoleChange}
/>
</div>
{canRemoveMember && (
<Button
variant="ghost"
size="icon"
onClick={() => onRemoveMember(organizationId, member.id)}
className="h-6 w-6"
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</li>
);
})}
</ul>
);
}

View File

@ -0,0 +1,346 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { authClient } from '@/lib/auth-client';
import { Organization, OrganizationMember, User } from '@/types/User';
import { Loader2, Mail, PencilIcon, TrashIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AddMemberPopover } from './AddMemberPopover';
import { MembersList } from './MembersList';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface OrganizationCardProps {
organization: Organization;
members: OrganizationMember[];
projectCount: number;
isLoadingMembers: boolean;
onRemoveMember: (organizationId: string, memberId: string) => Promise<void>;
onAddMember: (organizationId: string) => Promise<void>;
onRemoveOrganization: (organizationId: string) => Promise<boolean>;
onUpdateOrganization: (
organizationId: string,
name: string,
description: string
) => Promise<boolean>;
onOpenAllowedEmailsDialog: (
organizationId: string,
organizationName: string
) => void;
onRoleChange: (
memberId: string,
newRole: 'admin' | 'member'
) => Promise<void>;
users: User[];
isLoadingUsers: boolean;
isAddingMember: boolean;
selectedUser: string;
setSelectedUser: (value: string) => void;
selectedRole: 'admin' | 'member';
setSelectedRole: (value: 'admin' | 'member') => void;
openPopoverId: string | null;
setOpenPopoverId: (value: string | null) => void;
emailListChanged: number;
}
export function OrganizationCard({
organization,
members,
projectCount,
isLoadingMembers,
onRemoveMember,
onAddMember,
onRemoveOrganization,
onUpdateOrganization,
onOpenAllowedEmailsDialog,
onRoleChange,
users,
isLoadingUsers,
isAddingMember,
selectedUser,
setSelectedUser,
selectedRole,
setSelectedRole,
openPopoverId,
setOpenPopoverId,
emailListChanged,
}: OrganizationCardProps) {
const [organizationName, setOrganizationName] = useState('');
const [isRemovingOrganization, setIsRemovingOrganization] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editName, setEditName] = useState(organization.name);
const [editDescription, setEditDescription] = useState(
organization.description || ''
);
const [isUpdatingOrganization, setIsUpdatingOrganization] = useState(false);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
// Obter o ID do usuário atual
useEffect(() => {
const getCurrentUser = async () => {
try {
const session = await authClient.getSession();
if (session?.data?.user?.id) {
setCurrentUserId(session.data.user.id);
}
} catch (error) {
console.error('Erro ao obter usuário atual:', error);
}
};
getCurrentUser();
}, []);
// Verificar se o usuário atual é admin ou owner
const currentUser = members.find((member) => member.userId === currentUserId);
const canRemoveOrganization =
currentUser && ['owner', 'admin'].includes(currentUser.role);
const handleRemoveOrganization = async () => {
if (organizationName !== organization.name) {
return;
}
setIsRemovingOrganization(true);
try {
const success = await onRemoveOrganization(organization.id);
if (success) {
setIsDialogOpen(false);
setOrganizationName('');
}
} finally {
setIsRemovingOrganization(false);
}
};
const handleUpdateOrganization = async () => {
if (!editName.trim()) {
return;
}
setIsUpdatingOrganization(true);
try {
const success = await onUpdateOrganization(
organization.id,
editName.trim(),
editDescription.trim()
);
if (success) {
setIsEditDialogOpen(false);
}
} finally {
setIsUpdatingOrganization(false);
}
};
// Atualizar estados quando a organização mudar
useEffect(() => {
setEditName(organization.name);
setEditDescription(organization.description || '');
}, [organization.name, organization.description]);
return (
<Card className="w-full">
<CardHeader>
<CardTitle>{organization.name}</CardTitle>
<CardDescription>{organization.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold">Membros</h2>
<AddMemberPopover
organizationId={organization.id}
onAddMember={onAddMember}
users={users}
isLoadingUsers={isLoadingUsers}
isAddingMember={isAddingMember}
selectedUser={selectedUser}
setSelectedUser={setSelectedUser}
selectedRole={selectedRole}
setSelectedRole={setSelectedRole}
openPopoverId={openPopoverId}
setOpenPopoverId={setOpenPopoverId}
currentMemberIds={members.map((member) => member.userId)}
projectCount={projectCount}
emailListChanged={emailListChanged}
/>
</div>
<MembersList
members={members}
isLoadingMembers={isLoadingMembers}
organizationId={organization.id}
currentUserRole={
members.find((m) => m.userId === currentUserId)?.role
}
onRemoveMember={onRemoveMember}
onRoleChange={onRoleChange}
/>
</div>
</CardContent>
<CardFooter>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
onOpenAllowedEmailsDialog(organization.id, organization.name)
}
title="Gerenciar emails permitidos para esta organização"
>
<Mail className="w-4 h-4" /> Emails Permitidos
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={!canRemoveOrganization}
title={
!canRemoveOrganization
? 'Apenas owners e admins podem remover organizações'
: ''
}
>
<TrashIcon className="w-4 h-4" /> Remover
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover organização</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Esta ação não pode ser desfeita. Para confirmar a remoção da
organização, digite o nome exato:{' '}
<strong>{organization.name}</strong>
</p>
<Label htmlFor="organization-name">Nome da organização:</Label>
<Input
id="organization-name"
type="text"
className="w-full"
value={organizationName}
onChange={(e) => {
setOrganizationName(e.target.value);
}}
placeholder="Digite o nome da organização"
/>
</div>
<DialogFooter>
<Button
variant="destructive"
size="sm"
disabled={
organizationName !== organization.name ||
isRemovingOrganization
}
onClick={handleRemoveOrganization}
>
{isRemovingOrganization ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Removendo...
</>
) : (
'Remover'
)}
</Button>
<DialogClose asChild>
<Button variant="outline" size="sm">
Cancelar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canRemoveOrganization}
title={
!canRemoveOrganization
? 'Apenas owners e admins podem editar organizações'
: ''
}
>
<PencilIcon className="w-4 h-4" /> Editar
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar organização</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Nome da organização:</Label>
<Input
id="edit-name"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Digite o nome da organização"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">
Descrição (opcional):
</Label>
<textarea
id="edit-description"
className="w-full min-h-[80px] p-3 border border-input bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 rounded-md resize-none"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Digite uma descrição para a organização"
/>
</div>
</div>
<DialogFooter>
<Button
variant="default"
size="sm"
disabled={!editName.trim() || isUpdatingOrganization}
onClick={handleUpdateOrganization}
>
{isUpdatingOrganization ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Salvando...
</>
) : (
'Salvar'
)}
</Button>
<DialogClose asChild>
<Button variant="outline" size="sm">
Cancelar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,98 @@
'use client';
import { Organization, OrganizationMember, User } from '@/types/User';
import { OrganizationCard } from './OrganizationCard';
interface OrganizationsListProps {
organizations: Organization[];
organizationMembers: Record<string, OrganizationMember[]>;
organizationProjectCounts: Record<string, number>;
isLoadingMembers: Record<string, boolean>;
onRemoveMember: (organizationId: string, memberId: string) => Promise<void>;
onAddMember: (organizationId: string) => Promise<void>;
onRemoveOrganization: (organizationId: string) => Promise<boolean>;
onUpdateOrganization: (
organizationId: string,
name: string,
description: string
) => Promise<boolean>;
onOpenAllowedEmailsDialog: (
organizationId: string,
organizationName: string
) => void;
onRoleChange: (
memberId: string,
newRole: 'admin' | 'member'
) => Promise<void>;
users: User[];
isLoadingUsers: boolean;
isAddingMember: boolean;
selectedUser: string;
setSelectedUser: (value: string) => void;
selectedRole: 'admin' | 'member';
setSelectedRole: (value: 'admin' | 'member') => void;
openPopoverId: string | null;
setOpenPopoverId: (value: string | null) => void;
emailListChanged: number;
}
export function OrganizationsList({
organizations,
organizationMembers,
organizationProjectCounts,
isLoadingMembers,
onRemoveMember,
onAddMember,
onRemoveOrganization,
onUpdateOrganization,
onOpenAllowedEmailsDialog,
onRoleChange,
users,
isLoadingUsers,
isAddingMember,
selectedUser,
setSelectedUser,
selectedRole,
setSelectedRole,
openPopoverId,
setOpenPopoverId,
emailListChanged,
}: OrganizationsListProps) {
if (organizations.length === 0) {
return (
<div className="w-full text-center py-8 text-muted-foreground">
Nenhuma organização encontrada. Crie sua primeira organização!
</div>
);
}
return (
<div className="flex flex-col gap-3 w-full">
{organizations.map((organization) => (
<OrganizationCard
key={organization.id}
organization={organization}
members={organizationMembers[organization.id] || []}
projectCount={organizationProjectCounts[organization.id] || 0}
isLoadingMembers={isLoadingMembers[organization.id] || false}
onRemoveMember={onRemoveMember}
onAddMember={onAddMember}
onRemoveOrganization={onRemoveOrganization}
onUpdateOrganization={onUpdateOrganization}
onOpenAllowedEmailsDialog={onOpenAllowedEmailsDialog}
onRoleChange={onRoleChange}
users={users}
isLoadingUsers={isLoadingUsers}
isAddingMember={isAddingMember}
selectedUser={selectedUser}
setSelectedUser={setSelectedUser}
selectedRole={selectedRole}
setSelectedRole={setSelectedRole}
openPopoverId={openPopoverId}
setOpenPopoverId={setOpenPopoverId}
emailListChanged={emailListChanged}
/>
))}
</div>
);
}

View File

@ -0,0 +1,133 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ChevronDown, Crown, Shield, User } from 'lucide-react';
import { useState } from 'react';
interface RoleSelectorProps {
currentRole: 'owner' | 'admin' | 'member';
memberId: string;
canEdit: boolean;
onRoleChange: (
memberId: string,
newRole: 'admin' | 'member'
) => Promise<void>;
}
const roleConfig = {
owner: {
label: 'Owner',
icon: Crown,
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
description: 'Controle total da organização',
},
admin: {
label: 'Admin',
icon: Shield,
color: 'bg-blue-100 text-blue-800 border-blue-200',
description: 'Pode gerenciar membros e projetos',
},
member: {
label: 'Member',
icon: User,
color: 'bg-gray-100 text-gray-800 border-gray-200',
description: 'Acesso básico à organização',
},
};
export function RoleSelector({
currentRole,
memberId,
canEdit,
onRoleChange,
}: RoleSelectorProps) {
const [isLoading, setIsLoading] = useState(false);
const config = roleConfig[currentRole];
const Icon = config.icon;
const handleRoleChange = async (newRole: 'admin' | 'member') => {
if (!canEdit || newRole === currentRole) return;
setIsLoading(true);
try {
await onRoleChange(memberId, newRole);
} catch (error) {
console.error('Erro ao alterar role:', error);
} finally {
setIsLoading(false);
}
};
if (!canEdit) {
return (
<Badge className={`${config.color} flex items-center gap-1`}>
<Icon className="h-3 w-3" />
{config.label}
</Badge>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`${config.color} hover:opacity-80 flex items-center gap-1`}
disabled={isLoading}
>
<Icon className="h-3 w-3" />
{config.label}
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="px-2 py-1.5 text-sm text-muted-foreground">
Alterar role para:
</div>
{currentRole !== 'owner' && (
<>
<DropdownMenuItem
onClick={() => handleRoleChange('admin')}
disabled={isLoading || currentRole === 'admin'}
className="flex items-center gap-2"
>
<Shield className="h-4 w-4" />
<div>
<div className="font-medium">Admin</div>
<div className="text-xs text-muted-foreground">
Pode gerenciar membros e projetos
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleRoleChange('member')}
disabled={isLoading || currentRole === 'member'}
className="flex items-center gap-2"
>
<User className="h-4 w-4" />
<div>
<div className="font-medium">Member</div>
<div className="text-xs text-muted-foreground">
Acesso básico à organização
</div>
</div>
</DropdownMenuItem>
</>
)}
{currentRole === 'owner' && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
Owners não podem ter seu role alterado
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,7 @@
export { AddMemberPopover } from './AddMemberPopover';
export { AllowedEmailsDialog } from './AllowedEmailsDialog';
export { CreateOrganizationDialog } from './CreateOrganizationDialog';
export { MembersList } from './MembersList';
export { OrganizationCard } from './OrganizationCard';
export { OrganizationsList } from './OrganizationsList';
export { RoleSelector } from './RoleSelector';

View File

@ -0,0 +1 @@
export { useOrganizations } from './useOrganizations';

View File

@ -0,0 +1,512 @@
'use client';
import { Organization, OrganizationMember, User } from '@/types/User';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
export function useOrganizations() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [organizationMembers, setOrganizationMembers] = useState<
Record<string, OrganizationMember[]>
>({});
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMembers, setIsLoadingMembers] = useState<
Record<string, boolean>
>({});
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({ name: '', description: '' });
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Estados para adicionar membros
const [users, setUsers] = useState<User[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [selectedUser, setSelectedUser] = useState<string>('');
const [selectedRole, setSelectedRole] = useState<'admin' | 'member'>(
'member'
);
const [isAddingMember, setIsAddingMember] = useState(false);
const [openPopoverId, setOpenPopoverId] = useState<string | null>(null);
const [organizationProjectCounts, setOrganizationProjectCounts] = useState<
Record<string, number>
>({});
// Estados para gerenciar emails permitidos
const [isAllowedEmailsDialogOpen, setIsAllowedEmailsDialogOpen] =
useState(false);
const [selectedOrganizationForEmails, setSelectedOrganizationForEmails] =
useState<{
id: string;
name: string;
} | null>(null);
const [emailListChanged, setEmailListChanged] = useState(0);
const loadOrganizations = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch('/api/organizations', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
const orgs = data.data || [];
setOrganizations(orgs);
// Carregar membros para cada organização
await loadMembersForOrganizations(orgs);
} else {
const errorData = await response.json();
setError('Erro ao carregar organizações');
toast.error(
`Erro ao carregar organizações: ${errorData.error || response.status}`
);
}
} catch (error) {
setError('Erro ao carregar organizações');
toast.error('Erro ao carregar organizações');
} finally {
setIsLoading(false);
}
};
const loadMembersForOrganizations = async (orgs: Organization[]) => {
const membersData: Record<string, OrganizationMember[]> = {};
const loadingData: Record<string, boolean> = {};
const projectCounts: Record<string, number> = {};
for (const org of orgs) {
loadingData[org.id] = true;
try {
const response = await fetch(`/api/organizations/${org.id}/members`, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
membersData[org.id] = data.data || [];
} else {
console.error(`Erro ao carregar membros da organização ${org.id}`);
membersData[org.id] = [];
}
// Buscar número de projetos da organização
try {
const projectResponse = await fetch(
`/api/getProject?organizationId=${org.id}`,
{
credentials: 'include',
}
);
if (projectResponse.ok) {
const projectData = await projectResponse.json();
projectCounts[org.id] = projectData.data?.length || 0;
} else {
projectCounts[org.id] = 0;
}
} catch (error) {
console.error(
`Erro ao carregar projetos da organização ${org.id}:`,
error
);
projectCounts[org.id] = 0;
}
} catch (error) {
console.error(
`Erro ao carregar membros da organização ${org.id}:`,
error
);
membersData[org.id] = [];
projectCounts[org.id] = 0;
} finally {
loadingData[org.id] = false;
}
}
setOrganizationMembers(membersData);
setIsLoadingMembers(loadingData);
setOrganizationProjectCounts(projectCounts);
};
const handleRemoveMember = async (
organizationId: string,
memberId: string
) => {
try {
const response = await fetch(
`/api/organizations/${organizationId}/members?memberId=${memberId}`,
{
method: 'DELETE',
credentials: 'include',
}
);
if (response.ok) {
// Recarregar membros da organização para garantir dados atualizados
const memberResponse = await fetch(
`/api/organizations/${organizationId}/members`,
{
credentials: 'include',
}
);
if (memberResponse.ok) {
const memberData = await memberResponse.json();
setOrganizationMembers((prev) => ({
...prev,
[organizationId]: memberData.data || [],
}));
}
toast.success('Membro removido com sucesso!');
} else {
const errorData = await response.json();
toast.error(errorData.error || 'Erro ao remover membro');
}
} catch (error) {
toast.error('Erro ao remover membro');
}
};
const handleRemoveOrganization = async (organizationId: string) => {
try {
const response = await fetch(`/api/organizations/${organizationId}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
// Remover a organização da lista local
setOrganizations((prev) =>
prev.filter((org) => org.id !== organizationId)
);
// Remover membros da organização da lista local
setOrganizationMembers((prev) => {
const newMembers = { ...prev };
delete newMembers[organizationId];
return newMembers;
});
toast.success('Organização removida com sucesso!');
return true;
} else {
const errorData = await response.json();
toast.error(errorData.error || 'Erro ao remover organização');
return false;
}
} catch (error) {
toast.error('Erro ao remover organização');
return false;
}
};
const handleUpdateOrganization = async (
organizationId: string,
name: string,
description: string
) => {
try {
const response = await fetch(`/api/organizations/${organizationId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ name, description }),
});
if (response.ok) {
const data = await response.json();
// Atualizar a organização na lista local
setOrganizations((prev) =>
prev.map((org) =>
org.id === organizationId
? {
...org,
name: data.data.name,
description: data.data.description,
}
: org
)
);
toast.success('Organização atualizada com sucesso!');
return true;
} else {
const errorData = await response.json();
toast.error(errorData.error || 'Erro ao atualizar organização');
return false;
}
} catch (error) {
toast.error('Erro ao atualizar organização');
return false;
}
};
const handleAddMember = async (organizationId: string) => {
if (!selectedUser) {
toast.error('Selecione um usuário');
return;
}
// Verificar se o usuário já é membro
const isAlreadyMember = organizationMembers[organizationId]?.some(
(member) => member.userId === selectedUser
);
if (isAlreadyMember) {
toast.error('Este usuário já é membro da organização');
return;
}
try {
setIsAddingMember(true);
const response = await fetch(
`/api/organizations/${organizationId}/members`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
userId: selectedUser,
role: selectedRole,
}),
}
);
if (response.ok) {
const data = await response.json();
// Buscar dados do usuário adicionado
const addedUser = users.find((user) => user.id === selectedUser);
// Criar objeto do membro com dados do usuário
const newMember = {
...data.data,
user: addedUser,
};
// Mostrar mensagem com informações sobre os projetos
const projectCount = data.projectCount || 0;
const projectsAdded = data.projectsAdded || 0;
// Recarregar membros da organização para garantir dados atualizados
const memberResponse = await fetch(
`/api/organizations/${organizationId}/members`,
{
credentials: 'include',
}
);
if (memberResponse.ok) {
const memberData = await memberResponse.json();
setOrganizationMembers((prev) => ({
...prev,
[organizationId]: memberData.data || [],
}));
}
// Limpar seleções e fechar popover
setSelectedUser('');
setSelectedRole('member');
setOpenPopoverId(null);
if (projectCount > 0) {
toast.success(
`Membro adicionado com sucesso! O usuário foi adicionado a ${projectsAdded} de ${projectCount} projetos da organização.`
);
} else {
toast.success(
'Membro adicionado com sucesso! A organização não possui projetos ainda.'
);
}
} else {
const errorData = await response.json();
toast.error(errorData.error || 'Erro ao adicionar membro');
}
} catch (error) {
toast.error('Erro ao adicionar membro');
} finally {
setIsAddingMember(false);
}
};
const loadUsers = async () => {
try {
setIsLoadingUsers(true);
const response = await fetch('/api/users', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setUsers(data.data || []);
} else {
console.error('Erro ao carregar usuários');
toast.error('Erro ao carregar lista de usuários');
}
} catch (error) {
console.error('Erro ao carregar usuários:', error);
toast.error('Erro ao carregar lista de usuários');
} finally {
setIsLoadingUsers(false);
}
};
const handleCreateOrganization = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
toast.error('Nome da organização é obrigatório');
return;
}
try {
setIsCreating(true);
const payload = {
name: formData.name.trim(),
slug: formData.name.toLowerCase().replace(/[^a-z0-9]/g, '-'),
description: formData.description.trim(),
};
const response = await fetch('/api/organizations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload),
});
if (response.ok) {
const data = await response.json();
setOrganizations([...organizations, data.data]);
setFormData({ name: '', description: '' });
setIsDialogOpen(false);
toast.success('Organização criada com sucesso!');
} else {
const errorData = await response.json();
toast.error(errorData.error || 'Erro ao criar organização');
}
} catch (error) {
toast.error('Erro ao criar organização');
} finally {
setIsCreating(false);
}
};
const handleOpenAllowedEmailsDialog = (
organizationId: string,
organizationName: string
) => {
setSelectedOrganizationForEmails({
id: organizationId,
name: organizationName,
});
setIsAllowedEmailsDialogOpen(true);
};
const handleCloseAllowedEmailsDialog = () => {
setIsAllowedEmailsDialogOpen(false);
setSelectedOrganizationForEmails(null);
};
const handleEmailListChanged = () => {
// Incrementar o contador para forçar o reload da validação de emails
setEmailListChanged((prev) => prev + 1);
};
const handleRoleChange = async (
memberId: string,
newRole: 'admin' | 'member'
) => {
try {
// Encontrar a organização do membro
const organizationId = Object.keys(organizationMembers).find((orgId) =>
organizationMembers[orgId].some((member) => member.userId === memberId)
);
if (!organizationId) {
toast.error('Organização não encontrada');
return;
}
const response = await fetch(
`/api/organizations/${organizationId}/members/${memberId}/role`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ role: newRole }),
credentials: 'include',
}
);
const data = await response.json();
if (response.ok) {
toast.success(data.message);
// Recarregar os membros da organização
await loadMembersForOrganizations(organizations);
} else {
toast.error(data.error || 'Erro ao alterar role do membro');
}
} catch (error) {
console.error('Erro ao alterar role do membro:', error);
toast.error('Erro ao alterar role do membro');
}
};
useEffect(() => {
loadOrganizations();
loadUsers();
}, []);
return {
// Estados
organizations,
organizationMembers,
organizationProjectCounts,
isLoading,
isLoadingMembers,
isCreating,
error,
formData,
isDialogOpen,
users,
isLoadingUsers,
selectedUser,
selectedRole,
isAddingMember,
openPopoverId,
isAllowedEmailsDialogOpen,
selectedOrganizationForEmails,
emailListChanged,
// Setters
setFormData,
setIsDialogOpen,
setSelectedUser,
setSelectedRole,
setOpenPopoverId,
// Funções
handleCreateOrganization,
handleAddMember,
handleRemoveMember,
handleRemoveOrganization,
handleUpdateOrganization,
handleOpenAllowedEmailsDialog,
handleCloseAllowedEmailsDialog,
handleEmailListChanged,
handleRoleChange,
};
}

View File

@ -0,0 +1,125 @@
'use client';
import Loading from '@/components/Loading';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import {
AllowedEmailsDialog,
CreateOrganizationDialog,
OrganizationsList,
} from './components';
import { useOrganizations } from './hooks';
export default function Organizations() {
const {
// Estados
organizations,
organizationMembers,
organizationProjectCounts,
isLoading,
isLoadingMembers,
isCreating,
formData,
isDialogOpen,
users,
isLoadingUsers,
selectedUser,
selectedRole,
isAddingMember,
openPopoverId,
isAllowedEmailsDialogOpen,
selectedOrganizationForEmails,
emailListChanged,
// Setters
setFormData,
setIsDialogOpen,
setSelectedUser,
setSelectedRole,
setOpenPopoverId,
// Funções
handleCreateOrganization,
handleAddMember,
handleRemoveMember,
handleRemoveOrganization,
handleUpdateOrganization,
handleOpenAllowedEmailsDialog,
handleCloseAllowedEmailsDialog,
handleEmailListChanged,
handleRoleChange,
} = useOrganizations();
return (
<div className="h-[calc(100dvh-4.375rem)]">
<ResizablePanelGroup
direction="horizontal"
className="h-[calc(100dvh-4.375rem)]"
>
<ResizablePanel defaultSize={20}>
<div className="flex flex-col gap-2 justify-start items-center">
<h1 className="text-2xl font-bold">Organizações</h1>
<div className="flex flex-col gap-2">
<CreateOrganizationDialog
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
formData={formData}
setFormData={setFormData}
isCreating={isCreating}
onSubmit={handleCreateOrganization}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{isLoading ? (
<ResizablePanel defaultSize={80}>
<Loading />
</ResizablePanel>
) : (
<ResizablePanel defaultSize={80}>
<div className="flex flex-col gap-3 justify-start items-start px-4 pt-2 pb-4 h-[calc(100dvh-4.375rem)] overflow-y-auto">
<h1 className="text-2xl font-bold">Organizações</h1>
<OrganizationsList
organizations={organizations}
organizationMembers={organizationMembers}
organizationProjectCounts={organizationProjectCounts}
isLoadingMembers={isLoadingMembers}
onRemoveMember={handleRemoveMember}
onAddMember={handleAddMember}
onRemoveOrganization={handleRemoveOrganization}
onUpdateOrganization={handleUpdateOrganization}
onOpenAllowedEmailsDialog={handleOpenAllowedEmailsDialog}
onRoleChange={handleRoleChange}
users={users}
isLoadingUsers={isLoadingUsers}
isAddingMember={isAddingMember}
selectedUser={selectedUser}
setSelectedUser={setSelectedUser}
selectedRole={selectedRole}
setSelectedRole={setSelectedRole}
openPopoverId={openPopoverId}
setOpenPopoverId={setOpenPopoverId}
emailListChanged={emailListChanged}
/>
</div>
</ResizablePanel>
)}
</ResizablePanelGroup>
{/* Diálogo para gerenciar emails permitidos */}
{selectedOrganizationForEmails && (
<AllowedEmailsDialog
isOpen={isAllowedEmailsDialogOpen}
onClose={handleCloseAllowedEmailsDialog}
organizationId={selectedOrganizationForEmails.id}
organizationName={selectedOrganizationForEmails.name}
onEmailListChanged={handleEmailListChanged}
/>
)}
</div>
);
}

View File

@ -0,0 +1,769 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { formatTimeSpent } from '@/utils/timeUtils';
import { Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
interface TimeSpentRecord {
id: string;
timeSpent: number;
createdAt: string;
userId: string;
userName: string;
userEmail: string;
}
interface CardComment {
id: string;
content: string;
createdAt: string;
updatedAt: string;
userId: string;
userName: string;
userEmail: string;
}
interface CardEditDialogProps {
isOpen: boolean;
onClose: () => void;
card: {
id: string;
name: string;
description: string;
status: string;
hours: number;
timeSpent: number;
order: number;
} | null;
onSave: (updatedCard: {
id: string;
name: string;
description: string;
status: string;
hours: number;
timeSpent: number;
isManualTimeAddition?: boolean;
}) => Promise<void>;
isSaving?: boolean;
}
const statusOptions = [
{ value: 'A fazer', label: 'A fazer' },
{ value: 'Em progresso', label: 'Em progresso' },
{ value: 'Em revisão', label: 'Em revisão' },
{ value: 'Testando', label: 'Testando' },
{ value: 'Concluido', label: 'Concluído' },
];
export default function CardEditDialog({
isOpen,
onClose,
card,
onSave,
isSaving = false,
}: CardEditDialogProps) {
const [formData, setFormData] = useState({
name: '',
description: '',
status: '',
hours: 0,
timeSpent: 0,
});
// Estados para os campos de tempo manual
const [manualHours, setManualHours] = useState(0);
const [manualMinutes, setManualMinutes] = useState(0);
const [manualSeconds, setManualSeconds] = useState(0);
// Estados para o histórico de tempo gasto
const [timeSpentHistory, setTimeSpentHistory] = useState<TimeSpentRecord[]>(
[]
);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
// Estados para comentários
const [comments, setComments] = useState<CardComment[]>([]);
const [isLoadingComments, setIsLoadingComments] = useState(false);
const [newComment, setNewComment] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(
null
);
// Estados para o AlertDialog de confirmação
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [commentToDelete, setCommentToDelete] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Função para converter segundos em horas, minutos e segundos
const secondsToTime = (totalSeconds: number) => {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return { hours, minutes, seconds };
};
// Função para converter horas, minutos e segundos em segundos
const timeToSeconds = (hours: number, minutes: number, seconds: number) => {
return hours * 3600 + minutes * 60 + seconds;
};
// Atualizar formData quando o card mudar
useEffect(() => {
if (card) {
setFormData({
name: card.name,
description: card.description || '',
status: card.status,
hours: card.hours,
timeSpent: 0, // Começar com 0 para adição manual
});
// Inicializar campos manuais com 0
setManualHours(0);
setManualMinutes(0);
setManualSeconds(0);
}
}, [card]);
// Atualizar quando o card mudar (após salvamento)
useEffect(() => {
if (card) {
// Atualizar apenas os campos que não são de tempo manual
setFormData((prev) => ({
...prev,
name: card.name,
description: card.description || '',
status: card.status,
hours: card.hours,
// Manter timeSpent como 0 para adições manuais
}));
}
}, [card]);
// Atualizar o tempo gasto quando os campos manuais mudarem
useEffect(() => {
const newTimeSpent = timeToSeconds(
manualHours,
manualMinutes,
manualSeconds
);
setFormData((prev) => ({
...prev,
timeSpent: newTimeSpent,
}));
}, [manualHours, manualMinutes, manualSeconds]);
// Focar no input de nome quando o dialog abrir
useEffect(() => {
if (isOpen && nameInputRef.current) {
// Pequeno delay para garantir que o dialog esteja completamente aberto
const timer = setTimeout(() => {
nameInputRef.current?.focus();
nameInputRef.current?.select();
}, 150);
return () => clearTimeout(timer);
}
}, [isOpen]);
// Função para buscar o histórico de tempo gasto
const fetchTimeSpentHistory = async (cardId: string) => {
try {
setIsLoadingHistory(true);
const response = await fetch(`/api/getTimeSpentHistory/${cardId}`, {
credentials: 'include',
});
if (response.ok) {
const result = await response.json();
if (result.success) {
setTimeSpentHistory(result.data);
}
}
} catch (error) {
console.error('Erro ao buscar histórico de tempo:', error);
} finally {
setIsLoadingHistory(false);
}
};
// Função para buscar comentários
const fetchComments = async (cardId: string) => {
try {
setIsLoadingComments(true);
const response = await fetch(`/api/getCardComments/${cardId}`, {
credentials: 'include',
});
if (response.ok) {
const result = await response.json();
if (result.success) {
setComments(result.data);
}
}
} catch (error) {
console.error('Erro ao buscar comentários:', error);
} finally {
setIsLoadingComments(false);
}
};
// Função para criar comentário
const handleCreateComment = async () => {
if (!card || !newComment.trim()) return;
try {
setIsSubmittingComment(true);
const response = await fetch('/api/createCardComment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
cardId: card.id,
content: newComment.trim(),
}),
});
if (response.ok) {
const result = await response.json();
if (result.success) {
setNewComment('');
// Recarregar comentários
await fetchComments(card.id);
}
}
} catch (error) {
console.error('Erro ao criar comentário:', error);
} finally {
setIsSubmittingComment(false);
}
};
// Função para mostrar o AlertDialog de confirmação
const handleDeleteCommentClick = (commentId: string) => {
setCommentToDelete(commentId);
setShowDeleteAlert(true);
};
// Função para deletar comentário
const handleDeleteComment = async () => {
if (!card || !commentToDelete) return;
try {
setDeletingCommentId(commentToDelete);
const response = await fetch(
`/api/deleteCardComment/${commentToDelete}`,
{
method: 'DELETE',
credentials: 'include',
}
);
if (response.ok) {
const result = await response.json();
if (result.success) {
// Recarregar comentários
await fetchComments(card.id);
} else {
alert(result.error || 'Erro ao deletar comentário');
}
} else {
const result = await response.json();
alert(result.error || 'Erro ao deletar comentário');
}
} catch (error) {
console.error('Erro ao deletar comentário:', error);
alert('Erro ao deletar comentário');
} finally {
setDeletingCommentId(null);
setShowDeleteAlert(false);
setCommentToDelete(null);
}
};
// Função para cancelar a exclusão
const handleCancelDelete = () => {
setShowDeleteAlert(false);
setCommentToDelete(null);
};
// Buscar histórico e comentários quando o card mudar
useEffect(() => {
if (card && isOpen) {
fetchTimeSpentHistory(card.id);
fetchComments(card.id);
}
}, [card, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();
if (!card) return;
try {
// Se há tempo manual sendo adicionado, enviar esse valor
if (formData.timeSpent > 0) {
await onSave({
id: card.id,
name: formData.name,
description: formData.description,
status: formData.status,
hours: formData.hours,
timeSpent: formData.timeSpent, // Enviar o tempo digitado
isManualTimeAddition: true, // Indicar que é uma adição manual
});
} else {
// Se não há tempo adicional, salvar normalmente sem modificar o tempo
await onSave({
id: card.id,
name: formData.name,
description: formData.description,
status: formData.status,
hours: formData.hours,
timeSpent: 0, // Não adicionar tempo
isManualTimeAddition: false,
});
}
onClose();
} catch (error) {
console.error('Erro ao salvar card:', error);
}
};
const handleInputChange = (field: string, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Permitir que todos os eventos de teclado funcionem normalmente
e.stopPropagation();
// Se pressionar Enter no input de nome, submeter o formulário
if (e.key === 'Enter' && e.target === nameInputRef.current) {
e.preventDefault();
formRef.current?.requestSubmit();
}
};
const handleInputKeyDown = (e: React.KeyboardEvent) => {
// Permitir todos os caracteres, incluindo espaço e Enter
e.stopPropagation();
};
// Função para formatar data e hora
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="sm:max-w-[425px]"
onKeyDown={handleKeyDown}
onKeyUp={handleKeyDown}
onKeyPress={handleKeyDown}
>
<Tabs defaultValue="edit">
<TabsList defaultValue="edit">
<TabsTrigger value="edit">Editar</TabsTrigger>
<TabsTrigger value="time">Tempo</TabsTrigger>
<TabsTrigger value="comments">Comentários</TabsTrigger>
</TabsList>
<TabsContent value="edit">
<DialogHeader>
<DialogTitle>Editar Card</DialogTitle>
<DialogDescription>
Faça as alterações necessárias no card e clique em salvar.
</DialogDescription>
</DialogHeader>
<form ref={formRef} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nome do Card</Label>
<input
ref={nameInputRef}
id="name"
type="text"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyDown}
onKeyPress={handleInputKeyDown}
placeholder="Digite o nome do card"
required
autoComplete="off"
autoFocus
className="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descrição</Label>
<Input
id="description"
type="text"
value={formData.description}
onChange={(e) =>
handleInputChange('description', e.target.value)
}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyDown}
onKeyPress={handleInputKeyDown}
placeholder="Digite a descrição do card"
autoComplete="off"
className="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => handleInputChange('status', value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="hours">Horas Estimadas</Label>
<Input
id="hours"
type="number"
min="0"
step="0.5"
value={formData.hours}
onChange={(e) =>
handleInputChange(
'hours',
parseFloat(e.target.value) || 0
)
}
onKeyDown={handleInputKeyDown}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label>Tempo Total Gasto</Label>
<div className="flex items-center gap-2 p-2 bg-muted rounded-md border">
<span className="text-sm font-medium">
{formatTimeSpent(card?.timeSpent || 0)}
</span>
</div>
</div>
</div>
{/* Campos para adição manual do tempo */}
<div className="space-y-2">
<Label>Adicionar Tempo Manualmente</Label>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label htmlFor="manualHours" className="text-xs">
Horas
</Label>
<Input
id="manualHours"
type="number"
min="0"
step="1"
value={manualHours}
onChange={(e) =>
setManualHours(parseInt(e.target.value) || 0)
}
onKeyDown={handleInputKeyDown}
placeholder="0"
className="text-center"
/>
</div>
<div className="space-y-1">
<Label htmlFor="manualMinutes" className="text-xs">
Minutos
</Label>
<Input
id="manualMinutes"
type="number"
min="0"
max="59"
step="1"
value={manualMinutes}
onChange={(e) =>
setManualMinutes(parseInt(e.target.value) || 0)
}
onKeyDown={handleInputKeyDown}
placeholder="0"
className="text-center"
/>
</div>
<div className="space-y-1">
<Label htmlFor="manualSeconds" className="text-xs">
Segundos
</Label>
<Input
id="manualSeconds"
type="number"
min="0"
max="59"
step="1"
value={manualSeconds}
onChange={(e) =>
setManualSeconds(parseInt(e.target.value) || 0)
}
onKeyDown={handleInputKeyDown}
placeholder="0"
className="text-center"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Tempo a ser adicionado: {formatTimeSpent(formData.timeSpent)}
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Salvando...' : 'Salvar'}
</Button>
</DialogFooter>
</form>
</TabsContent>
<TabsContent value="time">
<DialogHeader>
<DialogTitle>Histórico de Tempo Gasto</DialogTitle>
<DialogDescription>
Visualize todos os registros de tempo gasto neste card.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{isLoadingHistory ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
Carregando histórico...
</div>
</div>
) : timeSpentHistory.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
Nenhum registro de tempo encontrado.
</div>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{timeSpentHistory.map((record) => (
<div
key={record.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg border"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{record.userName || 'Usuário desconhecido'}
</span>
<span className="text-xs text-muted-foreground">
{record.userEmail}
</span>
</div>
<div className="text-xs text-muted-foreground">
{formatDateTime(record.createdAt)}
</div>
</div>
<div className="text-right">
<div className="font-semibold text-sm">
{formatTimeSpent(record.timeSpent)}
</div>
</div>
</div>
))}
</div>
)}
<div className="pt-4 border-t">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Tempo Total Gasto:
</span>
<span className="text-lg font-bold">
{formatTimeSpent(
timeSpentHistory.reduce(
(total, record) => total + record.timeSpent,
0
)
)}
</span>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="comments">
<DialogHeader>
<DialogTitle>Comentários</DialogTitle>
<DialogDescription>
Adicione comentários e acompanhe a discussão sobre este card.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Formulário para adicionar comentário */}
<div className="space-y-2">
<Label htmlFor="newComment">Adicionar Comentário</Label>
<Textarea
id="newComment"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Digite seu comentário..."
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
onClick={handleCreateComment}
disabled={!newComment.trim() || isSubmittingComment}
size="sm"
>
{isSubmittingComment ? 'Enviando...' : 'Enviar'}
</Button>
</div>
</div>
{/* Lista de comentários */}
<div className="space-y-3">
{isLoadingComments ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
Carregando comentários...
</div>
</div>
) : comments.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
Nenhum comentário encontrado.
</div>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{comments.map((comment) => (
<div
key={comment.id}
className="p-3 bg-muted/50 rounded-lg border"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{comment.userName || 'Usuário desconhecido'}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteCommentClick(comment.id)}
disabled={deletingCommentId === comment.id}
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
>
{deletingCommentId === comment.id ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Trash2 className="h-3 w-3" />
)}
</Button>
</div>
<div className="text-sm mb-2 whitespace-pre-wrap">
{comment.content}
</div>
<div className="text-xs text-muted-foreground">
{formatDateTime(comment.createdAt)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
{/* AlertDialog para confirmação de exclusão de comentário */}
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar Exclusão</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja deletar este comentário? Esta ação não pode
ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDelete}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteComment}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Deletar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View File

@ -0,0 +1,271 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PlusIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
interface CreateCardDialogProps {
projectId: string;
onCreateCard: (newCard: {
id: string;
name: string;
description: string;
status: string;
hours: number;
timeSpent: number;
order: number;
}) => void;
}
const statusOptions = [
{ value: 'A fazer', label: 'A fazer' },
{ value: 'Em progresso', label: 'Em progresso' },
{ value: 'Em revisão', label: 'Em revisão' },
{ value: 'Testando', label: 'Testando' },
{ value: 'Concluido', label: 'Concluído' },
];
export default function CreateCardDialog({
projectId,
onCreateCard,
}: CreateCardDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
status: 'A fazer',
hours: 0,
});
const nameInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Resetar formulário quando o dialog abrir/fechar
useEffect(() => {
if (isOpen) {
setFormData({
name: '',
description: '',
status: 'A fazer',
hours: 0,
});
}
}, [isOpen]);
// Focar no input de nome quando o dialog abrir
useEffect(() => {
if (isOpen && nameInputRef.current) {
// Pequeno delay para garantir que o dialog esteja completamente aberto
const timer = setTimeout(() => {
nameInputRef.current?.focus();
}, 150);
return () => clearTimeout(timer);
}
}, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();
if (!formData.name.trim()) {
return;
}
try {
setIsLoading(true);
const response = await fetch('/api/createCard', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim(),
description: formData.description.trim(),
status: formData.status,
hours: formData.hours,
projectId,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Erro ao criar card');
}
const result = await response.json();
if (result.success) {
// Chamar callback para atualizar a lista de cards
onCreateCard(result.data);
// Fechar dialog
setIsOpen(false);
} else {
throw new Error(result.message || 'Erro ao criar card');
}
} catch (error) {
console.error('Erro ao criar card:', error);
// Aqui você pode adicionar um toast de erro se desejar
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: string, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Permitir que todos os eventos de teclado funcionem normalmente
e.stopPropagation();
// Se pressionar Enter no input de nome, submeter o formulário
if (e.key === 'Enter' && e.target === nameInputRef.current) {
e.preventDefault();
formRef.current?.requestSubmit();
}
};
const handleInputKeyDown = (e: React.KeyboardEvent) => {
// Permitir todos os caracteres, incluindo espaço e Enter
e.stopPropagation();
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="w-4 h-4 mr-2" />
Novo Card
</Button>
</DialogTrigger>
<DialogContent
className="sm:max-w-[425px]"
onKeyDown={handleKeyDown}
onKeyUp={handleKeyDown}
onKeyPress={handleKeyDown}
>
<DialogHeader>
<DialogTitle>Criar Novo Card</DialogTitle>
<DialogDescription>
Preencha as informações do novo card e clique em criar.
</DialogDescription>
</DialogHeader>
<form ref={formRef} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nome do Card</Label>
<input
ref={nameInputRef}
id="name"
type="text"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyDown}
onKeyPress={handleInputKeyDown}
placeholder="Digite o nome do card"
required
autoComplete="off"
autoFocus
className="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descrição</Label>
<Input
id="description"
type="text"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyDown}
onKeyPress={handleInputKeyDown}
placeholder="Digite a descrição do card"
autoComplete="off"
className="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => handleInputChange('status', value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="hours">Horas Estimadas</Label>
<Input
id="hours"
type="number"
min="0"
step="0.5"
value={formData.hours}
onChange={(e) =>
handleInputChange('hours', parseFloat(e.target.value) || 0)
}
onKeyDown={handleInputKeyDown}
placeholder="0"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancelar
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Criando...' : 'Criar Card'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,198 @@
import { Button } from '@/components/ui/button';
import { useTimer } from '@/context/timerContext';
import { formatTimeSpent } from '@/utils/timeUtils';
import { Clock, Pause, Pen, Play } from 'lucide-react';
import { useState } from 'react';
import CardEditDialog from './CardEditDialog';
interface KanbanCardProps {
item: {
id: string;
name: string;
description: string;
column: string;
order: number;
};
isUpdating: boolean;
onCardUpdate?: (updatedCard: {
id: string;
name: string;
status: string;
hours: number;
timeSpent: number;
isManualTimeAddition?: boolean;
}) => Promise<void>;
cardData?: {
id: string;
name: string;
status: string;
description?: string;
hours: number;
timeSpent: number;
order: number;
};
}
export default function KanbanCard({
item,
isUpdating,
onCardUpdate,
cardData,
}: KanbanCardProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const { activeCard, startTimer, stopTimer, isPaused } = useTimer();
const isThisCardActive = activeCard?.id === item.id;
const handleCardClick = (e: React.MouseEvent) => {
// Prevenir que o clique seja tratado como início de drag
e.preventDefault();
e.stopPropagation();
setIsDialogOpen(true);
};
const handleSave = async (updatedCard: {
id: string;
name: string;
status: string;
hours: number;
timeSpent: number;
isManualTimeAddition?: boolean;
}) => {
if (!onCardUpdate) return;
setIsSaving(true);
try {
await onCardUpdate(updatedCard);
} finally {
setIsSaving(false);
}
};
const handleTimerClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isThisCardActive) {
await stopTimer();
} else {
startTimer(item.id, item.name);
}
};
return (
<>
<div
className="flex flex-col gap-1 relative group cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleCardClick}
style={{
// Desabilitar drag and drop quando o dialog estiver aberto
pointerEvents: isDialogOpen ? 'none' : 'auto',
}}
>
{isUpdating && (
<div className="absolute top-0 right-0 w-2 h-2 bg-blue-500 rounded-full animate-pulse z-10" />
)}
<div
className={`p-2 hover:bg-accent/20 rounded-md transition-colors relative ${
isThisCardActive ? 'bg-primary/10 border border-primary/30' : ''
}`}
>
<p className="font-medium text-sm border-b border-gray-200 mb-2">
{item.name}
</p>
{(item.description || cardData?.description) && (
<p className="text-xs text-muted-foreground">
{item.description || cardData?.description}
</p>
)}
{cardData && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
<Clock className="w-3 h-3" />
<span>{cardData.hours}h</span>
<span
className={
cardData.hours > 0 &&
cardData.timeSpent >= cardData.hours * 3600
? 'text-red-500'
: 'text-muted-foreground'
}
>
{' '}
/ {formatTimeSpent(cardData.timeSpent)}
</span>
</div>
)}
{/* Indicador visual de que é clicável */}
{isHovered && (
<div className="absolute top-1 right-1 text-muted-foreground opacity-70 bg-background/80 px-0.5 py-0.5 rounded-lg text-xs">
<Pen className="w-4 h-4" />
</div>
)}
{/* Botão do temporizador */}
<div className="w-full flex justify-center items-center mt-2 -mb-3">
<Button
variant="ghost"
size="sm"
onClick={handleTimerClick}
className={`h-6 w-6 p-0 ${
isThisCardActive
? 'text-destructive hover:text-destructive'
: 'text-primary hover:text-primary'
}`}
>
{isThisCardActive ? (
<Pause className="w-3 h-3" />
) : (
<Play className="w-3 h-3" />
)}
</Button>
</div>
{/* Indicador visual de card ativo */}
{isThisCardActive && (
<div className="absolute top-0 left-0 w-full h-1 bg-primary rounded-t-md" />
)}
{/* Indicador de status pausado */}
{isThisCardActive && isPaused && (
<div className="absolute top-1 left-1 bg-yellow-500 text-white text-xs px-1 py-0.5 rounded">
PAUSADO
</div>
)}
</div>
</div>
<CardEditDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
card={
cardData
? {
...cardData,
description: cardData.description || '',
}
: {
id: item.id,
name: item.name,
description: '',
status: item.column,
hours: 0,
timeSpent: 0,
order: item.order,
}
}
onSave={handleSave}
isSaving={isSaving}
/>
</>
);
}

View File

@ -0,0 +1,34 @@
interface NotificationToastProps {
notification: {
message: string;
type: 'success' | 'error';
} | null;
onClose: () => void;
}
export default function NotificationToast({
notification,
onClose,
}: NotificationToastProps) {
if (!notification) return null;
return (
<div
className={`fixed top-4 right-4 z-50 p-4 rounded-md shadow-lg transition-all duration-300 ${
notification.type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`}
>
<div className="flex items-center gap-2">
<span>{notification.message}</span>
<button
onClick={onClose}
className="ml-2 text-white hover:text-gray-200"
>
×
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Project } from '@/types/Project';
import CreateCardDialog from './CreateCardDialog';
interface ProjectHeaderProps {
project: Project | null;
onCreateCard: (newCard: {
id: string;
name: string;
description: string;
status: string;
hours: number;
timeSpent: number;
order: number;
}) => void;
}
export default function ProjectHeader({
project,
onCreateCard,
}: ProjectHeaderProps) {
return (
<header className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground">
{project ? project.name.toUpperCase() : 'Carregando...'}
</h1>
<p className="text-md text-muted-foreground">
{project
? project.description.length > 40
? project.description.slice(0, 40) + '...'
: project.description
: 'Carregando...'}
</p>
</div>
<div>
{project && (
<CreateCardDialog
projectId={project.id}
onCreateCard={onCreateCard}
/>
)}
</div>
</header>
);
}

View File

@ -0,0 +1,55 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Project } from '@/types/Project';
import { CalendarIcon, GitBranch, User } from 'lucide-react';
interface ProjectInfoProps {
project: Project;
}
export default function ProjectInfo({ project }: ProjectInfoProps) {
// Função para formatar a data final de forma segura
const formatFinalDate = (finalDate: Date | string | null | undefined) => {
if (!finalDate) return 'Não definido';
// Se já é um objeto Date, usar diretamente
const date = finalDate instanceof Date ? finalDate : new Date(finalDate);
// Verifica se a data é válida (não é 31/12/1969 ou data inválida)
if (isNaN(date.getTime()) || date.getFullYear() === 1969) {
return 'Não definido';
}
return date.toLocaleDateString();
};
return (
<Card className="w-2/5">
<CardHeader>
<CardTitle>Informações do Projeto</CardTitle>
</CardHeader>
<CardContent>
{project.projectUrl && (
<div className="flex items-center gap-2 ">
<GitBranch className="w-4 h-4" />
<span className="text-sm text-muted-foreground">
{project.projectUrl.replace(/https:\/\/github\.com\//g, '')}
</span>
</div>
)}
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span className="text-sm text-muted-foreground">
{new Date(project.createdAt).toLocaleDateString()} {' - '}
{formatFinalDate(project.finalDate)}
</span>
</div>
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<span className="text-sm text-muted-foreground">
{project.users?.[0].name || 'Nenhum usuário encontrado'}
</span>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,116 @@
import {
KanbanBoard,
KanbanCard as KanbanCardComponent,
KanbanCards,
KanbanHeader,
KanbanProvider,
} from '@/components/ui/kibo-ui/kanban';
import { Project } from '@/types/Project';
import KanbanCard from './KanbanCard';
type KanbanItemProps = {
id: string;
name: string;
column: string;
order?: number;
} & Record<string, unknown>;
interface ProjectKanbanProps {
project: Project;
updatingCards: Set<string>;
onDataChange: (newData: KanbanItemProps[]) => Promise<void>;
onCardUpdate?: (updatedCard: {
id: string;
name: string;
status: string;
hours: number;
timeSpent: number;
isManualTimeAddition?: boolean;
}) => Promise<void>;
}
export default function ProjectKanban({
project,
updatingCards,
onDataChange,
onCardUpdate,
}: ProjectKanbanProps) {
const columns = [
{ id: 'A fazer', name: 'A fazer' },
{ id: 'Em progresso', name: 'Em progresso' },
{ id: 'Em revisão', name: 'Em revisão' },
{ id: 'Testando', name: 'Testando' },
{ id: 'Concluido', name: 'Concluído' },
];
const data = project.cards.map((card) => ({
id: card.id,
name: card.name,
description: card.description || '',
column: card.status,
order: card.order || 0,
}));
const getColumnColor = (columnId: string) => {
switch (columnId) {
case 'A fazer':
return '#3b82f6';
case 'Em progresso':
return '#f59e0b';
case 'Em revisão':
return '#8b5cf6';
case 'Testando':
return '#10b981';
default:
return '#6b7280';
}
};
return (
<div>
<KanbanProvider columns={columns} data={data} onDataChange={onDataChange}>
{(column) => (
<KanbanBoard id={column.id} key={column.id}>
<KanbanHeader>
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: getColumnColor(column.id),
}}
/>
<span>{column.name}</span>
</div>
</KanbanHeader>
<KanbanCards id={column.id}>
{(item: KanbanItemProps) => {
// Encontrar os dados completos do card
const fullCardData = project.cards.find(
(card) => card.id === item.id
);
return (
<KanbanCardComponent key={item.id} {...item}>
<KanbanCard
key={`${item.id}-${fullCardData?.timeSpent}-${fullCardData?.name}`}
item={{
id: item.id,
name: item.name,
description: item.description as string,
column: item.column,
order: item.order || 0,
}}
isUpdating={updatingCards.has(item.id)}
onCardUpdate={onCardUpdate}
cardData={fullCardData}
/>
</KanbanCardComponent>
);
}}
</KanbanCards>
</KanbanBoard>
)}
</KanbanProvider>
</div>
);
}

View File

@ -0,0 +1,89 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AvatarStack } from '@/components/ui/kibo-ui/avatar-stack';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Project } from '@/types/Project';
import { useEffect } from 'react';
interface ProjectMembersProps {
project: Project;
}
export default function ProjectMembers({ project }: ProjectMembersProps) {
// Debug: Log dos dados dos usuários
useEffect(() => {
console.log('ProjectMembers - Dados dos usuários:', project.users);
project.users?.forEach((user, index) => {
console.log(`Usuário ${index + 1}:`, {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
});
});
}, [project.users]);
return (
<Card className="w-3/5 flex flex-col gap-2">
<CardHeader>
<CardTitle>Membros do Projeto</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<TooltipProvider>
<div className="flex flex-wrap justify-center items-center gap-2">
<AvatarStack animate>
{project.users?.map((user) => (
<Tooltip key={user.id}>
<TooltipTrigger asChild>
<Avatar className="w-10 h-10 cursor-pointer">
<AvatarImage
src={user.image || undefined}
alt={user.name}
onError={(e) => {
console.error(
'Erro ao carregar imagem do usuário:',
user.name,
user.image
);
e.currentTarget.style.display = 'none';
}}
onLoad={() => {
console.log(
'Imagem carregada com sucesso para:',
user.name,
user.image
);
}}
/>
<AvatarFallback>
{(() => {
const names = user.name.trim().split(' ');
if (names.length >= 2) {
return `${names[0]
.charAt(0)
.toUpperCase()}${names[1]
.charAt(0)
.toUpperCase()}`;
}
return names[0].charAt(0).toUpperCase();
})()}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<p>{user.name}</p>
</TooltipContent>
</Tooltip>
))}
</AvatarStack>
</div>
</TooltipProvider>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,84 @@
import CardDash from '@/components/CardDash';
import { Project } from '@/types/Project';
import { BarChart3Icon, CalendarIcon } from 'lucide-react';
interface ProjectStatsProps {
project: Project;
}
export default function ProjectStats({ project }: ProjectStatsProps) {
// Função auxiliar para calcular estatísticas com valores padrão
const calculateProjectStats = () => {
if (!project.cards || project.cards.length === 0) {
return {
totalCards: 0,
activeCards: 0,
completedCards: 0,
estimatedHours: 0,
spentHours: 0,
};
}
return {
totalCards: project.cards.length,
activeCards: project.cards.filter((card) => card.status !== 'Concluido')
.length,
completedCards: project.cards.filter(
(card) => card.status === 'Concluido'
).length,
estimatedHours: project.cards.reduce(
(acc, card) => acc + (card.hours || 0),
0
),
spentHours: project.cards.reduce(
(acc, card) => acc + (card.timeSpent || 0),
0
),
};
};
const stats = calculateProjectStats();
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<CardDash
title="Total de Cards"
icon={<BarChart3Icon className="w-4 h-4" />}
value={stats.totalCards}
description="Cards no projeto"
isLoading={false}
/>
<CardDash
title="Cards Ativos"
icon={<BarChart3Icon className="w-4 h-4" />}
value={stats.activeCards}
description="Cards em andamento"
isLoading={false}
/>
<CardDash
title="Cards Concluídos"
icon={<BarChart3Icon className="w-4 h-4" />}
value={stats.completedCards}
description="Cards finalizados"
isLoading={false}
/>
<CardDash
title="Horas Estimadas"
icon={<CalendarIcon className="w-4 h-4" />}
value={stats.estimatedHours}
description="Horas estimadas total"
isLoading={false}
/>
<CardDash
title="Horas Produzidas"
icon={<BarChart3Icon className="w-4 h-4" />}
valueProject={stats.estimatedHours}
value={stats.spentHours}
description="Horas produzidas total"
isLoading={false}
caculavel={true}
formatAsTime={true}
/>
</div>
);
}

View File

@ -0,0 +1,79 @@
# Componentes do Projeto
Esta pasta contém todos os componentes extraídos do arquivo `page.tsx` para manter uma arquitetura mais limpa e facilitar a manutenção.
## Estrutura de Arquivos
### Componentes UI
- **`NotificationToast.tsx`** - Componente para exibir notificações de sucesso e erro
- **`ProjectHeader.tsx`** - Cabeçalho do projeto com título, descrição e botão de novo card
- **`ProjectStats.tsx`** - Cards de estatísticas do projeto (total de cards, horas, etc.)
- **`ProjectInfo.tsx`** - Informações básicas do projeto (URL, datas, usuário)
- **`ProjectMembers.tsx`** - Lista de membros do projeto com avatares
- **`ProjectKanban.tsx`** - Componente principal do kanban
- **`KanbanCard.tsx`** - Card individual do kanban
### Hooks
- **`useProject.tsx`** - Hook personalizado que gerencia toda a lógica do projeto
### Utilitários
- **`index.ts`** - Arquivo de índice para exportar todos os componentes
## Benefícios da Refatoração
1. **Separação de Responsabilidades**: Cada componente tem uma responsabilidade específica
2. **Reutilização**: Componentes podem ser reutilizados em outras partes da aplicação
3. **Manutenibilidade**: Código mais fácil de manter e debugar
4. **Testabilidade**: Componentes isolados são mais fáceis de testar
5. **Legibilidade**: Código mais limpo e organizado
## Como Usar
```tsx
import {
NotificationToast,
ProjectHeader,
ProjectStats,
ProjectInfo,
ProjectMembers,
ProjectKanban,
useProject,
} from './components';
export default function ProjectPage() {
const {
project,
isLoading,
error,
updatingCards,
notification,
setNotification,
handleDataChange,
} = useProject();
// ... resto do código
}
```
## Funcionalidades
### useProject Hook
- Gerencia o estado do projeto
- Faz requisições para buscar dados
- Gerencia notificações
- Controla atualizações de cards do kanban
- Gerencia loading states e erros
### Componentes
- **NotificationToast**: Exibe notificações temporárias
- **ProjectHeader**: Interface do cabeçalho do projeto
- **ProjectStats**: Métricas e estatísticas do projeto
- **ProjectInfo**: Informações detalhadas do projeto
- **ProjectMembers**: Lista de membros com avatares
- **ProjectKanban**: Interface drag-and-drop do kanban
- **KanbanCard**: Cards individuais do kanban

View File

@ -0,0 +1,10 @@
export { default as CardEditDialog } from './CardEditDialog';
export { default as CreateCardDialog } from './CreateCardDialog';
export { default as KanbanCard } from './KanbanCard';
export { default as NotificationToast } from './NotificationToast';
export { default as ProjectHeader } from './ProjectHeader';
export { default as ProjectInfo } from './ProjectInfo';
export { default as ProjectKanban } from './ProjectKanban';
export { default as ProjectMembers } from './ProjectMembers';
export { default as ProjectStats } from './ProjectStats';
export { default as useProject } from './useProject';

View File

@ -0,0 +1,457 @@
import { useOrganization } from '@/context/organizationContext';
import { useTimer } from '@/context/timerContext';
import useAuth from '@/hook/useAuth';
import { authClient } from '@/lib/auth-client';
import { Project } from '@/types/Project';
import { formatTimeSpent } from '@/utils/timeUtils';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
type KanbanItemProps = {
id: string;
name: string;
column: string;
order?: number;
} & Record<string, unknown>;
export default function useProject() {
const { isAuthenticated } = useAuth();
const { selectedOrganization } = useOrganization();
const { setOnTimeSaved } = useTimer();
const router = useRouter();
const [project, setProject] = useState<Project | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [updatingCards, setUpdatingCards] = useState<Set<string>>(new Set());
const [notification, setNotification] = useState<{
message: string;
type: 'success' | 'error';
} | null>(null);
const { id } = useParams();
// Configurar callback para quando o tempo for salvo
useEffect(() => {
const handleTimeSaved = (cardId: string, timeSpent: number) => {
if (!cardId || !timeSpent) return;
setProject((currentProject) => {
if (currentProject) {
// Atualizar o tempo gasto do card no estado local
const updatedCards = currentProject.cards.map((card) => {
if (card.id === cardId) {
return {
...card,
timeSpent: card.timeSpent + timeSpent,
};
}
return card;
});
setNotification({
message: `Tempo de ${formatTimeSpent(
timeSpent
)} adicionado ao card`,
type: 'success',
});
return { ...currentProject, cards: updatedCards };
}
return currentProject;
});
};
setOnTimeSaved(handleTimeSaved);
// Cleanup function
return () => {
setOnTimeSaved(() => {});
};
}, []); // Removendo setOnTimeSaved da dependência
// Limpar notificação automaticamente
useEffect(() => {
if (notification) {
const timer = setTimeout(() => {
setNotification(null);
}, 3000);
return () => clearTimeout(timer);
}
}, [notification]);
useEffect(() => {
const getProjectData = async () => {
try {
setIsLoading(true);
setError(null);
const session = await authClient.getSession();
if (session?.data?.user) {
const projectData = await getProject();
setProject(projectData);
} else {
console.error('Usuário não autenticado - sessão:', session);
setError('Usuário não autenticado');
}
} catch (error) {
console.error('Erro ao buscar dados do projeto:', error);
setError('Erro ao carregar os dados do projeto');
} finally {
setIsLoading(false);
}
};
const getProject = async (): Promise<Project | null> => {
try {
const response = await fetch(`/api/getProject/${id}`, {
credentials: 'include',
});
if (!response.ok) {
console.error(
`Erro HTTP: ${response.status} - ${response.statusText}`
);
const errorText = await response.text();
console.error('Corpo do erro:', errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data;
} catch (error) {
console.error('Erro ao buscar projeto:', error);
return null;
}
};
if (isAuthenticated) {
try {
getProjectData();
} catch (error) {
console.error('Erro ao carregar dados do projeto:', error);
setIsLoading(false);
}
} else {
setIsLoading(false);
}
}, [isAuthenticated, id]);
// Verificar se há organização selecionada
useEffect(() => {
// Só verificar se não está carregando e não há organização
if (!selectedOrganization && !isLoading) {
setNotification({
message: 'Nenhuma organização selecionada',
type: 'error',
});
setTimeout(() => {
router.push('/dashboard');
}, 2000);
}
}, [selectedOrganization, isLoading, router]);
// Verificar se o projeto pertence à organização selecionada (apenas após carregamento completo)
useEffect(() => {
// Só verificar se temos todos os dados necessários
if (
project &&
selectedOrganization &&
!isLoading &&
project.organizationId
) {
// Verificar se o projeto pertence à organização selecionada
if (project.organizationId !== selectedOrganization.id) {
setNotification({
message: 'Este projeto não pertence à organização selecionada',
type: 'error',
});
// Redirecionar para o dashboard após um breve delay
setTimeout(() => {
router.push('/dashboard');
}, 2000);
}
}
}, [project, selectedOrganization, isLoading, router]);
const handleDataChange = async (newData: KanbanItemProps[]) => {
if (!project) return;
// Encontrar cards que mudaram de status
const changedStatusCards = newData.filter((newItem) => {
const originalCard = project.cards.find((card) => card.id === newItem.id);
return originalCard && originalCard.status !== newItem.column;
});
// Encontrar cards que mudaram de ordem (incluindo mudanças de status)
const changedOrderCards = newData.filter((newItem, newIndex) => {
const originalCard = project.cards.find((card) => card.id === newItem.id);
if (!originalCard) return false;
// Verificar se mudou de status ou ordem
const statusChanged = originalCard.status !== newItem.column;
const orderChanged = originalCard.order !== (newItem.order || newIndex);
return statusChanged || orderChanged;
});
// Atualizar o estado local primeiro para feedback imediato
const updatedCards = newData.map((item, index) => {
const originalCard = project.cards.find((card) => card.id === item.id);
if (!originalCard) {
// Se não encontrar o card original, criar um objeto básico
return {
id: item.id,
name: item.name,
description: '',
status: item.column,
hours: 0,
timeSpent: 0,
order: item.order !== undefined ? item.order : index,
};
}
return {
...originalCard,
status: item.column,
order: item.order !== undefined ? item.order : index,
};
});
setProject({ ...project, cards: updatedCards });
// Atualizar status no banco de dados
for (const changedCard of changedStatusCards) {
try {
// Adicionar card à lista de cards sendo atualizados
setUpdatingCards((prev) => new Set(prev).add(changedCard.id));
const response = await fetch('/api/updateCardStatus', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
cardId: changedCard.id,
newStatus: changedCard.column,
}),
});
if (!response.ok) {
console.error('Erro ao atualizar card:', response.status);
setNotification({
message: 'Erro ao atualizar status do card',
type: 'error',
});
// Reverter mudança local se falhar
const revertedCards = project.cards.map((card) => {
if (card.id === changedCard.id) {
const originalCard = project.cards.find(
(c) => c.id === changedCard.id
);
return originalCard || card;
}
return card;
});
setProject({ ...project, cards: revertedCards });
} else {
setNotification({
message: 'Status do card atualizado com sucesso',
type: 'success',
});
}
} catch (error) {
console.error('Erro ao atualizar card:', error);
// Reverter mudança local se falhar
const revertedCards = project.cards.map((card) => {
if (card.id === changedCard.id) {
const originalCard = project.cards.find(
(c) => c.id === changedCard.id
);
return originalCard || card;
}
return card;
});
setProject({ ...project, cards: revertedCards });
} finally {
// Remover card da lista de cards sendo atualizados
setUpdatingCards((prev) => {
const newSet = new Set(prev);
newSet.delete(changedCard.id);
return newSet;
});
}
}
// Atualizar ordem no banco de dados
if (changedOrderCards.length > 0) {
try {
// Preparar dados para atualização em lote
// Incluir todos os cards que foram afetados pela mudança
const cardOrders = newData.map((card, index) => ({
cardId: card.id,
order: index,
}));
const response = await fetch('/api/updateCardOrder', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ cardOrders }),
});
if (!response.ok) {
console.error('Erro ao atualizar ordem dos cards:', response.status);
setNotification({
message: 'Erro ao atualizar ordem dos cards',
type: 'error',
});
} else {
setNotification({
message: 'Ordem dos cards atualizada com sucesso',
type: 'success',
});
// Atualizar o estado local com os dados do banco
const responseData = await response.json();
if (responseData.success && responseData.data) {
const updatedCardsFromDB = updatedCards.map((card) => {
const dbCard = responseData.data.find(
(dbCard: { id: string; order: number }) => dbCard.id === card.id
);
return dbCard ? { ...card, order: dbCard.order } : card;
});
setProject({ ...project, cards: updatedCardsFromDB });
}
}
} catch (error) {
console.error('Erro ao atualizar ordem dos cards:', error);
setNotification({
message: 'Erro ao atualizar ordem dos cards',
type: 'error',
});
}
}
};
const handleCardUpdate = async (updatedCard: {
id: string;
name: string;
description?: string;
status: string;
hours: number;
timeSpent: number;
isManualTimeAddition?: boolean;
}) => {
if (!project) return;
try {
// Adicionar card à lista de cards sendo atualizados
setUpdatingCards((prev) => new Set(prev).add(updatedCard.id));
// Fazer requisição para atualizar o card
const response = await fetch('/api/updateCard', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(updatedCard),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
// Atualizar o estado local
const updatedCards = project.cards.map((card) => {
if (card.id === updatedCard.id) {
// Se for uma adição manual de tempo, somar ao tempo existente
// Se não for adição manual, manter o tempo existente
const newTimeSpent = updatedCard.isManualTimeAddition
? card.timeSpent + updatedCard.timeSpent
: card.timeSpent;
return {
...card,
name: updatedCard.name,
description: updatedCard.description || card.description,
status: updatedCard.status,
hours: updatedCard.hours,
timeSpent: newTimeSpent,
};
}
return card;
});
setProject({ ...project, cards: updatedCards });
setNotification({
message: 'Card atualizado com sucesso',
type: 'success',
});
} else {
throw new Error(result.message || 'Erro ao atualizar card');
}
} catch (error) {
console.error('Erro ao atualizar card:', error);
setNotification({
message: 'Erro ao atualizar card',
type: 'error',
});
throw error;
} finally {
// Remover card da lista de cards sendo atualizados
setUpdatingCards((prev) => {
const newSet = new Set(prev);
newSet.delete(updatedCard.id);
return newSet;
});
}
};
const handleCreateCard = async (newCard: {
id: string;
name: string;
description: string;
status: string;
hours: number;
timeSpent: number;
order: number;
}) => {
if (!project) return;
try {
// Adicionar o novo card ao estado local
const updatedCards = [...project.cards, newCard];
setProject({ ...project, cards: updatedCards });
setNotification({
message: 'Card criado com sucesso',
type: 'success',
});
} catch (error) {
console.error('Erro ao criar card:', error);
setNotification({
message: 'Erro ao criar card',
type: 'error',
});
throw error;
}
};
return {
project,
isLoading,
error,
updatingCards,
notification,
setNotification,
handleDataChange,
handleCardUpdate,
handleCreateCard,
};
}

View File

@ -0,0 +1,80 @@
'use client';
import Loading from '@/components/Loading';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import {
NotificationToast,
ProjectHeader,
ProjectInfo,
ProjectKanban,
ProjectMembers,
ProjectStats,
useProject,
} from './components';
export default function ProjectPage() {
const {
project,
isLoading,
error,
updatingCards,
notification,
setNotification,
handleDataChange,
handleCardUpdate,
handleCreateCard,
} = useProject();
if (isLoading) {
return <Loading />;
}
if (error) {
return <div>Erro: {error}</div>;
}
return (
<div className="min-h-screen bg-background">
<NotificationToast
notification={notification}
onClose={() => setNotification(null)}
/>
<div className="container mx-auto px-4 py-2">
{/* Navegação de volta */}
<div className="mb-4">
<Link href="/dashboard">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Voltar ao Dashboard
</Button>
</Link>
</div>
<ProjectHeader project={project} onCreateCard={handleCreateCard} />
{project && (
<main className="flex flex-col gap-4">
{/* Cards do Projeto */}
<div className="flex flex-col gap-4">
<ProjectStats project={project} />
</div>
<div className="flex justify-start gap-2 min-h-36 items-stretch">
<ProjectInfo project={project} />
<ProjectMembers project={project} />
</div>
{/* Kanban */}
<ProjectKanban
project={project}
updatingCards={updatingCards}
onDataChange={handleDataChange}
onCardUpdate={handleCardUpdate}
/>
</main>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,130 @@
import { auth } from '@/lib/auth';
import {
addAllowedEmail,
getAllowedEmails,
removeAllowedEmail,
} from '@/lib/email-validation';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const organizationId = searchParams.get('organizationId');
if (!organizationId) {
return NextResponse.json(
{ error: 'ID da organização é obrigatório' },
{ status: 400 }
);
}
const allowedEmails = await getAllowedEmails(organizationId);
return NextResponse.json({ allowedEmails });
} catch (error) {
console.error('Erro ao buscar emails permitidos:', error);
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const { email, organizationId } = await request.json();
if (!email) {
return NextResponse.json(
{ error: 'Email é obrigatório' },
{ status: 400 }
);
}
if (!organizationId) {
return NextResponse.json(
{ error: 'ID da organização é obrigatório' },
{ status: 400 }
);
}
const result = await addAllowedEmail(
email,
organizationId,
session.user.id
);
if (result.success) {
return NextResponse.json({ message: result.message });
} else {
return NextResponse.json({ error: result.message }, { status: 400 });
}
} catch (error) {
console.error('Erro ao adicionar email permitido:', error);
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const { email, organizationId } = await request.json();
if (!email) {
return NextResponse.json(
{ error: 'Email é obrigatório' },
{ status: 400 }
);
}
if (!organizationId) {
return NextResponse.json(
{ error: 'ID da organização é obrigatório' },
{ status: 400 }
);
}
const success = await removeAllowedEmail(email, organizationId);
if (success) {
return NextResponse.json({ message: 'Email removido com sucesso' });
} else {
return NextResponse.json(
{ error: 'Erro ao remover email' },
{ status: 400 }
);
}
} catch (error) {
console.error('Erro ao remover email permitido:', error);
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,6 @@
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
import { corsHeaders, handleCors } from '@/lib/cors';
const handler = toNextJsHandler(auth);
export const { GET, POST } = handler;

View File

@ -0,0 +1,91 @@
import { db } from '@/db';
import { cardTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { corsHeaders, handleCors } from '@/lib/cors';
import { eq, max } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json(
{ success: false, message: 'Não autorizado' },
{ status: 401 }
);
return corsHeaders(response);
}
const body = await request.json();
const { name, description, status, hours, projectId } = body;
// Validar dados obrigatórios
if (!name || !projectId) {
const response = NextResponse.json(
{
success: false,
message: 'Nome do card e ID do projeto são obrigatórios',
},
{ status: 400 }
);
return corsHeaders(response);
}
// Buscar a maior ordem atual para o projeto
const maxOrderResult = await db
.select({ maxOrder: max(cardTable.order) })
.from(cardTable)
.where(eq(cardTable.projectId, projectId));
const nextOrder = (maxOrderResult[0]?.maxOrder || 0) + 1;
// Criar o novo card
const [newCard] = await db
.insert(cardTable)
.values({
name: name.trim(),
description: description?.trim() || '',
status: status || 'A fazer',
estimatedTime: hours || 0,
order: nextOrder,
projectId,
createdBy: session.user.id,
branch: 'main', // Valor padrão
})
.returning();
const response = NextResponse.json({
success: true,
message: 'Card criado com sucesso',
data: {
id: newCard.id,
name: newCard.name,
description: newCard.description,
status: newCard.status,
hours: newCard.estimatedTime,
timeSpent: 0,
order: newCard.order,
createdAt: newCard.createdAt,
updatedAt: newCard.updatedAt,
projectId: newCard.projectId,
createdBy: newCard.createdBy,
},
});
return corsHeaders(response);
} catch (error) {
console.error('Erro detalhado ao criar card:', error);
const response = NextResponse.json(
{ success: false, message: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,57 @@
import { db } from '@/db';
import { cardCommentTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json(
{ success: false, error: 'Usuário não autenticado' },
{ status: 401 }
);
}
const body = await request.json();
const { cardId, content } = body;
if (!cardId || !content) {
return NextResponse.json(
{ success: false, error: 'ID do card e conteúdo são obrigatórios' },
{ status: 400 }
);
}
if (content.trim().length === 0) {
return NextResponse.json(
{ success: false, error: 'O comentário não pode estar vazio' },
{ status: 400 }
);
}
// Criar o comentário
const [newComment] = await db
.insert(cardCommentTable)
.values({
cardId,
content: content.trim(),
userId: session.user.id,
})
.returning();
return NextResponse.json({
success: true,
data: newComment,
});
} catch (error) {
console.error('Erro ao criar comentário:', error);
return NextResponse.json(
{ success: false, error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,118 @@
import { db } from '@/db';
import {
cardCommentTable,
organizationMemberTable,
projectTable,
} from '@/db/schema';
import { auth } from '@/lib/auth';
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ commentId: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json(
{ success: false, error: 'Usuário não autenticado' },
{ status: 401 }
);
}
const { commentId } = await params;
if (!commentId) {
return NextResponse.json(
{ success: false, error: 'ID do comentário é obrigatório' },
{ status: 400 }
);
}
// Buscar o comentário para verificar se existe
const [comment] = await db
.select({
id: cardCommentTable.id,
userId: cardCommentTable.userId,
cardId: cardCommentTable.cardId,
})
.from(cardCommentTable)
.where(eq(cardCommentTable.id, commentId));
if (!comment) {
return NextResponse.json(
{ success: false, error: 'Comentário não encontrado' },
{ status: 404 }
);
}
// Verificar se o usuário é o autor do comentário
const isAuthor = comment.userId === session.user.id;
// Se não for o autor, verificar se é admin da organização
let isAdmin = false;
if (!isAuthor) {
// Buscar o projeto do card para encontrar a organização
const [project] = await db
.select({
organizationId: projectTable.organizationId,
})
.from(projectTable)
.innerJoin(
cardCommentTable,
eq(projectTable.id, cardCommentTable.cardId)
)
.where(eq(cardCommentTable.id, commentId));
if (project?.organizationId) {
// Verificar se o usuário é admin da organização
const [member] = await db
.select({
role: organizationMemberTable.role,
})
.from(organizationMemberTable)
.where(
and(
eq(
organizationMemberTable.organizationId,
project.organizationId
),
eq(organizationMemberTable.userId, session.user.id),
eq(organizationMemberTable.isActive, true)
)
);
isAdmin = member?.role === 'admin' || member?.role === 'owner';
}
}
// Verificar permissões
if (!isAuthor && !isAdmin) {
return NextResponse.json(
{
success: false,
error: 'Você não tem permissão para deletar este comentário',
},
{ status: 403 }
);
}
// Deletar o comentário
await db.delete(cardCommentTable).where(eq(cardCommentTable.id, commentId));
return NextResponse.json({
success: true,
message: 'Comentário deletado com sucesso',
});
} catch (error) {
console.error('Erro ao deletar comentário:', error);
return NextResponse.json(
{ success: false, error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,47 @@
import { db } from '@/db';
import { cardCommentTable, userTable } from '@/db/schema';
import { desc, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ cardId: string }> }
) {
try {
const { cardId } = await params;
if (!cardId) {
return NextResponse.json(
{ success: false, error: 'ID do card é obrigatório' },
{ status: 400 }
);
}
// Buscar comentários com informações do usuário, ordenados do mais novo para o mais antigo
const comments = await db
.select({
id: cardCommentTable.id,
content: cardCommentTable.content,
createdAt: cardCommentTable.createdAt,
updatedAt: cardCommentTable.updatedAt,
userId: cardCommentTable.userId,
userName: userTable.name,
userEmail: userTable.email,
})
.from(cardCommentTable)
.leftJoin(userTable, eq(cardCommentTable.userId, userTable.id))
.where(eq(cardCommentTable.cardId, cardId))
.orderBy(desc(cardCommentTable.createdAt));
return NextResponse.json({
success: true,
data: comments,
});
} catch (error) {
console.error('Erro ao buscar comentários:', error);
return NextResponse.json(
{ success: false, error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,133 @@
import { db } from '@/db';
import { projectUserTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
// POST - Adicionar membro ao projeto
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { id: projectId } = await params;
const body = await request.json();
const { userId } = body;
if (!userId) {
const response = NextResponse.json(
{ error: 'ID do usuário é obrigatório' },
{ status: 400 }
);
return corsHeaders(response);
}
// Verificar se o usuário já está no projeto
const existingMember = await db
.select()
.from(projectUserTable)
.where(
eq(projectUserTable.projectId, projectId) &&
eq(projectUserTable.userId, userId)
)
.limit(1);
if (existingMember.length > 0) {
const response = NextResponse.json(
{ error: 'Usuário já é membro deste projeto' },
{ status: 400 }
);
return corsHeaders(response);
}
// Adicionar usuário ao projeto
const [newMember] = await db
.insert(projectUserTable)
.values({
projectId,
userId,
})
.returning();
const response = NextResponse.json(
{
success: true,
data: newMember,
},
{ status: 201 }
);
return corsHeaders(response);
} catch (error) {
console.error('Erro ao adicionar membro ao projeto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
// DELETE - Remover membro do projeto
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { id: projectId } = await params;
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
if (!userId) {
const response = NextResponse.json(
{ error: 'ID do usuário é obrigatório' },
{ status: 400 }
);
return corsHeaders(response);
}
// Remover usuário do projeto
await db
.delete(projectUserTable)
.where(
eq(projectUserTable.projectId, projectId) &&
eq(projectUserTable.userId, userId)
);
const response = NextResponse.json(
{
success: true,
message: 'Membro removido com sucesso',
},
{ status: 200 }
);
return corsHeaders(response);
} catch (error) {
console.error('Erro ao remover membro do projeto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,312 @@
import { db } from '@/db';
import {
cardTable,
projectTable,
projectUserTable,
timeSpentTable,
userTable,
} from '@/db/schema';
import { auth } from '@/lib/auth';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { id: projectId } = await params;
// Buscar o projeto com todos os dados relacionados
const project = await db
.select({
id: projectTable.id,
name: projectTable.name,
description: projectTable.description,
status: projectTable.status,
projectUrl: projectTable.projectUrl,
createdAt: projectTable.createdAt,
initialDate: projectTable.initialDate,
finalDate: projectTable.finalDate,
updatedAt: projectTable.updatedAt,
organizationId: projectTable.organizationId,
userId: projectTable.userId,
})
.from(projectTable)
.where(eq(projectTable.id, projectId))
.limit(1);
if (project.length === 0) {
const response = NextResponse.json(
{ error: 'Projeto não encontrado' },
{ status: 404 }
);
return corsHeaders(response);
}
const projectData = project[0];
// Buscar cards do projeto
const cards = await db
.select({
id: cardTable.id,
name: cardTable.name,
description: cardTable.description,
status: cardTable.status,
type: cardTable.type,
priority: cardTable.priority,
estimatedTime: cardTable.estimatedTime,
order: cardTable.order,
createdAt: cardTable.createdAt,
updatedAt: cardTable.updatedAt,
projectId: cardTable.projectId,
createdBy: cardTable.createdBy,
assignedTo: cardTable.assignedTo,
})
.from(cardTable)
.where(eq(cardTable.projectId, projectId))
.orderBy(cardTable.order);
// Buscar tempo gasto para cada card
const cardsWithTimeSpent = await Promise.all(
cards.map(async (card) => {
const timeSpent = await db
.select({
timeSpent: timeSpentTable.timeSpent,
})
.from(timeSpentTable)
.where(eq(timeSpentTable.cardId, card.id));
const totalTimeSpent = timeSpent.reduce((acc, record) => {
return acc + (record.timeSpent || 0);
}, 0);
return {
id: card.id,
name: card.name,
description: card.description,
status: card.status,
type: card.type,
priority: card.priority,
hours: card.estimatedTime || 0,
timeSpent: totalTimeSpent,
order: card.order,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
projectId: card.projectId,
createdBy: card.createdBy,
assignedTo: card.assignedTo,
};
})
);
// Buscar usuários do projeto
const projectUsers = await db
.select({
id: projectUserTable.id,
projectId: projectUserTable.projectId,
userId: projectUserTable.userId,
})
.from(projectUserTable)
.where(eq(projectUserTable.projectId, projectId));
// Buscar dados dos usuários
const users = await Promise.all(
projectUsers.map(async (projectUser) => {
const user = await db
.select({
id: userTable.id,
name: userTable.name,
email: userTable.email,
image: userTable.image,
})
.from(userTable)
.where(eq(userTable.id, projectUser.userId!))
.limit(1);
return user[0];
})
);
// Formatar o projeto com os dados relacionados
const formattedProject = {
id: projectData.id,
name: projectData.name,
description: projectData.description,
status: projectData.status,
projectUrl: projectData.projectUrl,
createdAt: projectData.createdAt,
initialDate: projectData.initialDate,
finalDate: projectData.finalDate,
updatedAt: projectData.updatedAt,
organizationId: projectData.organizationId,
userId: projectData.userId,
cards: cardsWithTimeSpent,
users: users.filter(Boolean),
};
const response = NextResponse.json({
success: true,
data: formattedProject,
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao buscar projeto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { id: projectId } = await params;
const body = await request.json();
const {
name,
description,
projectUrl,
status,
initialDate,
finalDate,
keyRepository,
repositoryUrl,
} = body;
// Verificar se o projeto existe
const existingProject = await db
.select()
.from(projectTable)
.where(eq(projectTable.id, projectId));
if (existingProject.length === 0) {
const response = NextResponse.json(
{ error: 'Projeto não encontrado' },
{ status: 404 }
);
return corsHeaders(response);
}
// Atualizar projeto
const [updatedProject] = await db
.update(projectTable)
.set({
name: name || existingProject[0].name,
description:
description !== undefined
? description
: existingProject[0].description,
projectUrl:
projectUrl !== undefined ? projectUrl : existingProject[0].projectUrl,
status: status || existingProject[0].status,
initialDate: initialDate
? new Date(initialDate)
: existingProject[0].initialDate,
finalDate:
finalDate !== undefined
? finalDate
? new Date(finalDate)
: null
: existingProject[0].finalDate,
keyRepository:
keyRepository !== undefined
? keyRepository
: existingProject[0].keyRepository,
repositoryUrl:
repositoryUrl !== undefined
? repositoryUrl
: existingProject[0].repositoryUrl,
updatedAt: new Date(),
})
.where(eq(projectTable.id, projectId))
.returning();
const response = NextResponse.json({
success: true,
data: updatedProject,
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao atualizar projeto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { id: projectId } = await params;
// Verificar se o projeto existe
const existingProject = await db
.select()
.from(projectTable)
.where(eq(projectTable.id, projectId));
if (existingProject.length === 0) {
const response = NextResponse.json(
{ error: 'Projeto não encontrado' },
{ status: 404 }
);
return corsHeaders(response);
}
// Deletar projeto
await db.delete(projectTable).where(eq(projectTable.id, projectId));
const response = NextResponse.json({
success: true,
message: 'Projeto deletado com sucesso',
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao deletar projeto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,246 @@
import { db } from '@/db';
import {
cardTable,
projectTable,
projectUserTable,
timeSpentTable,
userTable,
} from '@/db/schema';
import { auth } from '@/lib/auth';
import { and, desc, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
export async function GET(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
const organizationId = searchParams.get('organizationId');
const limit = searchParams.get('limit')
? parseInt(searchParams.get('limit')!)
: 50;
const offset = searchParams.get('offset')
? parseInt(searchParams.get('offset')!)
: 0;
// Se userId não foi fornecido, usar o ID do usuário da sessão
const targetUserId = userId || session.user.id;
// Construir as condições de filtro
const conditions = [eq(projectUserTable.userId, targetUserId)];
if (organizationId) {
conditions.push(eq(projectTable.organizationId, organizationId));
}
// Buscar os projetos do usuário
const userProjects = await db
.select({
projectUserId: projectUserTable.id,
projectId: projectTable.id,
name: projectTable.name,
description: projectTable.description,
status: projectTable.status,
projectUrl: projectTable.projectUrl,
createdAt: projectTable.createdAt,
initialDate: projectTable.initialDate,
finalDate: projectTable.finalDate,
updatedAt: projectTable.updatedAt,
organizationId: projectTable.organizationId,
userId: userTable.id,
userName: userTable.name,
userEmail: userTable.email,
})
.from(projectUserTable)
.innerJoin(projectTable, eq(projectUserTable.projectId, projectTable.id))
.innerJoin(userTable, eq(projectUserTable.userId, userTable.id))
.where(and(...conditions))
.orderBy(desc(projectTable.createdAt))
.limit(limit)
.offset(offset);
// Para cada projeto, buscar os cards
const projectsWithCards = await Promise.all(
userProjects.map(async (project) => {
const cards = await db
.select({
id: cardTable.id,
status: cardTable.status,
estimatedTime: cardTable.estimatedTime,
name: cardTable.name,
order: cardTable.order,
})
.from(cardTable)
.where(eq(cardTable.projectId, project.projectId));
// Para cada card, buscar o tempo gasto
const cardsWithTimeSpent = await Promise.all(
cards.map(async (card) => {
const timeSpent = await db
.select({
timeSpent: timeSpentTable.timeSpent,
})
.from(timeSpentTable)
.where(eq(timeSpentTable.cardId, card.id));
// Calcular o total de tempo gasto para este card
const totalTimeSpent = timeSpent.reduce((acc, record) => {
return acc + (record.timeSpent || 0);
}, 0);
return {
id: card.id,
status: card.status,
hours: card.estimatedTime || 0,
timeSpent: totalTimeSpent,
name: card.name,
order: card.order,
};
})
);
// Buscar todos os usuários do projeto
const projectUsers = await db
.select({
id: userTable.id,
name: userTable.name,
email: userTable.email,
image: userTable.image,
})
.from(projectUserTable)
.innerJoin(userTable, eq(projectUserTable.userId, userTable.id))
.where(eq(projectUserTable.projectId, project.projectId));
return {
id: project.projectId,
projectUserId: project.projectUserId,
name: project.name,
description: project.description,
status: project.status,
projectUrl: project.projectUrl,
createdAt: project.createdAt,
initialDate: project.initialDate,
finalDate: project.finalDate,
updatedAt: project.updatedAt,
organizationId: project.organizationId,
users: projectUsers,
cards: cardsWithTimeSpent,
};
})
);
const response = NextResponse.json({
success: true,
data: projectsWithCards,
pagination: {
limit,
offset,
total: projectsWithCards.length,
},
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao buscar projetos:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
export async function POST(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const body = await request.json();
const {
name,
description,
projectUrl,
initialDate,
finalDate,
organizationId,
keyRepository,
status,
repositoryUrl,
} = body;
// Validação básica
if (!name || !initialDate || !organizationId) {
const response = NextResponse.json(
{ error: 'Nome, data inicial e organização são obrigatórios' },
{ status: 400 }
);
return corsHeaders(response);
}
// Criar novo projeto
const [newProject] = await db
.insert(projectTable)
.values({
name,
description,
projectUrl: projectUrl || '',
initialDate: new Date(initialDate),
finalDate: finalDate ? new Date(finalDate) : null,
userId: session.user.id,
organizationId,
keyRepository: keyRepository || '',
status: status || 'active',
repositoryUrl: repositoryUrl || '',
})
.returning();
// Criar relação na tabela project_user
const [projectUserRelation] = await db
.insert(projectUserTable)
.values({
projectId: newProject.id,
userId: session.user.id,
})
.returning();
const response = NextResponse.json(
{
success: true,
data: {
...newProject,
projectUserId: projectUserRelation.id,
},
},
{ status: 201 }
);
return corsHeaders(response);
} catch (error) {
console.error('Erro ao criar projeto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,60 @@
import { db } from '@/db';
import { timeSpentTable, userTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { desc, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ cardId: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user?.id) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { cardId } = await params;
if (!cardId) {
const response = NextResponse.json(
{ error: 'cardId é obrigatório' },
{ status: 400 }
);
return corsHeaders(response);
}
// Buscar histórico de tempo gasto com dados do usuário
const timeSpentHistory = await db
.select({
id: timeSpentTable.id,
timeSpent: timeSpentTable.timeSpent,
createdAt: timeSpentTable.createdAt,
userId: timeSpentTable.userId,
userName: userTable.name,
userEmail: userTable.email,
})
.from(timeSpentTable)
.leftJoin(userTable, eq(timeSpentTable.userId, userTable.id))
.where(eq(timeSpentTable.cardId, cardId))
.orderBy(desc(timeSpentTable.createdAt));
const response = NextResponse.json({
success: true,
data: timeSpentHistory,
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao buscar histórico de tempo gasto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,116 @@
import { db } from '@/db';
import { organizationMemberTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { and, eq } from 'drizzle-orm';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string; memberId: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const { id: organizationId, memberId } = await params;
const { role } = await request.json();
if (!role || !['admin', 'member'].includes(role)) {
return NextResponse.json(
{ error: 'Role inválido. Deve ser "admin" ou "member"' },
{ status: 400 }
);
}
// Verificar se o usuário atual é owner ou admin da organização
const currentUserMembership = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, session.user.id)
)
)
.limit(1);
if (currentUserMembership.length === 0) {
return NextResponse.json(
{ error: 'Usuário não é membro desta organização' },
{ status: 403 }
);
}
const currentUserRole = currentUserMembership[0].role;
if (currentUserRole !== 'owner' && currentUserRole !== 'admin') {
return NextResponse.json(
{ error: 'Apenas owners e admins podem alterar roles de membros' },
{ status: 403 }
);
}
// Verificar se o membro existe na organização
const targetMember = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, memberId)
)
)
.limit(1);
if (targetMember.length === 0) {
return NextResponse.json(
{ error: 'Membro não encontrado nesta organização' },
{ status: 404 }
);
}
// Impedir que admins alterem o role de owners
if (currentUserRole === 'admin' && targetMember[0].role === 'owner') {
return NextResponse.json(
{ error: 'Admins não podem alterar o role de owners' },
{ status: 403 }
);
}
// Impedir que owners se tornem admins ou members
if (targetMember[0].role === 'owner' && role !== 'owner') {
return NextResponse.json(
{ error: 'Owners não podem ter seu role alterado' },
{ status: 403 }
);
}
// Atualizar o role do membro
await db
.update(organizationMemberTable)
.set({ role })
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, memberId)
)
);
return NextResponse.json({
message: 'Role do membro alterado com sucesso',
newRole: role,
});
} catch (error) {
console.error('Erro ao alterar role do membro:', error);
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,334 @@
import { db } from '@/db';
import {
organizationMemberTable,
projectTable,
projectUserTable,
userTable,
} from '@/db/schema';
import { auth } from '@/lib/auth';
import { corsHeaders } from '@/lib/cors';
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
// GET - Listar membros da organização
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json(
{ error: 'Não autorizado' },
{ status: 401 }
);
return corsHeaders(response);
}
const { id: organizationId } = await params;
// Verificar se o usuário é membro da organização
const userMembership = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, session.user.id)
)
)
.limit(1);
if (userMembership.length === 0) {
const response = NextResponse.json(
{ error: 'Acesso negado' },
{ status: 403 }
);
return corsHeaders(response);
}
// Buscar todos os membros da organização
const members = await db
.select({
id: organizationMemberTable.id,
userId: organizationMemberTable.userId,
role: organizationMemberTable.role,
isActive: organizationMemberTable.isActive,
joinedAt: organizationMemberTable.joinedAt,
updatedAt: organizationMemberTable.updatedAt,
user: {
id: userTable.id,
name: userTable.name,
email: userTable.email,
image: userTable.image,
},
})
.from(organizationMemberTable)
.innerJoin(userTable, eq(organizationMemberTable.userId, userTable.id))
.where(eq(organizationMemberTable.organizationId, organizationId));
const response = NextResponse.json({ data: members });
return corsHeaders(response);
} catch (error) {
console.error('Erro ao buscar membros da organização:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
// POST - Adicionar membro à organização
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json(
{ error: 'Não autorizado' },
{ status: 401 }
);
return corsHeaders(response);
}
const { id: organizationId } = await params;
const { userId, role = 'member' } = await request.json();
if (!userId) {
const response = NextResponse.json(
{ error: 'ID do usuário é obrigatório' },
{ status: 400 }
);
return corsHeaders(response);
}
// Verificar se o usuário atual tem permissão para adicionar membros
const currentUserMembership = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, session.user.id)
)
)
.limit(1);
if (
currentUserMembership.length === 0 ||
!['owner', 'admin'].includes(currentUserMembership[0].role || '')
) {
const response = NextResponse.json(
{ error: 'Permissão insuficiente' },
{ status: 403 }
);
return corsHeaders(response);
}
// Verificar se o usuário já é membro
const existingMembership = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, userId)
)
)
.limit(1);
if (existingMembership.length > 0) {
const response = NextResponse.json(
{ error: 'Usuário já é membro da organização' },
{ status: 400 }
);
return corsHeaders(response);
}
// Adicionar membro
const [newMember] = await db
.insert(organizationMemberTable)
.values({
organizationId,
userId,
role,
})
.returning();
// Buscar todos os projetos da organização
const organizationProjects = await db
.select({
id: projectTable.id,
})
.from(projectTable)
.where(eq(projectTable.organizationId, organizationId));
const projectCount = organizationProjects.length;
// Adicionar o novo membro a todos os projetos da organização
let projectsToAdd: Array<{ projectId: string; userId: string }> = [];
if (organizationProjects.length > 0) {
const projectIds = organizationProjects.map((project) => project.id);
// Verificar quais projetos o usuário já está
const existingProjectMemberships = await db
.select({
projectId: projectUserTable.projectId,
})
.from(projectUserTable)
.where(eq(projectUserTable.userId, userId));
const existingProjectIds = existingProjectMemberships.map(
(membership) => membership.projectId
);
// Filtrar projetos onde o usuário ainda não está
projectsToAdd = projectIds
.filter((projectId) => !existingProjectIds.includes(projectId))
.map((projectId) => ({
projectId,
userId,
}));
if (projectsToAdd.length > 0) {
await db.insert(projectUserTable).values(projectsToAdd);
console.log(
`Usuário ${userId} adicionado a ${projectsToAdd.length} projetos da organização ${organizationId}`
);
}
}
const response = NextResponse.json(
{
data: newMember,
projectCount,
projectsAdded: projectsToAdd.length,
},
{ status: 201 }
);
return corsHeaders(response);
} catch (error) {
console.error('Erro ao adicionar membro:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
// DELETE - Remover membro da organização
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json(
{ error: 'Não autorizado' },
{ status: 401 }
);
return corsHeaders(response);
}
const { id: organizationId } = await params;
const { searchParams } = new URL(request.url);
const memberId = searchParams.get('memberId');
if (!memberId) {
const response = NextResponse.json(
{ error: 'ID do membro é obrigatório' },
{ status: 400 }
);
return corsHeaders(response);
}
// Verificar se o usuário atual tem permissão para remover membros
const currentUserMembership = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, session.user.id)
)
)
.limit(1);
if (
currentUserMembership.length === 0 ||
!['owner', 'admin'].includes(currentUserMembership[0].role || '')
) {
const response = NextResponse.json(
{ error: 'Permissão insuficiente' },
{ status: 403 }
);
return corsHeaders(response);
}
// Verificar se o membro existe
const memberToRemove = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.id, memberId)
)
)
.limit(1);
if (memberToRemove.length === 0) {
const response = NextResponse.json(
{ error: 'Membro não encontrado' },
{ status: 404 }
);
return corsHeaders(response);
}
// Não permitir que o owner remova a si mesmo
if (
memberToRemove[0].role === 'owner' &&
memberToRemove[0].userId === session.user.id
) {
const response = NextResponse.json(
{ error: 'Não é possível remover o proprietário da organização' },
{ status: 400 }
);
return corsHeaders(response);
}
// Remover membro
await db
.delete(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.id, memberId)
)
);
const response = NextResponse.json({
message: 'Membro removido com sucesso',
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao remover membro:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,181 @@
import { db } from '@/db';
import { organizationMemberTable, organizationTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
// PUT - Editar organização
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { id: organizationId } = await params;
const userId = session.user.id;
const { name, description } = await request.json();
// Verificar se a organização existe
const organization = await db
.select()
.from(organizationTable)
.where(eq(organizationTable.id, organizationId))
.limit(1);
if (organization.length === 0) {
const response = NextResponse.json(
{ error: 'Organização não encontrada' },
{ status: 404 }
);
return corsHeaders(response);
}
// Verificar se o usuário é owner ou admin da organização
const userMembership = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, userId)
)
)
.limit(1);
if (
userMembership.length === 0 ||
!['owner', 'admin'].includes(userMembership[0].role || '')
) {
const response = NextResponse.json(
{
error:
'Permissão insuficiente. Apenas owners e admins podem editar organizações.',
},
{ status: 403 }
);
return corsHeaders(response);
}
// Validar dados de entrada
if (!name || name.trim().length === 0) {
const response = NextResponse.json(
{ error: 'Nome da organização é obrigatório' },
{ status: 400 }
);
return corsHeaders(response);
}
// Atualizar a organização
const [updatedOrganization] = await db
.update(organizationTable)
.set({
name: name.trim(),
description: description?.trim() || null,
updatedAt: new Date(),
})
.where(eq(organizationTable.id, organizationId))
.returning();
const response = NextResponse.json({
success: true,
data: updatedOrganization,
message: 'Organização atualizada com sucesso',
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao atualizar organização:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
// DELETE - Remover organização
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { id: organizationId } = await params;
const userId = session.user.id;
// Verificar se a organização existe
const organization = await db
.select()
.from(organizationTable)
.where(eq(organizationTable.id, organizationId))
.limit(1);
if (organization.length === 0) {
const response = NextResponse.json(
{ error: 'Organização não encontrada' },
{ status: 404 }
);
return corsHeaders(response);
}
// Verificar se o usuário é owner ou admin da organização
const userMembership = await db
.select()
.from(organizationMemberTable)
.where(
and(
eq(organizationMemberTable.organizationId, organizationId),
eq(organizationMemberTable.userId, userId)
)
)
.limit(1);
if (
userMembership.length === 0 ||
!['owner', 'admin'].includes(userMembership[0].role || '')
) {
const response = NextResponse.json(
{
error:
'Permissão insuficiente. Apenas owners e admins podem remover organizações.',
},
{ status: 403 }
);
return corsHeaders(response);
}
// Remover a organização (isso também removerá todos os membros devido ao cascade)
await db
.delete(organizationTable)
.where(eq(organizationTable.id, organizationId));
const response = NextResponse.json({
success: true,
message: 'Organização removida com sucesso',
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao remover organização:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,110 @@
import { db } from '@/db';
import { organizationMemberTable, organizationTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
// GET - Listar organizações do usuário
export async function GET(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const userId = session.user.id;
// Buscar organizações onde o usuário é membro
const organizations = await db
.select({
id: organizationTable.id,
name: organizationTable.name,
description: organizationTable.description,
slug: organizationTable.slug,
logo: organizationTable.logo,
isActive: organizationTable.isActive,
createdAt: organizationTable.createdAt,
updatedAt: organizationTable.updatedAt,
role: organizationMemberTable.role,
})
.from(organizationTable)
.innerJoin(
organizationMemberTable,
eq(organizationTable.id, organizationMemberTable.organizationId)
)
.where(eq(organizationMemberTable.userId, userId));
const response = NextResponse.json({ data: organizations });
return corsHeaders(response);
} catch (error) {
console.error('Erro ao buscar organizações:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}
// POST - Criar nova organização
export async function POST(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { name, description, slug } = await request.json();
const userId = session.user.id;
if (!name || !slug) {
const response = NextResponse.json(
{ error: 'Nome e slug são obrigatórios' },
{ status: 400 }
);
return corsHeaders(response);
}
// Criar organização
const [organization] = await db
.insert(organizationTable)
.values({
name,
description,
slug,
createdBy: userId,
})
.returning();
// Adicionar criador como owner da organização
await db.insert(organizationMemberTable).values({
organizationId: organization.id,
userId,
role: 'owner',
});
const response = NextResponse.json({ data: organization }, { status: 201 });
return corsHeaders(response);
} catch (error) {
console.error('Erro ao criar organização:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,54 @@
import { db } from '@/db';
import { timeSpentTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
export async function POST(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user?.id) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const { cardId, timeSpent } = await request.json();
if (!cardId || !timeSpent) {
const response = NextResponse.json(
{ error: 'cardId e timeSpent são obrigatórios' },
{ status: 400 }
);
return corsHeaders(response);
}
// Salvar o tempo gasto em segundos
const newTimeSpent = await db
.insert(timeSpentTable)
.values({
cardId,
timeSpent,
userId: session.user.id,
})
.returning();
const response = NextResponse.json({
success: true,
data: newTimeSpent[0],
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao salvar tempo gasto:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,112 @@
import { db } from '@/db';
import { cardTable, timeSpentTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
export async function PUT(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json(
{ success: false, message: 'Não autorizado' },
{ status: 401 }
);
return corsHeaders(response);
}
const body = await request.json();
const {
id,
name,
description,
status,
hours,
timeSpent,
isManualTimeAddition,
} = body;
// Validar dados obrigatórios
if (!id || !name || !status) {
const response = NextResponse.json(
{ success: false, message: 'Dados obrigatórios não fornecidos' },
{ status: 400 }
);
return corsHeaders(response);
}
// Atualizar o card no banco de dados
const updateData: {
updatedAt: Date;
name?: string;
description?: string;
status?:
| 'A fazer'
| 'Em progresso'
| 'Em revisão'
| 'Testando'
| 'Concluido';
estimatedTime?: number;
} = {
updatedAt: new Date(),
};
// Adicionar apenas os campos que foram enviados
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (status !== undefined)
updateData.status = status as
| 'A fazer'
| 'Em progresso'
| 'Em revisão'
| 'Testando'
| 'Concluido';
if (hours !== undefined) updateData.estimatedTime = hours || 0;
const updatedCard = await db
.update(cardTable)
.set(updateData)
.where(eq(cardTable.id, id))
.returning();
if (updatedCard.length === 0) {
const response = NextResponse.json(
{ success: false, message: 'Card não encontrado' },
{ status: 404 }
);
return corsHeaders(response);
}
// Se timeSpent foi fornecido, criar um novo registro na tabela timeSpentTable
if (timeSpent !== undefined && isManualTimeAddition) {
// Sempre criar um novo registro para adições manuais de tempo
await db.insert(timeSpentTable).values({
cardId: id,
timeSpent: timeSpent || 0,
userId: session.user.id,
});
}
const response = NextResponse.json({
success: true,
message: 'Card atualizado com sucesso',
data: updatedCard[0],
});
return corsHeaders(response);
} catch (error) {
console.error('Erro detalhado ao atualizar card:', error);
const response = NextResponse.json(
{ success: false, message: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,64 @@
import { db } from '@/db';
import { cardTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
export async function PUT(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const body = await request.json();
const { cardOrders } = body;
if (!cardOrders || !Array.isArray(cardOrders)) {
const response = NextResponse.json(
{ error: 'cardOrders é obrigatório e deve ser um array' },
{ status: 400 }
);
return corsHeaders(response);
}
// Atualizar a ordem dos cards no banco de dados
const updatedCards = [];
for (const { cardId, order } of cardOrders) {
const [updatedCard] = await db
.update(cardTable)
.set({
order,
updatedAt: new Date(),
})
.where(eq(cardTable.id, cardId))
.returning();
if (updatedCard) {
updatedCards.push(updatedCard);
}
}
const response = NextResponse.json({
success: true,
data: updatedCards,
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao atualizar ordem dos cards:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,71 @@
import { db } from '@/db';
import { cardTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders, handleCors } from '@/lib/cors';
export async function PUT(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
// Verificar autenticação
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
return corsHeaders(response);
}
const body = await request.json();
const { cardId, newStatus } = body;
if (!cardId || !newStatus) {
const response = NextResponse.json(
{ error: 'cardId e newStatus são obrigatórios' },
{ status: 400 }
);
return corsHeaders(response);
}
// Verificar se o card existe
const existingCard = await db
.select()
.from(cardTable)
.where(eq(cardTable.id, cardId));
if (existingCard.length === 0) {
const response = NextResponse.json(
{ error: 'Card não encontrado' },
{ status: 404 }
);
return corsHeaders(response);
}
// Atualizar o status do card
const [updatedCard] = await db
.update(cardTable)
.set({
status: newStatus,
updatedAt: new Date(),
})
.where(eq(cardTable.id, cardId))
.returning();
const response = NextResponse.json({
success: true,
data: updatedCard,
});
return corsHeaders(response);
} catch (error) {
console.error('Erro ao atualizar status do card:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,50 @@
import { db } from '@/db';
import { userTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { corsHeaders, handleCors } from '@/lib/cors';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
// GET - Listar todos os usuários ativos
export async function GET(request: NextRequest) {
// Verificar se é uma requisição OPTIONS
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
const response = NextResponse.json(
{ error: 'Não autorizado' },
{ status: 401 }
);
return corsHeaders(response);
}
// Buscar todos os usuários ativos
const users = await db
.select({
id: userTable.id,
name: userTable.name,
email: userTable.email,
image: userTable.image,
isActive: userTable.isActive,
})
.from(userTable)
.where(eq(userTable.isActive, true))
.orderBy(userTable.name);
const response = NextResponse.json({ data: users });
return corsHeaders(response);
} catch (error) {
console.error('Erro ao buscar usuários:', error);
const response = NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
return corsHeaders(response);
}
}

View File

@ -0,0 +1,30 @@
import { isEmailAllowed } from '@/lib/email-validation';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { email, organizationId } = await request.json();
if (!email) {
return NextResponse.json(
{ error: 'Email é obrigatório' },
{ status: 400 }
);
}
const isAllowed = await isEmailAllowed(email, organizationId);
return NextResponse.json({
isAllowed,
message: isAllowed
? 'Email permitido'
: 'Email não permitido. Apenas emails do domínio jurunense.com.br ou emails externos previamente autorizados são aceitos.',
});
} catch (error) {
console.error('Erro ao validar email:', error);
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,49 @@
import { auth } from '@/lib/auth';
import { authClient } from '@/lib/auth-client';
import { isEmailAllowed } from '@/lib/email-validation';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST() {
try {
// Verificar se o usuário está autenticado
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Usuário não autenticado' },
{ status: 401 }
);
}
const email = session.user.email;
const isAllowed = await isEmailAllowed(email);
if (!isAllowed) {
// Se o email não for permitido, invalidar a sessão
await authClient.signOut();
return NextResponse.json(
{
error:
'Email não permitido. Apenas emails do domínio jurunense.com.br ou emails externos previamente autorizados são aceitos.',
isAllowed: false,
},
{ status: 403 }
);
}
return NextResponse.json({
message: 'Email permitido',
isAllowed: true,
});
} catch (error) {
console.error('Erro ao validar email do login social:', error);
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,80 @@
import { db } from '@/db';
import { allowedEmailTable, userTable } from '@/db/schema';
import { auth } from '@/lib/auth';
import { and, eq, inArray } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_DOMAIN = 'jurunense.com.br';
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const { organizationId, userIds } = await request.json();
if (!organizationId || !userIds || !Array.isArray(userIds)) {
return NextResponse.json(
{ error: 'organizationId e userIds são obrigatórios' },
{ status: 400 }
);
}
// Buscar usuários pelos IDs
const users = await db
.select({
id: userTable.id,
name: userTable.name,
email: userTable.email,
image: userTable.image,
})
.from(userTable)
.where(and(inArray(userTable.id, userIds), eq(userTable.isActive, true)));
// Buscar emails permitidos para a organização
const allowedEmails = await db
.select({
email: allowedEmailTable.email,
})
.from(allowedEmailTable)
.where(
and(
eq(allowedEmailTable.organizationId, organizationId),
eq(allowedEmailTable.isActive, true)
)
);
const allowedEmailSet = new Set(
allowedEmails.map((item) => item.email.toLowerCase())
);
// Filtrar usuários que têm emails permitidos
const validUsers = users.filter((user) => {
const email = user.email.toLowerCase();
const domain = email.split('@')[1];
// Se é do domínio jurunense.com.br, sempre permitido
if (domain === ALLOWED_DOMAIN) {
return true;
}
// Se está na lista de emails permitidos
return allowedEmailSet.has(email);
});
return NextResponse.json({
data: validUsers.map((user) => user.id),
});
} catch (error) {
console.error('Erro ao validar emails de usuários:', error);
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,105 @@
'use client';
import { authClient } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function AuthCallback() {
const [isValidating, setIsValidating] = useState(true);
const [error, setError] = useState('');
const router = useRouter();
useEffect(() => {
const validateUser = async () => {
try {
// Aguardar um pouco para garantir que a sessão foi criada
await new Promise((resolve) => setTimeout(resolve, 1000));
const session = await authClient.getSession();
if (!session?.data?.user?.email) {
setError('Erro ao obter dados do usuário');
setIsValidating(false);
return;
}
// Validar se o email é permitido
const response = await fetch('/api/validate-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: session.data.user.email,
}),
});
const data = await response.json();
if (!data.isAllowed) {
// Se o email não for permitido, fazer logout e mostrar erro
await authClient.signOut();
setError(data.message);
setIsValidating(false);
return;
}
// Se o email for permitido, redirecionar para o dashboard
router.push('/dashboard');
} catch (error) {
console.error('Erro na validação:', error);
setError('Erro interno do servidor');
setIsValidating(false);
}
};
validateUser();
}, [router]);
if (isValidating) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Validando acesso...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="text-red-500 mb-4">
<svg
className="w-16 h-16 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-red-600 mb-2">
Acesso Negado
</h1>
<p className="text-muted-foreground mb-6">{error}</p>
<button
onClick={() => router.push('/login')}
className="bg-primary text-primary-foreground px-4 py-2 rounded-md hover:bg-primary/90"
>
Voltar ao Login
</button>
</div>
</div>
);
}
return null;
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

122
src/app/globals.css Normal file
View File

@ -0,0 +1,122 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.379 0.146 265.522);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

44
src/app/layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import { ThemeProvider } from '@/components/theme-provider';
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { Toaster } from 'sonner';
import './globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Gestão de Projetos',
description: 'Ferramenta de gestão de projetos',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,64 @@
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { authClient } from '@/lib/auth-client';
import { AlertCircle, Github } from 'lucide-react';
import { useState } from 'react';
export function SignIn() {
const [emailError, setEmailError] = useState('');
const handleGitHubLogin = async () => {
try {
setEmailError(''); // Limpa erros anteriores
await authClient.signIn.social({
provider: 'github',
callbackURL: '/auth/callback', // Redirecionar para nossa página de callback
});
} catch (error) {
console.error('Erro no login:', error);
if (
error instanceof Error &&
error.message.includes('Email não permitido')
) {
setEmailError(error.message);
}
}
};
return (
<div className="w-full">
<Card className="w-full">
<div className="flex flex-col gap-5 p-5">
<h2 className="text-lg font-semibold">Login</h2>
{/* Mensagem de erro se houver */}
{emailError && (
<div className="flex items-center gap-2 text-sm text-red-600 p-3 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="h-4 w-4" />
{emailError}
</div>
)}
<div className="space-y-3">
<Button
onClick={handleGitHubLogin}
type="button"
className="w-full"
>
<Github />
Entrar com GitHub
</Button>
<div className="text-xs text-muted-foreground text-center">
<p>
Apenas emails do domínio <strong>jurunense.com.br</strong> ou
emails externos previamente autorizados são aceitos.
</p>
</div>
</div>
</div>
</Card>
</div>
);
}

23
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,23 @@
'use client';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SignIn } from './components/sign-in';
export default function LoginPage() {
return (
<>
<div className="flex w-full flex-col gap-6 p-5 items-center justify-center">
<div className="w-1/2">
<Tabs defaultValue="login">
<TabsList>
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="register">Registrar</TabsTrigger>
</TabsList>
<TabsContent value="login" className="w-full">
<SignIn />
</TabsContent>
</Tabs>
</div>
</div>
</>
);
}

48
src/app/page.tsx Normal file
View File

@ -0,0 +1,48 @@
'use client';
import { authClient } from '@/lib/auth-client';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function Home() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkSession = async () => {
try {
const session = await authClient.getSession();
if (session?.data?.user) {
router.push('/dashboard');
} else {
router.push('/login');
}
} catch (error) {
console.error('Erro:', error);
setTimeout(() => {
router.push('/login');
}, 1000);
} finally {
setIsLoading(false);
}
};
const timer = setTimeout(checkSession, 100);
return () => clearTimeout(timer);
}, [router]);
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<div className="flex flex-col items-center justify-center gap-5">
<Image src="/loading.gif" alt="loading" width={100} height={100} />
<p className="text-muted-foreground">Carregando...</p>
</div>
</div>
);
}
return null;
}

View File

@ -0,0 +1,55 @@
import { authClient } from '@/lib/auth-client';
import { User } from '@/types/User';
import { LogOutIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
export default function AvatarUsuario() {
const [user, setUser] = useState<User | null>(null);
const router = useRouter();
useEffect(() => {
const getUserData = async () => {
const session = await authClient.getSession();
if (session?.data?.user) {
setUser(session.data.user);
}
};
getUserData();
}, []);
const handleSignOut = () => {
authClient.signOut();
router.push('/');
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar>
<AvatarImage src={user?.image || ''} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{user?.name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" onClick={handleSignOut}>
<Button variant="destructive" className="w-full justify-around">
Sair
<LogOutIcon className="w-4 h-4" />
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import { Search } from 'lucide-react';
import { useState } from 'react';
import { Input } from './ui/input';
interface BarraBuscaProps {
searchTerm: string;
onSearchChange: (searchTerm: string) => void;
placeHolder: string;
}
export default function BarraBusca({
searchTerm,
onSearchChange,
placeHolder,
}: BarraBuscaProps) {
const [search, setSearch] = useState(searchTerm);
function handdleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const newSearchTerm = e.target.value;
setSearch(newSearchTerm);
onSearchChange(newSearchTerm);
}
return (
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder={placeHolder}
value={search}
onChange={handdleSearch}
className="pl-10"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,62 @@
'use client';
import { ChevronDownIcon } from 'lucide-react';
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
interface CalendarioProps {
label: string;
}
export const Calendario = forwardRef<
{ getValue: () => Date | undefined },
CalendarioProps
>(({ label }, ref) => {
const [open, setOpen] = useState(false);
const [date, setDate] = useState<Date | undefined>(undefined);
useImperativeHandle(ref, () => ({
getValue: () => date,
}));
return (
<div className="flex flex-col gap-3">
<Label htmlFor="date" className="px-1">
{label}
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-48 justify-between font-normal"
>
{date ? date.toLocaleDateString() : 'Select date'}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={date}
captionLayout="dropdown"
onSelect={(date) => {
setDate(date);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
);
});
Calendario.displayName = 'Calendario';

View File

@ -0,0 +1,74 @@
import { AspectRatio } from '@/components/ui/aspect-ratio';
import { formatTimeSpent } from '@/utils/timeUtils';
import Loading from './Loading';
interface CardDashProps {
title: string;
description: string;
value: number;
icon: React.ReactNode;
calculate?: () => number | string;
isLoading?: boolean;
valueProject?: number;
caculavel?: boolean;
formatAsTime?: boolean; // Nova prop para formatar como tempo
}
export default function CardDash({
title,
description,
icon,
value,
calculate,
isLoading,
valueProject,
caculavel,
formatAsTime = false,
}: CardDashProps) {
function calculateValue(valor: number, valorProjetado: number) {
if (valorProjetado > 0) {
// Se formatAsTime é true, o valor está em segundos, precisa converter para horas
const valorEmHoras = formatAsTime ? valor / 3600 : valor;
if (valorEmHoras >= valorProjetado) {
return 'text-red-500';
} else {
return 'text-green-500';
}
}
return 'text-foreground';
}
return (
<AspectRatio ratio={16 / 9} className="bg-muted rounded-lg mt-3">
{isLoading ? (
<Loading className="w-full h-full" width={20} height={20} />
) : (
<>
<div className="flex flex-col items-center justify-center h-full gap-2">
<div className="flex items-center justify-around w-full">
<p className="text-sm font-bold">{title}</p>
<div>{icon}</div>
</div>
{caculavel ? (
<div className={calculateValue(value, valueProject || 0)}>
{formatAsTime ? formatTimeSpent(value) : value}
</div>
) : (
<div
className={
formatAsTime ? calculateValue(value, valueProject || 0) : ''
}
>
{formatAsTime ? formatTimeSpent(value) : value}
</div>
)}
<div className="text-sm text-muted-foreground">
{calculate?.()} {description}
</div>
</div>
</>
)}
</AspectRatio>
);
}

View File

@ -0,0 +1,35 @@
import { Building, Github, Settings } from 'lucide-react';
import { useRouter } from 'next/navigation';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
export default function Configuracoes() {
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Settings className="hover:animate-spin cursor-pointer" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => router.push('/organizations')}
>
<Building />
<span className="text-sm">Organizações</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Github />
GitHub
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
);
}

171
src/components/Header.tsx Normal file
View File

@ -0,0 +1,171 @@
'use client';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useOrganization } from '@/context/organizationContext';
import useAuth from '@/hook/useAuth';
import { useTheme } from 'next-themes';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import AvatarUsuario from './AvatarUsuario';
import { ButtonToogleTheme } from './buttonToogleTheme';
import Configuracoes from './Configuracoes';
import TimerDisplay from './TimerDisplay';
export default function Header() {
const { isAuthenticated } = useAuth();
const { theme } = useTheme();
const router = useRouter();
const pathname = usePathname();
const {
selectedOrganization,
setSelectedOrganization,
organizations,
setOrganizations,
} = useOrganization();
const [isScrolled, setIsScrolled] = useState(false);
const [isLoadingOrgs, setIsLoadingOrgs] = useState(false);
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
setIsScrolled(scrollTop > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Verificar se deve redirecionar do projeto
useEffect(() => {
if (selectedOrganization && pathname.startsWith('/project/')) {
// Verificar se deve redirecionar do projeto
if (!selectedOrganization) {
router.push('/dashboard');
}
}
}, [selectedOrganization, pathname, router]);
// Carregar organizações quando o usuário estiver autenticado
useEffect(() => {
const loadOrganizations = async () => {
if (isLoadingOrgs) return;
setIsLoadingOrgs(true);
try {
const response = await fetch('/api/organizations', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
const orgs = data.data || [];
setOrganizations(orgs);
// A seleção da organização agora é gerenciada pelo contexto
// que automaticamente restaura a organização salva ou seleciona a primeira
} else {
console.error('Erro ao carregar organizações:', response.status);
}
} catch (error) {
console.error('Erro ao carregar organizações:', error);
} finally {
setIsLoadingOrgs(false);
}
};
// Carregar organizações apenas se o usuário estiver autenticado e não houver organizações carregadas
if (isAuthenticated && organizations.length === 0) {
loadOrganizations();
}
}, [isAuthenticated, organizations.length, setOrganizations, isLoadingOrgs]);
const handleOrganizationChange = (organizationId: string) => {
const org = organizations.find((o) => o.id === organizationId);
if (org) {
setSelectedOrganization(org);
}
};
const logo = theme === 'dark' ? '/logo.png' : '/logo2.png';
if (isAuthenticated) {
return (
<div>
<div>
<header
className={`container fixed top-0 left-0 right-0 z-50 mx-auto px-4 py-4 flex justify-between items-center transition-all duration-300 ${
isScrolled
? 'bg-background/70 backdrop-blur-sm shadow-sm'
: 'bg-background'
}`}
>
<Link href="/dashboard">
<Image
src={logo}
alt="logo"
width={100}
height={100}
className="cursor-pointer"
/>
</Link>
<div className="flex items-center gap-5">
{/* Temporizador */}
<TimerDisplay />
{/* Seletor de Organização */}
<div className="flex items-center gap-2">
<Select
value={selectedOrganization?.id || ''}
onValueChange={handleOrganizationChange}
disabled={isLoadingOrgs}
>
<SelectTrigger className="w-48">
<SelectValue
placeholder={
isLoadingOrgs
? 'Carregando organizações...'
: 'Selecione uma organização'
}
/>
</SelectTrigger>
<SelectContent>
{organizations.length === 0 && !isLoadingOrgs ? (
<div className="px-2 py-1 text-sm text-muted-foreground">
Nenhuma organização encontrada
</div>
) : (
organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<AvatarUsuario />
<Configuracoes />
<ButtonToogleTheme />
</div>
</header>
</div>
<div className="h-[4.375rem]"></div>
</div>
);
}
return (
<header className="flex justify-between items-center p-4">
<ButtonToogleTheme />
</header>
);
}

View File

@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
import Image from 'next/image';
interface LoadingProps {
className?: string;
width?: number;
height?: number;
}
export default function Loading({
className,
width = 100,
height = 100,
}: LoadingProps) {
return (
<div className="flex h-full w-full items-center justify-center">
<div
className={cn(
'flex flex-col items-center justify-center gap-5',
className
)}
>
<Image
src="/loading.webp"
alt="loading"
width={width}
height={height}
unoptimized
/>
<p className="text-muted-foreground">Carregando...</p>
</div>
</div>
);
}

View File

@ -0,0 +1,287 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, Mail, Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
interface AllowedEmail {
id: string;
email: string;
isActive: boolean;
createdAt: string;
createdBy: string;
}
interface OrganizationAllowedEmailsManagerProps {
organizationId: string;
organizationName: string;
onEmailListChanged?: () => void;
}
export function OrganizationAllowedEmailsManager({
organizationId,
organizationName,
onEmailListChanged,
}: OrganizationAllowedEmailsManagerProps) {
const [allowedEmails, setAllowedEmails] = useState<AllowedEmail[]>([]);
const [newEmail, setNewEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const fetchAllowedEmails = async () => {
try {
const response = await fetch(
`/api/allowed-emails?organizationId=${organizationId}`
);
if (response.ok) {
const data = await response.json();
setAllowedEmails(data.allowedEmails);
} else {
setError('Erro ao carregar emails permitidos');
}
} catch (error) {
console.error('Erro ao buscar emails permitidos:', error);
setError('Erro ao carregar emails permitidos');
}
};
useEffect(() => {
if (organizationId) {
fetchAllowedEmails();
}
}, [organizationId]);
const handleAddEmail = async () => {
if (!newEmail.trim()) {
setError('Email é obrigatório');
return;
}
setIsLoading(true);
setError('');
setSuccess('');
try {
const response = await fetch('/api/allowed-emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: newEmail.trim(),
organizationId,
}),
});
const data = await response.json();
if (response.ok) {
setSuccess('Email adicionado com sucesso');
setNewEmail('');
fetchAllowedEmails();
// Notificar que a lista de emails mudou
onEmailListChanged?.();
} else {
setError(data.error || 'Erro ao adicionar email');
}
} catch (error) {
console.error('Erro ao adicionar email:', error);
setError('Erro ao adicionar email');
} finally {
setIsLoading(false);
}
};
const handleRemoveEmail = async (email: string) => {
setIsLoading(true);
setError('');
setSuccess('');
try {
const response = await fetch('/api/allowed-emails', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
organizationId,
}),
});
const data = await response.json();
if (response.ok) {
setSuccess('Email removido com sucesso');
fetchAllowedEmails();
// Notificar que a lista de emails mudou
onEmailListChanged?.();
} else {
setError(data.error || 'Erro ao remover email');
}
} catch (error) {
console.error('Erro ao remover email:', error);
setError('Erro ao remover email');
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Emails Externos Permitidos - {organizationName}
</CardTitle>
<CardDescription>
Gerencie emails externos que podem se registrar nesta organização.
Emails do domínio jurunense.com.br são permitidos automaticamente.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Adicionar novo email */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-email">Adicionar Email Externo</Label>
<div className="flex gap-2">
<Input
id="new-email"
type="email"
placeholder="exemplo@empresa.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="flex-1"
/>
<Button
onClick={handleAddEmail}
disabled={isLoading || !newEmail.trim()}
className="px-4"
>
<Plus className="h-4 w-4" />
Adicionar
</Button>
</div>
</div>
</div>
{/* Mensagens de feedback */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md text-red-700">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-md text-green-700">
<Mail className="h-4 w-4" />
{success}
</div>
)}
{/* Lista de emails permitidos */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Emails Externos Permitidos</h3>
{allowedEmails.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
Nenhum email externo foi adicionado para esta organização ainda.
</p>
) : (
<div className="space-y-2">
{allowedEmails.map((email) => (
<div
key={email.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex items-center gap-3">
<Mail className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{email.email}</span>
<span className="text-sm text-muted-foreground">
Adicionado em{' '}
{new Date(email.createdAt).toLocaleDateString('pt-BR')}
</span>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remover Email</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja remover o email{' '}
<strong>{email.email}</strong> da lista de permitidos
desta organização? Este usuário não poderá mais se
registrar nesta organização.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleRemoveEmail(email.email)}
className="bg-red-600 hover:bg-red-700"
>
Remover
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
{/* Informações adicionais */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
<h4 className="font-semibold text-blue-900 mb-2">
Informações Importantes:
</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li>
Emails do domínio <strong>jurunense.com.br</strong> são
permitidos automaticamente
</li>
<li>
Apenas membros da organização podem gerenciar emails externos
</li>
<li>
Emails removidos não poderão mais se registrar nesta organização
</li>
<li> Usuários registrados não são afetados pela remoção</li>
</ul>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,306 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Organization, OrganizationMember } from '@/types/User';
import { PlusIcon, UsersIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
interface OrganizationManagerProps {
onOrganizationSelect?: (organization: Organization) => void;
}
export function OrganizationManager({
onOrganizationSelect,
}: OrganizationManagerProps) {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [members, setMembers] = useState<OrganizationMember[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Estados para criar organização
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newOrgName, setNewOrgName] = useState('');
const [newOrgSlug, setNewOrgSlug] = useState('');
const [newOrgDescription, setNewOrgDescription] = useState('');
// Estados para adicionar membro
const [showAddMemberDialog, setShowAddMemberDialog] = useState(false);
const [newMemberEmail, setNewMemberEmail] = useState('');
const [newMemberRole, setNewMemberRole] = useState<'member' | 'admin'>(
'member'
);
useEffect(() => {
fetchOrganizations();
}, []);
useEffect(() => {
if (selectedOrg) {
fetchMembers(selectedOrg.id);
}
}, [selectedOrg]);
const fetchOrganizations = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/organizations');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setOrganizations(result.data || []);
} catch (error) {
console.error('Erro ao buscar organizações:', error);
} finally {
setIsLoading(false);
}
};
const fetchMembers = async (organizationId: string) => {
try {
const response = await fetch(
`/api/organizations/${organizationId}/members`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setMembers(result.data || []);
} catch (error) {
console.error('Erro ao buscar membros:', error);
}
};
const handleCreateOrganization = async () => {
try {
const response = await fetch('/api/organizations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newOrgName,
slug: newOrgSlug,
description: newOrgDescription,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setOrganizations([...organizations, result.data]);
setShowCreateDialog(false);
setNewOrgName('');
setNewOrgSlug('');
setNewOrgDescription('');
} catch (error) {
console.error('Erro ao criar organização:', error);
}
};
const handleAddMember = async () => {
if (!selectedOrg) return;
try {
const response = await fetch(
`/api/organizations/${selectedOrg.id}/members`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: newMemberEmail,
role: newMemberRole,
}),
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
await fetchMembers(selectedOrg.id);
setShowAddMemberDialog(false);
setNewMemberEmail('');
setNewMemberRole('member');
} catch (error) {
console.error('Erro ao adicionar membro:', error);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Organizações</h2>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="w-4 h-4 mr-2" />
Nova Organização
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar Nova Organização</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Nome da Organização</Label>
<Input
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Nome da organização"
/>
</div>
<div>
<Label>Slug</Label>
<Input
value={newOrgSlug}
onChange={(e) => setNewOrgSlug(e.target.value)}
placeholder="slug-da-organizacao"
/>
</div>
<div>
<Label>Descrição</Label>
<Textarea
value={newOrgDescription}
onChange={(e) => setNewOrgDescription(e.target.value)}
placeholder="Descrição da organização"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancelar</Button>
</DialogClose>
<Button onClick={handleCreateOrganization}>Criar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{organizations.map((org) => (
<div
key={org.id}
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
selectedOrg?.id === org.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
onClick={() => {
setSelectedOrg(org);
onOrganizationSelect?.(org);
}}
>
<h3 className="font-semibold">{org.name}</h3>
<p className="text-sm text-muted-foreground">{org.description}</p>
<p className="text-xs text-muted-foreground mt-2">
Slug: {org.slug}
</p>
</div>
))}
</div>
{selectedOrg && (
<div className="mt-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
Membros de {selectedOrg.name}
</h3>
<Dialog
open={showAddMemberDialog}
onOpenChange={setShowAddMemberDialog}
>
<DialogTrigger asChild>
<Button size="sm">
<UsersIcon className="w-4 h-4 mr-2" />
Adicionar Membro
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Adicionar Membro</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Email do Usuário</Label>
<Input
value={newMemberEmail}
onChange={(e) => setNewMemberEmail(e.target.value)}
placeholder="usuario@exemplo.com"
/>
</div>
<div>
<Label>Função</Label>
<Select
value={newMemberRole}
onValueChange={(value: 'member' | 'admin') =>
setNewMemberRole(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Membro</SelectItem>
<SelectItem value="admin">Administrador</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancelar</Button>
</DialogClose>
<Button onClick={handleAddMember}>Adicionar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
{members.map((member) => (
<div
key={member.id}
className="flex justify-between items-center p-3 border rounded-lg"
>
<div>
<p className="font-medium">{member.user?.name}</p>
<p className="text-sm text-muted-foreground">
{member.user?.email}
</p>
</div>
<span className="text-xs px-2 py-1 bg-secondary rounded-full">
{member.role}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,212 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { EditProjectDialog } from '@/app/(controle)/dashboard/components/EditProjectDialog';
import { ProjectMembersDialog } from '@/app/(controle)/dashboard/components/ProjectMembersDialog';
import { Project } from '@/types/Project';
import { Organization, User } from '@/types/User';
import {
CalendarIcon,
CheckCircle,
Circle,
Clock,
GitBranch,
User as UserIcon,
} from 'lucide-react';
import Link from 'next/link';
interface ProjectListProps {
projects: Project[] | null;
isLoading: boolean;
searchTerm: string;
statusFilter: string;
selectedOrganization: Organization | null;
uniqueUsers: User[];
onProjectUpdated: () => void;
}
export default function ProjectList({
projects,
isLoading,
searchTerm,
statusFilter,
selectedOrganization,
uniqueUsers,
onProjectUpdated,
}: ProjectListProps) {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, index) => (
<Card key={index} className="animate-pulse">
<CardHeader>
<div className="h-4 bg-muted rounded w-3/4"></div>
</CardHeader>
<CardContent>
<div className="h-3 bg-muted rounded w-full mb-2"></div>
<div className="h-3 bg-muted rounded w-2/3"></div>
</CardContent>
</Card>
))}
</div>
);
}
if (!projects || projects.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Nenhum projeto encontrado.</p>
</div>
);
}
// Filtrar projetos da organização selecionada
const organizationProjects = projects.filter(
(project) =>
!selectedOrganization ||
project.organizationId === selectedOrganization.id
);
if (organizationProjects.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
Nenhum projeto encontrado na organização selecionada.
</p>
</div>
);
}
// Filtrar projetos baseado no termo de busca e status (já filtrados por organização)
const filteredProjects = organizationProjects.filter((project) => {
const matchesSearch =
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus =
statusFilter === 'all' || project.status === statusFilter;
return matchesSearch && matchesStatus;
});
if (filteredProjects.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
Nenhum projeto encontrado com os filtros aplicados.
</p>
</div>
);
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'active':
return <Circle className="w-4 h-4 text-green-500" />;
case 'inactive':
return <CheckCircle className="w-4 h-4 text-gray-500" />;
default:
return <Circle className="w-4 h-4 text-gray-500" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return 'Ativo';
case 'inactive':
return 'Inativo';
default:
return status;
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredProjects.map((project) => (
<div key={project.id} className="relative group">
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
<Link href={`/project/${project.id}`} className="block">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-lg font-semibold overflow-hidden text-ellipsis whitespace-nowrap">
{project.name}
</CardTitle>
{getStatusIcon(project.status)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{project.description}
</p>
<div className="space-y-2">
{project.projectUrl && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<GitBranch className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{project.projectUrl.replace(
/https:\/\/github\.com\//g,
''
)}
</span>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<CalendarIcon className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{new Date(project.initialDate).toLocaleDateString()} -{' '}
{project.finalDate
? new Date(project.finalDate).toLocaleDateString()
: 'Não definido'}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<UserIcon className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{project.users?.[0]?.name || 'Nenhum usuário'}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{project.cards.length} cards {' '}
{
project.cards.filter(
(card) => card.status === 'Concluido'
).length
}{' '}
concluídos
</span>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<span className="text-xs px-2 py-1 rounded-full bg-muted">
{getStatusText(project.status)}
</span>
</div>
</CardContent>
</Link>
{/* Botões de ação */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<ProjectMembersDialog
project={project}
availableUsers={uniqueUsers}
onMembersChanged={onProjectUpdated}
/>
<EditProjectDialog
project={project}
selectedOrganization={selectedOrganization}
uniqueUsers={uniqueUsers}
onProjectUpdated={onProjectUpdated}
/>
</div>
</Card>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,37 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
interface SeletorProps {
value: string;
onValueChange: (value: string) => void;
options: { value: string; label: string }[];
placheHolder: string;
}
export default function Seletor({
value,
onValueChange,
options,
placheHolder,
}: SeletorProps) {
return (
<>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger>
<SelectValue placeholder={placheHolder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
);
}

View File

@ -0,0 +1,79 @@
'use client';
import { Button } from '@/components/ui/button';
import { useTimer } from '@/context/timerContext';
import { Clock, Pause, Play, Square } from 'lucide-react';
import { toast } from 'sonner';
export default function TimerDisplay() {
const {
activeCard,
elapsedTime,
isPaused,
pauseTimer,
resumeTimer,
stopTimer,
formatTime,
} = useTimer();
if (!activeCard) {
return null;
}
const handleStop = async () => {
const finalTime = elapsedTime;
await stopTimer();
toast.success(`Atividade finalizada: ${activeCard.name}`, {
description: `Tempo gasto: ${formatTime(finalTime)}`,
duration: 5000,
});
};
return (
<div className="flex items-center gap-3 bg-accent/20 px-4 py-2 rounded-lg border">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-muted-foreground">
{activeCard.name}
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-lg font-bold text-primary">
{formatTime(elapsedTime)}
</span>
<div className="flex items-center gap-1">
{isPaused ? (
<Button
variant="ghost"
size="sm"
onClick={resumeTimer}
className="h-8 w-8 p-0"
>
<Play className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={pauseTimer}
className="h-8 w-8 p-0"
>
<Pause className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleStop}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Square className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function ButtonToogleTheme() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="cursor-pointer">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setTheme('light')}
className="cursor-pointer"
>
Light
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme('dark')}
className="cursor-pointer"
>
Dark
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme('system')}
className="cursor-pointer"
>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

Some files were not shown because too many files have changed in this diff Show More