Initial commit
|
|
@ -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
|
||||||
|
|
@ -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.**
|
||||||
|
|
@ -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()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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';
|
||||||
|
|
@ -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 já 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 já estão neste projeto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Fechar</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { DashboardClient } from './components/DashboardClient';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardClient />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { useOrganizations } from './useOrganizations';
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 já registrados não são afetados pela remoção</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||