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>
|
||||
);
|
||||
}
|
||||