Initial commit: Todo app with Jira-style board
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Database (Vercel Postgres / Neon / Supabase)
|
||||
DATABASE_URL="postgresql://user:password@host:5432/database?sslmode=require"
|
||||
DIRECT_URL="postgresql://user:password@host:5432/database?sslmode=require"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-super-secret-key-here-change-in-production"
|
||||
|
||||
# Google OAuth (get from Google Cloud Console)
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
|
||||
# Production
|
||||
dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
214
README.md
Normal file
214
README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# TaskFlow - Ứng dụng Quản lý Công việc Cá nhân
|
||||
|
||||
Ứng dụng To-do List hiện đại với hệ thống xác thực người dùng, cho phép mỗi user quản lý công việc của riêng mình.
|
||||
|
||||
## 🚀 Tính năng
|
||||
|
||||
### Xác thực & Phân quyền
|
||||
- ✅ Đăng ký / đăng nhập bằng Email & Password
|
||||
- ✅ Đăng nhập bằng Google OAuth
|
||||
- ✅ Mỗi user chỉ xem và thao tác trên công việc của chính mình
|
||||
- ✅ Đăng xuất
|
||||
|
||||
### Quản lý Công việc (CRUD)
|
||||
- ✅ Tạo công việc mới (tiêu đề, mô tả, trạng thái, deadline)
|
||||
- ✅ Sửa công việc
|
||||
- ✅ Xóa công việc
|
||||
- ✅ Xem danh sách công việc
|
||||
|
||||
### Tìm kiếm – Lọc – Sắp xếp
|
||||
- ✅ Tìm kiếm theo tiêu đề
|
||||
- ✅ Lọc theo trạng thái (Todo / In Progress / Done)
|
||||
- ✅ Lọc theo deadline (hôm nay, tuần này, quá hạn)
|
||||
- ✅ Sắp xếp theo deadline, trạng thái, thời gian tạo
|
||||
|
||||
### Giao diện
|
||||
- ✅ Responsive (desktop + mobile)
|
||||
- ✅ Màu sắc rõ ràng cho từng trạng thái
|
||||
- ✅ Hiển thị deadline sắp đến & quá hạn
|
||||
- ✅ Loading states, empty states, thông báo
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | Next.js 14 (App Router) + TypeScript |
|
||||
| Styling | Tailwind CSS |
|
||||
| Auth | NextAuth.js (Credentials + Google OAuth) |
|
||||
| Database | PostgreSQL + Prisma ORM |
|
||||
| Validation | Zod |
|
||||
|
||||
## 📁 Cấu trúc Project
|
||||
|
||||
```
|
||||
todo-app/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema
|
||||
│ └── seed.ts # Seed data
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── auth/ # NextAuth handlers
|
||||
│ │ │ └── tasks/ # Tasks CRUD API
|
||||
│ │ ├── login/ # Login page
|
||||
│ │ ├── register/ # Register page
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── page.tsx # Main dashboard
|
||||
│ │ └── globals.css
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # Reusable UI components
|
||||
│ │ ├── tasks/ # Task-related components
|
||||
│ │ ├── Navbar.tsx
|
||||
│ │ └── Providers.tsx
|
||||
│ ├── lib/
|
||||
│ │ ├── auth.ts # NextAuth config
|
||||
│ │ ├── prisma.ts # Prisma client
|
||||
│ │ ├── utils.ts # Utility functions
|
||||
│ │ └── validations.ts # Zod schemas
|
||||
│ ├── types/ # TypeScript types
|
||||
│ └── middleware.ts # Auth middleware
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🔧 Hướng dẫn cài đặt
|
||||
|
||||
### 1. Clone và cài đặt dependencies
|
||||
|
||||
```bash
|
||||
cd todo-app
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Cấu hình biến môi trường
|
||||
|
||||
Copy file `.env.example` thành `.env` và cập nhật các giá trị:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```env
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/todoapp?schema=public"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-super-secret-key-here"
|
||||
|
||||
# Google OAuth (lấy từ Google Cloud Console)
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
```
|
||||
|
||||
### 3. Thiết lập Google OAuth
|
||||
|
||||
1. Vào [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Tạo project mới hoặc chọn project có sẵn
|
||||
3. Vào **APIs & Services** > **Credentials**
|
||||
4. Tạo **OAuth 2.0 Client IDs**
|
||||
5. Thêm Authorized redirect URIs:
|
||||
- `http://localhost:3000/api/auth/callback/google`
|
||||
6. Copy Client ID và Client Secret vào file `.env`
|
||||
|
||||
### 4. Khởi tạo Database
|
||||
|
||||
```bash
|
||||
# Generate Prisma client
|
||||
npm run db:generate
|
||||
|
||||
# Push schema to database
|
||||
npm run db:push
|
||||
|
||||
# (Optional) Seed sample data
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### 5. Chạy ứng dụng
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Mở [http://localhost:3000](http://localhost:3000) để xem kết quả.
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Endpoint | Mô tả |
|
||||
|--------|----------|-------|
|
||||
| POST | `/api/auth/register` | Đăng ký tài khoản mới |
|
||||
| POST | `/api/auth/[...nextauth]` | NextAuth handlers |
|
||||
|
||||
### Tasks (Yêu cầu xác thực)
|
||||
|
||||
| Method | Endpoint | Mô tả |
|
||||
|--------|----------|-------|
|
||||
| GET | `/api/tasks` | Lấy danh sách tasks của user |
|
||||
| POST | `/api/tasks` | Tạo task mới |
|
||||
| GET | `/api/tasks/:id` | Lấy chi tiết task |
|
||||
| PUT | `/api/tasks/:id` | Cập nhật task |
|
||||
| DELETE | `/api/tasks/:id` | Xóa task |
|
||||
|
||||
**Query Parameters cho GET /api/tasks:**
|
||||
- `search` - Tìm kiếm theo tiêu đề
|
||||
- `status` - Lọc theo trạng thái (TODO, IN_PROGRESS, DONE)
|
||||
- `deadline` - Lọc theo deadline (today, this_week, overdue)
|
||||
- `sortBy` - Sắp xếp theo (deadline, status, createdAt)
|
||||
- `sortOrder` - Thứ tự (asc, desc)
|
||||
|
||||
## 🔐 Bảo mật
|
||||
|
||||
- **Middleware Protection**: API routes được bảo vệ bằng NextAuth middleware
|
||||
- **User Isolation**: Mỗi task được gắn với `userId`, API chỉ trả về tasks của user hiện tại
|
||||
- **Password Hashing**: Mật khẩu được hash bằng bcrypt
|
||||
- **Input Validation**: Tất cả input được validate bằng Zod
|
||||
|
||||
## 🎨 Trạng thái Task
|
||||
|
||||
| Status | Label | Màu |
|
||||
|--------|-------|-----|
|
||||
| TODO | Chưa làm | 🟡 Vàng |
|
||||
| IN_PROGRESS | Đang làm | 🔵 Xanh dương |
|
||||
| DONE | Hoàn thành | 🟢 Xanh lá |
|
||||
| Overdue | Quá hạn | 🔴 Đỏ |
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
### Dashboard
|
||||
- Thống kê tổng quan
|
||||
- Bộ lọc và tìm kiếm
|
||||
- Danh sách công việc
|
||||
|
||||
### Task Form
|
||||
- Tạo / sửa công việc
|
||||
- Chọn trạng thái
|
||||
- Đặt deadline
|
||||
|
||||
### Authentication
|
||||
- Đăng nhập / Đăng ký
|
||||
- Google OAuth
|
||||
|
||||
## 🚀 Deploy
|
||||
|
||||
### Vercel (Recommended)
|
||||
|
||||
1. Push code lên GitHub
|
||||
2. Import project vào [Vercel](https://vercel.com)
|
||||
3. Thêm biến môi trường
|
||||
4. Deploy!
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 📝 Demo Account
|
||||
|
||||
- **Email**: demo@example.com
|
||||
- **Password**: demo123456
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License
|
||||
17
next.config.js
Normal file
17
next.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'lh3.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
3147
package-lock.json
generated
Normal file
3147
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "todo-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.0.0",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "^16.0.10",
|
||||
"next-auth": "^4.24.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"prisma": "^5.7.0",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
90
prisma/schema.prisma
Normal file
90
prisma/schema.prisma
Normal file
@@ -0,0 +1,90 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directUrl = env("DIRECT_URL")
|
||||
}
|
||||
|
||||
// ==================== AUTH MODELS ====================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
password String? // null if using OAuth
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
tasks Task[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
// ==================== TASK MODEL ====================
|
||||
|
||||
model Task {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
description String?
|
||||
status String @default("TODO") // TODO, IN_PROGRESS, DONE
|
||||
deadline DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([deadline])
|
||||
@@map("tasks")
|
||||
}
|
||||
75
prisma/seed.ts
Normal file
75
prisma/seed.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// Create a demo user
|
||||
const hashedPassword = await bcrypt.hash('demo123456', 12)
|
||||
|
||||
const demoUser = await prisma.user.upsert({
|
||||
where: { email: 'demo@example.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'demo@example.com',
|
||||
name: 'Demo User',
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created demo user:', demoUser.email)
|
||||
|
||||
// Create sample tasks
|
||||
const tasks = [
|
||||
{
|
||||
title: 'Hoàn thành báo cáo dự án',
|
||||
description: 'Viết báo cáo tổng kết dự án Q4',
|
||||
status: 'IN_PROGRESS',
|
||||
deadline: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now
|
||||
},
|
||||
{
|
||||
title: 'Review code PR #123',
|
||||
description: 'Review pull request của team member',
|
||||
status: 'TODO',
|
||||
deadline: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000), // tomorrow
|
||||
},
|
||||
{
|
||||
title: 'Họp team weekly',
|
||||
description: 'Cuộc họp hàng tuần với team',
|
||||
status: 'DONE',
|
||||
deadline: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // yesterday
|
||||
},
|
||||
{
|
||||
title: 'Cập nhật documentation',
|
||||
description: 'Cập nhật tài liệu API cho version mới',
|
||||
status: 'TODO',
|
||||
deadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // next week
|
||||
},
|
||||
{
|
||||
title: 'Fix bug login page',
|
||||
description: null,
|
||||
status: 'TODO',
|
||||
deadline: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago (overdue)
|
||||
},
|
||||
]
|
||||
|
||||
for (const task of tasks) {
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
...task,
|
||||
userId: demoUser.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Created sample tasks')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
69
src/app/api/auth/register/route.ts
Normal file
69
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { registerSchema } from '@/lib/validations'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const validatedData = registerSchema.safeParse(body)
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: validatedData.error.errors[0].message
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name, email, password } = validatedData.data
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Email đã được sử dụng' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12)
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: user,
|
||||
message: 'Đăng ký thành công!'
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Register error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Đã có lỗi xảy ra. Vui lòng thử lại.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
168
src/app/api/tasks/[id]/route.ts
Normal file
168
src/app/api/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { updateTaskSchema } from '@/lib/validations'
|
||||
|
||||
// GET /api/tasks/[id] - Lấy chi tiết task
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId: session.user.id, // Chỉ lấy task của user hiện tại
|
||||
},
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Task not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: task,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get task error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch task' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/tasks/[id] - Cập nhật task
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if task belongs to user
|
||||
const existingTask = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingTask) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Task not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const validatedData = updateTaskSchema.safeParse(body)
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: validatedData.error.errors[0].message
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { title, description, status, deadline } = validatedData.data
|
||||
|
||||
// Update task
|
||||
const task = await prisma.task.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
...(status && { status }),
|
||||
...(deadline !== undefined && {
|
||||
deadline: deadline ? new Date(deadline) : null
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: task,
|
||||
message: 'Cập nhật thành công!',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update task error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update task' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/tasks/[id] - Xóa task
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if task belongs to user
|
||||
const existingTask = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingTask) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Task not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete task
|
||||
await prisma.task.delete({
|
||||
where: { id: params.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Xóa công việc thành công!',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Delete task error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete task' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
154
src/app/api/tasks/route.ts
Normal file
154
src/app/api/tasks/route.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createTaskSchema, taskFiltersSchema } from '@/lib/validations'
|
||||
import { isToday, isThisWeek, isOverdue } from '@/lib/utils'
|
||||
|
||||
// GET /api/tasks - Lấy danh sách tasks của user
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Parse filters
|
||||
const filters = {
|
||||
search: searchParams.get('search') || undefined,
|
||||
status: searchParams.get('status') || undefined,
|
||||
deadline: searchParams.get('deadline') || undefined,
|
||||
sortBy: searchParams.get('sortBy') || 'createdAt',
|
||||
sortOrder: searchParams.get('sortOrder') || 'desc',
|
||||
}
|
||||
|
||||
const validatedFilters = taskFiltersSchema.safeParse(filters)
|
||||
if (!validatedFilters.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid filters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: any = {
|
||||
userId: session.user.id,
|
||||
}
|
||||
|
||||
// Search by title
|
||||
if (filters.search) {
|
||||
where.title = {
|
||||
contains: filters.search,
|
||||
mode: 'insensitive',
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filters.status && filters.status !== 'ALL') {
|
||||
where.status = filters.status
|
||||
}
|
||||
|
||||
// Build orderBy
|
||||
const orderBy: any = {}
|
||||
if (filters.sortBy) {
|
||||
orderBy[filters.sortBy] = filters.sortOrder || 'desc'
|
||||
}
|
||||
|
||||
// Fetch tasks
|
||||
let tasks = await prisma.task.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
})
|
||||
|
||||
// Filter by deadline (client-side filtering for complex date logic)
|
||||
if (filters.deadline && filters.deadline !== 'all') {
|
||||
tasks = tasks.filter((task) => {
|
||||
if (!task.deadline) return false
|
||||
|
||||
switch (filters.deadline) {
|
||||
case 'today':
|
||||
return isToday(task.deadline)
|
||||
case 'this_week':
|
||||
return isThisWeek(task.deadline)
|
||||
case 'overdue':
|
||||
return isOverdue(task.deadline) && task.status !== 'DONE'
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tasks,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get tasks error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch tasks' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tasks - Tạo task mới
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const validatedData = createTaskSchema.safeParse(body)
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: validatedData.error.errors[0].message
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { title, description, status, deadline } = validatedData.data
|
||||
|
||||
// Create task with userId
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
deadline: deadline ? new Date(deadline) : null,
|
||||
userId: session.user.id, // Gắn task với user hiện tại
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: task,
|
||||
message: 'Tạo công việc thành công!'
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Create task error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create task' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
42
src/app/globals.css
Normal file
42
src/app/globals.css
Normal file
@@ -0,0 +1,42 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
29
src/app/layout.tsx
Normal file
29
src/app/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { Providers } from '@/components/Providers'
|
||||
import { Navbar } from '@/components/Navbar'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin', 'vietnamese'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'TaskFlow - Quản lý công việc cá nhân',
|
||||
description: 'Ứng dụng quản lý công việc cá nhân hiệu quả và dễ sử dụng',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="vi">
|
||||
<body className={`${inter.className} bg-gray-50 min-h-screen`}>
|
||||
<Providers>
|
||||
<Navbar />
|
||||
<main>{children}</main>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
184
src/app/login/page.tsx
Normal file
184
src/app/login/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { FcGoogle } from 'react-icons/fc'
|
||||
import { FiMail, FiLock } from 'react-icons/fi'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [googleLoading, setGoogleLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Vui lòng nhập email'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Email không hợp lệ'
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Vui lòng nhập mật khẩu'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
// Đăng nhập bằng Email/Password
|
||||
const handleCredentialsLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
toast.success('Đăng nhập thành công!')
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Đăng nhập bằng Google
|
||||
const handleGoogleLogin = async () => {
|
||||
setGoogleLoading(true)
|
||||
try {
|
||||
await signIn('google', { callbackUrl: '/' })
|
||||
} catch (error) {
|
||||
toast.error('Đăng nhập Google thất bại')
|
||||
setGoogleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Đăng nhập
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Chào mừng bạn quay trở lại!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Google Login Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full mb-6"
|
||||
onClick={handleGoogleLogin}
|
||||
loading={googleLoading}
|
||||
disabled={loading}
|
||||
>
|
||||
<FcGoogle className="h-5 w-5 mr-2" />
|
||||
Tiếp tục với Google
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-white text-gray-500">
|
||||
hoặc đăng nhập bằng email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleCredentialsLogin} className="space-y-4">
|
||||
<div className="relative">
|
||||
<FiMail className="absolute left-3 top-9 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
error={errors.email}
|
||||
disabled={loading || googleLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<FiLock className="absolute left-3 top-9 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
label="Mật khẩu"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
error={errors.password}
|
||||
disabled={loading || googleLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={googleLoading}
|
||||
>
|
||||
Đăng nhập
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<p className="text-center text-sm text-gray-600 mt-6">
|
||||
Chưa có tài khoản?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Đăng ký ngay
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Demo Account Info */}
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-600 font-medium mb-2">
|
||||
Tài khoản demo:
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Email: demo@example.com
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Mật khẩu: demo123456
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
375
src/app/page.tsx
Normal file
375
src/app/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Task } from '@prisma/client'
|
||||
import { FiPlus, FiCheckCircle, FiClock, FiAlertTriangle, FiList, FiCalendar, FiTrello } from 'react-icons/fi'
|
||||
import { Button, Modal } from '@/components/ui'
|
||||
import { TaskList, TaskForm, TaskFormData, TaskFiltersComponent, TaskCalendar, TaskBoard } from '@/components/tasks'
|
||||
import { TaskFilters } from '@/lib/validations'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function HomePage() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'list' | 'board' | 'calendar'>('board')
|
||||
const [defaultStatus, setDefaultStatus] = useState<string>('TODO')
|
||||
const [filters, setFilters] = useState<TaskFilters>({
|
||||
search: '',
|
||||
status: 'ALL',
|
||||
deadline: 'all',
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
})
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
// Fetch tasks
|
||||
const fetchTasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// Only apply filters in list view
|
||||
if (viewMode === 'list') {
|
||||
if (filters.search) params.set('search', filters.search)
|
||||
if (filters.status && filters.status !== 'ALL') params.set('status', filters.status)
|
||||
if (filters.deadline && filters.deadline !== 'all') params.set('deadline', filters.deadline)
|
||||
if (filters.sortBy) params.set('sortBy', filters.sortBy)
|
||||
if (filters.sortOrder) params.set('sortOrder', filters.sortOrder)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tasks?${params.toString()}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setTasks(data.data)
|
||||
} else {
|
||||
toast.error(data.error || 'Không thể tải danh sách công việc')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters, viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
fetchTasks()
|
||||
}
|
||||
}, [status, fetchTasks, viewMode])
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (status === 'authenticated') {
|
||||
fetchTasks()
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [filters.search, status, fetchTasks])
|
||||
|
||||
// Create task
|
||||
const handleCreateTask = async (data: TaskFormData) => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
deadline: data.deadline ? new Date(data.deadline).toISOString() : null,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Tạo công việc thành công!')
|
||||
setIsModalOpen(false)
|
||||
fetchTasks()
|
||||
} else {
|
||||
toast.error(result.error || 'Không thể tạo công việc')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
}
|
||||
}
|
||||
|
||||
// Update task
|
||||
const handleUpdateTask = async (data: TaskFormData) => {
|
||||
if (!editingTask) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${editingTask.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
deadline: data.deadline ? new Date(data.deadline).toISOString() : null,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Cập nhật thành công!')
|
||||
setIsModalOpen(false)
|
||||
setEditingTask(null)
|
||||
fetchTasks()
|
||||
} else {
|
||||
toast.error(result.error || 'Không thể cập nhật')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
}
|
||||
}
|
||||
|
||||
// Delete task
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Xóa công việc thành công!')
|
||||
fetchTasks()
|
||||
} else {
|
||||
toast.error(result.error || 'Không thể xóa')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
}
|
||||
}
|
||||
|
||||
// Quick status change
|
||||
const handleStatusChange = async (taskId: string, status: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Cập nhật trạng thái thành công!')
|
||||
fetchTasks()
|
||||
} else {
|
||||
toast.error(result.error || 'Không thể cập nhật')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
}
|
||||
}
|
||||
|
||||
// Edit task
|
||||
const handleEdit = (task: Task) => {
|
||||
setEditingTask(task)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
// Close modal
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingTask(null)
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
todo: tasks.filter((t) => t.status === 'TODO').length,
|
||||
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
|
||||
done: tasks.filter((t) => t.status === 'DONE').length,
|
||||
overdue: tasks.filter(
|
||||
(t) => t.deadline && new Date(t.deadline) < new Date() && t.status !== 'DONE'
|
||||
).length,
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Xin chào, {session?.user?.name || 'bạn'}! 👋
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Quản lý công việc của bạn một cách hiệu quả
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Tổng công việc</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<FiCheckCircle className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Đang làm</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.inProgress}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<FiClock className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Hoàn thành</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.done}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FiCheckCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Quá hạn</p>
|
||||
<p className="text-2xl font-bold text-red-600">{stats.overdue}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<FiAlertTriangle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters & Create Button */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-wrap justify-between items-center gap-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Công việc</h2>
|
||||
{/* View Toggle */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('board')}
|
||||
className={`flex items-center px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
viewMode === 'board'
|
||||
? 'bg-white text-primary-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<FiTrello className="h-4 w-4 mr-1" />
|
||||
Board
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-white text-primary-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<FiList className="h-4 w-4 mr-1" />
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('calendar')}
|
||||
className={`flex items-center px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
viewMode === 'calendar'
|
||||
? 'bg-white text-primary-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<FiCalendar className="h-4 w-4 mr-1" />
|
||||
Lịch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => {
|
||||
setDefaultStatus('TODO')
|
||||
setIsModalOpen(true)
|
||||
}}>
|
||||
<FiPlus className="h-4 w-4 mr-2" />
|
||||
Tạo công việc mới
|
||||
</Button>
|
||||
</div>
|
||||
{viewMode === 'list' && (
|
||||
<TaskFiltersComponent filters={filters} onChange={setFilters} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task View */}
|
||||
{viewMode === 'board' ? (
|
||||
<TaskBoard
|
||||
tasks={tasks}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDeleteTask}
|
||||
onStatusChange={handleStatusChange}
|
||||
onCreateTask={(status) => {
|
||||
setDefaultStatus(status)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
) : viewMode === 'list' ? (
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDeleteTask}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
) : (
|
||||
<TaskCalendar tasks={tasks} onTaskClick={handleEdit} />
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={editingTask ? 'Sửa công việc' : 'Tạo công việc mới'}
|
||||
>
|
||||
<TaskForm
|
||||
task={editingTask}
|
||||
defaultStatus={defaultStatus}
|
||||
onSubmit={editingTask ? handleUpdateTask : handleCreateTask}
|
||||
onCancel={handleCloseModal}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
src/app/register/page.tsx
Normal file
233
src/app/register/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { FcGoogle } from 'react-icons/fc'
|
||||
import { FiUser, FiMail, FiLock } from 'react-icons/fi'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [googleLoading, setGoogleLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Vui lòng nhập tên'
|
||||
} else if (formData.name.length < 2) {
|
||||
newErrors.name = 'Tên phải có ít nhất 2 ký tự'
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Vui lòng nhập email'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Email không hợp lệ'
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Vui lòng nhập mật khẩu'
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Mật khẩu phải có ít nhất 6 ký tự'
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Vui lòng xác nhận mật khẩu'
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Mật khẩu không khớp'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
// Đăng ký tài khoản mới
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Đăng ký thành công! Đang đăng nhập...')
|
||||
|
||||
// Auto login after registration
|
||||
const loginResult = await signIn('credentials', {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (loginResult?.ok) {
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || 'Đăng ký thất bại')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Đăng ký bằng Google
|
||||
const handleGoogleRegister = async () => {
|
||||
setGoogleLoading(true)
|
||||
try {
|
||||
await signIn('google', { callbackUrl: '/' })
|
||||
} catch (error) {
|
||||
toast.error('Đăng ký Google thất bại')
|
||||
setGoogleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Tạo tài khoản
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Đăng ký để quản lý công việc của bạn
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Google Register Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full mb-6"
|
||||
onClick={handleGoogleRegister}
|
||||
loading={googleLoading}
|
||||
disabled={loading}
|
||||
>
|
||||
<FcGoogle className="h-5 w-5 mr-2" />
|
||||
Tiếp tục với Google
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-white text-gray-500">
|
||||
hoặc đăng ký bằng email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div className="relative">
|
||||
<FiUser className="absolute left-3 top-9 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
label="Họ và tên"
|
||||
type="text"
|
||||
placeholder="Nguyễn Văn A"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
error={errors.name}
|
||||
disabled={loading || googleLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<FiMail className="absolute left-3 top-9 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
error={errors.email}
|
||||
disabled={loading || googleLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<FiLock className="absolute left-3 top-9 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
label="Mật khẩu"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
error={errors.password}
|
||||
disabled={loading || googleLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<FiLock className="absolute left-3 top-9 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
label="Xác nhận mật khẩu"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
error={errors.confirmPassword}
|
||||
disabled={loading || googleLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={googleLoading}
|
||||
>
|
||||
Đăng ký
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<p className="text-center text-sm text-gray-600 mt-6">
|
||||
Đã có tài khoản?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Đăng nhập
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/components/Navbar.tsx
Normal file
78
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { FiLogOut, FiUser, FiCheckSquare } from 'react-icons/fi'
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<FiCheckSquare className="h-8 w-8 text-primary-600" />
|
||||
<span className="text-xl font-bold text-gray-900">
|
||||
TaskFlow
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{status === 'loading' ? (
|
||||
<div className="animate-pulse h-8 w-8 bg-gray-200 rounded-full" />
|
||||
) : session?.user ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{session.user.image ? (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
alt={session.user.name || 'User'}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<FiUser className="h-4 w-4 text-primary-600" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-700 hidden sm:block">
|
||||
{session.user.name || session.user.email}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
className="flex items-center space-x-1 text-gray-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<FiLogOut className="h-5 w-5" />
|
||||
<span className="hidden sm:block text-sm">Đăng xuất</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-gray-700 hover:text-primary-600"
|
||||
>
|
||||
Đăng nhập
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Đăng ký
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
38
src/components/Providers.tsx
Normal file
38
src/components/Providers.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
{children}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#22c55e',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
320
src/components/tasks/TaskBoard.tsx
Normal file
320
src/components/tasks/TaskBoard.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Task } from '@prisma/client'
|
||||
import { FiEdit2, FiTrash2, FiClock, FiAlertCircle, FiPlus, FiMoreHorizontal } from 'react-icons/fi'
|
||||
import { formatDate, isOverdue } from '@/lib/utils'
|
||||
|
||||
interface TaskBoardProps {
|
||||
tasks: Task[]
|
||||
loading?: boolean
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onStatusChange: (taskId: string, status: string) => void
|
||||
onCreateTask: (status: string) => void
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
id: 'TODO',
|
||||
title: 'Chưa làm',
|
||||
color: 'bg-yellow-500',
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
},
|
||||
{
|
||||
id: 'IN_PROGRESS',
|
||||
title: 'Đang làm',
|
||||
color: 'bg-blue-500',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
},
|
||||
{
|
||||
id: 'DONE',
|
||||
title: 'Hoàn thành',
|
||||
color: 'bg-green-500',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
},
|
||||
]
|
||||
|
||||
export function TaskBoard({
|
||||
tasks,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
onCreateTask,
|
||||
}: TaskBoardProps) {
|
||||
const [draggedTask, setDraggedTask] = useState<Task | null>(null)
|
||||
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null)
|
||||
|
||||
const getTasksByStatus = (status: string) => {
|
||||
return tasks.filter((task) => task.status === status)
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, task: Task) => {
|
||||
setDraggedTask(task)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Add visual feedback
|
||||
if (e.currentTarget instanceof HTMLElement) {
|
||||
e.currentTarget.style.opacity = '0.5'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
setDraggedTask(null)
|
||||
setDragOverColumn(null)
|
||||
if (e.currentTarget instanceof HTMLElement) {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverColumn(columnId)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverColumn(null)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, columnId: string) => {
|
||||
e.preventDefault()
|
||||
if (draggedTask && draggedTask.status !== columnId) {
|
||||
onStatusChange(draggedTask.id, columnId)
|
||||
}
|
||||
setDraggedTask(null)
|
||||
setDragOverColumn(null)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column.id} className="bg-gray-100 rounded-xl p-4 min-h-[400px]">
|
||||
<div className="h-6 bg-gray-200 rounded w-24 mb-4 animate-pulse" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg p-4 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnTasks = getTasksByStatus(column.id)
|
||||
const isDropTarget = dragOverColumn === column.id && draggedTask?.status !== column.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
onDragOver={(e) => handleDragOver(e, column.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, column.id)}
|
||||
className={`
|
||||
rounded-xl p-4 min-h-[500px] transition-all
|
||||
${column.bgColor} ${column.borderColor} border-2
|
||||
${isDropTarget ? 'ring-2 ring-primary-500 ring-offset-2 scale-[1.02]' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-3 h-3 rounded-full ${column.color}`} />
|
||||
<h3 className="font-semibold text-gray-800">{column.title}</h3>
|
||||
<span className="bg-gray-200 text-gray-600 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
{columnTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onCreateTask(column.id)}
|
||||
className="p-1.5 text-gray-500 hover:text-primary-600 hover:bg-white rounded-lg transition-colors"
|
||||
title={`Thêm task vào ${column.title}`}
|
||||
>
|
||||
<FiPlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="space-y-3">
|
||||
{columnTasks.length === 0 ? (
|
||||
<div className={`
|
||||
border-2 border-dashed rounded-lg p-6 text-center
|
||||
${isDropTarget ? 'border-primary-400 bg-primary-50' : 'border-gray-300'}
|
||||
`}>
|
||||
<p className="text-sm text-gray-500">
|
||||
{isDropTarget ? 'Thả task vào đây' : 'Không có task nào'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone at bottom */}
|
||||
{columnTasks.length > 0 && isDropTarget && (
|
||||
<div className="mt-3 border-2 border-dashed border-primary-400 bg-primary-50 rounded-lg p-4 text-center">
|
||||
<p className="text-sm text-primary-600">Thả task vào đây</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Task Card Component
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onDragStart: (e: React.DragEvent, task: Task) => void
|
||||
onDragEnd: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
function TaskCard({ task, onEdit, onDelete, onDragStart, onDragEnd }: TaskCardProps) {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const taskIsOverdue = task.deadline && isOverdue(task.deadline) && task.status !== 'DONE'
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('Bạn có chắc muốn xóa công việc này?')) {
|
||||
onDelete(task.id)
|
||||
}
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const getPriorityColor = () => {
|
||||
if (taskIsOverdue) return 'border-l-red-500'
|
||||
if (task.deadline) {
|
||||
const daysUntil = Math.ceil(
|
||||
(new Date(task.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
if (daysUntil <= 1) return 'border-l-orange-500'
|
||||
if (daysUntil <= 3) return 'border-l-yellow-500'
|
||||
}
|
||||
return 'border-l-gray-300'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, task)}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
bg-white rounded-lg border-l-4 shadow-sm hover:shadow-md
|
||||
transition-all cursor-grab active:cursor-grabbing
|
||||
${getPriorityColor()}
|
||||
${taskIsOverdue ? 'bg-red-50' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="p-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4
|
||||
className={`font-medium text-sm flex-1 ${
|
||||
task.status === 'DONE' ? 'line-through text-gray-400' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</h4>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
>
|
||||
<FiMoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-6 z-20 bg-white rounded-lg shadow-lg border py-1 min-w-[120px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit(task)
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<FiEdit2 className="h-3.5 w-3.5 mr-2" />
|
||||
Sửa
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 text-red-600 flex items-center"
|
||||
>
|
||||
<FiTrash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100">
|
||||
{task.deadline ? (
|
||||
<div
|
||||
className={`flex items-center text-xs ${
|
||||
taskIsOverdue
|
||||
? 'text-red-600'
|
||||
: task.status === 'DONE'
|
||||
? 'text-green-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{taskIsOverdue ? (
|
||||
<FiAlertCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<FiClock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
<span>
|
||||
{new Date(task.deadline).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{/* Task ID badge */}
|
||||
<span className="text-[10px] text-gray-400 font-mono">
|
||||
#{task.id.slice(-4).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
300
src/components/tasks/TaskCalendar.tsx
Normal file
300
src/components/tasks/TaskCalendar.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Task } from '@prisma/client'
|
||||
import { FiChevronLeft, FiChevronRight, FiCircle } from 'react-icons/fi'
|
||||
import { getStatusLabel } from '@/lib/utils'
|
||||
|
||||
interface TaskCalendarProps {
|
||||
tasks: Task[]
|
||||
onTaskClick: (task: Task) => void
|
||||
}
|
||||
|
||||
const DAYS = ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7']
|
||||
const MONTHS = [
|
||||
'Tháng 1', 'Tháng 2', 'Tháng 3', 'Tháng 4', 'Tháng 5', 'Tháng 6',
|
||||
'Tháng 7', 'Tháng 8', 'Tháng 9', 'Tháng 10', 'Tháng 11', 'Tháng 12'
|
||||
]
|
||||
|
||||
export function TaskCalendar({ tasks, onTaskClick }: TaskCalendarProps) {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
|
||||
// Get days in month
|
||||
const daysInMonth = useMemo(() => {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const days: (Date | null)[] = []
|
||||
|
||||
// Add empty days for padding
|
||||
for (let i = 0; i < firstDay.getDay(); i++) {
|
||||
days.push(null)
|
||||
}
|
||||
|
||||
// Add days of month
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
days.push(new Date(year, month, i))
|
||||
}
|
||||
|
||||
return days
|
||||
}, [year, month])
|
||||
|
||||
// Group tasks by date
|
||||
const tasksByDate = useMemo(() => {
|
||||
const map = new Map<string, Task[]>()
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (task.deadline) {
|
||||
const dateKey = new Date(task.deadline).toDateString()
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, [])
|
||||
}
|
||||
map.get(dateKey)!.push(task)
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
}, [tasks])
|
||||
|
||||
// Get tasks for selected date
|
||||
const selectedDateTasks = useMemo(() => {
|
||||
if (!selectedDate) return []
|
||||
return tasksByDate.get(selectedDate.toDateString()) || []
|
||||
}, [selectedDate, tasksByDate])
|
||||
|
||||
const goToPrevMonth = () => {
|
||||
setCurrentDate(new Date(year, month - 1, 1))
|
||||
}
|
||||
|
||||
const goToNextMonth = () => {
|
||||
setCurrentDate(new Date(year, month + 1, 1))
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date())
|
||||
setSelectedDate(new Date())
|
||||
}
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date()
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
)
|
||||
}
|
||||
|
||||
const isSelected = (date: Date) => {
|
||||
if (!selectedDate) return false
|
||||
return date.toDateString() === selectedDate.toDateString()
|
||||
}
|
||||
|
||||
const getTaskCountForDate = (date: Date) => {
|
||||
return tasksByDate.get(date.toDateString())?.length || 0
|
||||
}
|
||||
|
||||
const getStatusDot = (status: string) => {
|
||||
switch (status) {
|
||||
case 'TODO':
|
||||
return 'bg-yellow-400'
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-blue-500'
|
||||
case 'DONE':
|
||||
return 'bg-green-500'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const hasOverdueTask = (date: Date) => {
|
||||
const tasksForDate = tasksByDate.get(date.toDateString()) || []
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return tasksForDate.some(
|
||||
(task) => task.status !== 'DONE' && new Date(task.deadline!) < today
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
📅 Lịch công việc
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-3 py-1 text-sm bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 transition-colors"
|
||||
>
|
||||
Hôm nay
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
{/* Calendar */}
|
||||
<div className="flex-1 p-4">
|
||||
{/* Month Navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={goToPrevMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<FiChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{MONTHS[month]} {year}
|
||||
</h3>
|
||||
<button
|
||||
onClick={goToNextMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<FiChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Days Header */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{DAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-sm font-medium text-gray-500 py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{daysInMonth.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={`empty-${index}`} className="h-12" />
|
||||
}
|
||||
|
||||
const taskCount = getTaskCountForDate(date)
|
||||
const isOverdue = hasOverdueTask(date)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={date.toISOString()}
|
||||
onClick={() => setSelectedDate(date)}
|
||||
className={`
|
||||
h-12 rounded-lg flex flex-col items-center justify-center relative
|
||||
transition-all hover:bg-gray-100
|
||||
${isToday(date) ? 'bg-primary-100 text-primary-700 font-bold' : ''}
|
||||
${isSelected(date) ? 'ring-2 ring-primary-500 bg-primary-50' : ''}
|
||||
${isOverdue ? 'bg-red-50' : ''}
|
||||
`}
|
||||
>
|
||||
<span className="text-sm">{date.getDate()}</span>
|
||||
{taskCount > 0 && (
|
||||
<div className="flex space-x-0.5 mt-0.5">
|
||||
{taskCount <= 3 ? (
|
||||
Array.from({ length: taskCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isOverdue ? 'bg-red-500' : 'bg-primary-500'
|
||||
}`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-primary-600 font-medium">
|
||||
{taskCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center space-x-4 mt-4 pt-4 border-t text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<span className="text-gray-600">Chưa làm</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-gray-600">Đang làm</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<span className="text-gray-600">Hoàn thành</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Date Tasks */}
|
||||
<div className="lg:w-80 border-t lg:border-t-0 lg:border-l bg-gray-50">
|
||||
<div className="p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">
|
||||
{selectedDate
|
||||
? selectedDate.toLocaleDateString('vi-VN', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: 'Chọn một ngày để xem task'}
|
||||
</h4>
|
||||
|
||||
{selectedDate && (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{selectedDateTasks.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
Không có công việc nào
|
||||
</p>
|
||||
) : (
|
||||
selectedDateTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
className="w-full text-left p-3 bg-white rounded-lg border hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mt-1.5 ${getStatusDot(task.status)}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`font-medium text-sm truncate ${
|
||||
task.status === 'DONE'
|
||||
? 'line-through text-gray-400'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{getStatusLabel(task.status)}
|
||||
{task.deadline && (
|
||||
<span className="ml-2">
|
||||
{new Date(task.deadline).toLocaleTimeString('vi-VN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/components/tasks/TaskCard.tsx
Normal file
107
src/components/tasks/TaskCard.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Task } from '@prisma/client'
|
||||
import { FiEdit2, FiTrash2, FiClock, FiAlertCircle } from 'react-icons/fi'
|
||||
import { formatDate, getStatusColor, getStatusLabel, getDeadlineColor, isOverdue } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onStatusChange: (taskId: string, status: string) => void
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onEdit, onDelete, onStatusChange }: TaskCardProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Bạn có chắc muốn xóa công việc này?')) return
|
||||
|
||||
setIsDeleting(true)
|
||||
await onDelete(task.id)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
const taskIsOverdue = task.deadline && isOverdue(task.deadline) && task.status !== 'DONE'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white rounded-lg border-l-4 shadow-sm hover:shadow-md transition-shadow p-4
|
||||
${task.status === 'TODO' ? 'border-l-yellow-400' : ''}
|
||||
${task.status === 'IN_PROGRESS' ? 'border-l-blue-400' : ''}
|
||||
${task.status === 'DONE' ? 'border-l-green-400' : ''}
|
||||
${taskIsOverdue ? 'border-l-red-400 bg-red-50' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-medium text-gray-900 ${task.status === 'DONE' ? 'line-through text-gray-500' : ''}`}>
|
||||
{task.title}
|
||||
</h3>
|
||||
{task.description && (
|
||||
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => onEdit(task)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
||||
title="Sửa"
|
||||
>
|
||||
<FiEdit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<FiTrash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={task.status}
|
||||
onChange={(e) => onStatusChange(task.id, e.target.value)}
|
||||
className={`
|
||||
text-xs font-medium px-2 py-1 rounded-full border cursor-pointer
|
||||
${getStatusColor(task.status)}
|
||||
`}
|
||||
>
|
||||
<option value="TODO">Chưa làm</option>
|
||||
<option value="IN_PROGRESS">Đang làm</option>
|
||||
<option value="DONE">Hoàn thành</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deadline */}
|
||||
{task.deadline && (
|
||||
<div className={`flex items-center text-xs ${getDeadlineColor(task.deadline, task.status)}`}>
|
||||
{taskIsOverdue ? (
|
||||
<FiAlertCircle className="h-3.5 w-3.5 mr-1" />
|
||||
) : (
|
||||
<FiClock className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
<span>
|
||||
{taskIsOverdue ? 'Quá hạn: ' : ''}
|
||||
{formatDate(task.deadline)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/components/tasks/TaskFilters.tsx
Normal file
78
src/components/tasks/TaskFilters.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { Input, Select } from '@/components/ui'
|
||||
import { FiSearch } from 'react-icons/fi'
|
||||
import { TaskFilters } from '@/lib/validations'
|
||||
|
||||
interface TaskFiltersProps {
|
||||
filters: TaskFilters
|
||||
onChange: (filters: TaskFilters) => void
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ALL', label: 'Tất cả trạng thái' },
|
||||
{ value: 'TODO', label: 'Chưa làm' },
|
||||
{ value: 'IN_PROGRESS', label: 'Đang làm' },
|
||||
{ value: 'DONE', label: 'Hoàn thành' },
|
||||
]
|
||||
|
||||
const deadlineOptions = [
|
||||
{ value: 'all', label: 'Tất cả deadline' },
|
||||
{ value: 'today', label: 'Hôm nay' },
|
||||
{ value: 'this_week', label: 'Tuần này' },
|
||||
{ value: 'overdue', label: 'Quá hạn' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'createdAt-desc', label: 'Mới nhất' },
|
||||
{ value: 'createdAt-asc', label: 'Cũ nhất' },
|
||||
{ value: 'deadline-asc', label: 'Deadline gần nhất' },
|
||||
{ value: 'deadline-desc', label: 'Deadline xa nhất' },
|
||||
{ value: 'status-asc', label: 'Theo trạng thái' },
|
||||
]
|
||||
|
||||
export function TaskFiltersComponent({ filters, onChange }: TaskFiltersProps) {
|
||||
const handleSortChange = (value: string) => {
|
||||
const [sortBy, sortOrder] = value.split('-') as [string, 'asc' | 'desc']
|
||||
onChange({ ...filters, sortBy: sortBy as any, sortOrder })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm theo tiêu đề..."
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<Select
|
||||
options={statusOptions}
|
||||
value={filters.status || 'ALL'}
|
||||
onChange={(e) => onChange({ ...filters, status: e.target.value as any })}
|
||||
/>
|
||||
|
||||
{/* Deadline Filter */}
|
||||
<Select
|
||||
options={deadlineOptions}
|
||||
value={filters.deadline || 'all'}
|
||||
onChange={(e) => onChange({ ...filters, deadline: e.target.value as any })}
|
||||
/>
|
||||
|
||||
{/* Sort */}
|
||||
<Select
|
||||
options={sortOptions}
|
||||
value={`${filters.sortBy || 'createdAt'}-${filters.sortOrder || 'desc'}`}
|
||||
onChange={(e) => handleSortChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
src/components/tasks/TaskForm.tsx
Normal file
135
src/components/tasks/TaskForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Task } from '@prisma/client'
|
||||
import { Button, Input, TextArea, Select } from '@/components/ui'
|
||||
import { formatDateInput } from '@/lib/utils'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
interface TaskFormProps {
|
||||
task?: Task | null
|
||||
defaultStatus?: string
|
||||
onSubmit: (data: TaskFormData) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export interface TaskFormData {
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
deadline: string
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'TODO', label: 'Chưa làm' },
|
||||
{ value: 'IN_PROGRESS', label: 'Đang làm' },
|
||||
{ value: 'DONE', label: 'Hoàn thành' },
|
||||
]
|
||||
|
||||
export function TaskForm({ task, defaultStatus = 'TODO', onSubmit, onCancel }: TaskFormProps) {
|
||||
const [formData, setFormData] = useState<TaskFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
status: defaultStatus,
|
||||
deadline: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setFormData({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status,
|
||||
deadline: formatDateInput(task.deadline),
|
||||
})
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, status: defaultStatus }))
|
||||
}
|
||||
}, [task, defaultStatus])
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Tiêu đề không được để trống'
|
||||
} else if (formData.title.length > 200) {
|
||||
newErrors.title = 'Tiêu đề tối đa 200 ký tự'
|
||||
}
|
||||
|
||||
if (formData.description.length > 1000) {
|
||||
newErrors.description = 'Mô tả tối đa 1000 ký tự'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
} catch (error) {
|
||||
toast.error('Đã có lỗi xảy ra')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Tiêu đề *"
|
||||
placeholder="Nhập tiêu đề công việc"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Mô tả"
|
||||
placeholder="Nhập mô tả công việc (không bắt buộc)"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
error={errors.description}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Trạng thái"
|
||||
options={statusOptions}
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Deadline"
|
||||
type="datetime-local"
|
||||
value={formData.deadline}
|
||||
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{task ? 'Cập nhật' : 'Tạo mới'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
69
src/components/tasks/TaskList.tsx
Normal file
69
src/components/tasks/TaskList.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { Task } from '@prisma/client'
|
||||
import { TaskCard } from './TaskCard'
|
||||
import { FiInbox } from 'react-icons/fi'
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[]
|
||||
loading?: boolean
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onStatusChange: (taskId: string, status: string) => void
|
||||
}
|
||||
|
||||
export function TaskList({
|
||||
tasks,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
}: TaskListProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-lg border p-4 animate-pulse"
|
||||
>
|
||||
<div className="h-5 bg-gray-200 rounded w-3/4 mb-3" />
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4" />
|
||||
<div className="flex justify-between">
|
||||
<div className="h-6 bg-gray-200 rounded w-20" />
|
||||
<div className="h-4 bg-gray-200 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<FiInbox className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">
|
||||
Không có công việc nào
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Bấm "Tạo công việc mới" để thêm công việc đầu tiên
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
src/components/tasks/index.ts
Normal file
6
src/components/tasks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { TaskCard } from './TaskCard'
|
||||
export { TaskForm, type TaskFormData } from './TaskForm'
|
||||
export { TaskFiltersComponent } from './TaskFilters'
|
||||
export { TaskList } from './TaskList'
|
||||
export { TaskCalendar } from './TaskCalendar'
|
||||
export { TaskBoard } from './TaskBoard'
|
||||
46
src/components/ui/Button.tsx
Normal file
46
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { FiLoader } from 'react-icons/fi'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && <FiLoader className="animate-spin mr-2 h-4 w-4" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
42
src/components/ui/Input.tsx
Normal file
42
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-4 py-2 border rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
||||
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||
${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
58
src/components/ui/Modal.tsx
Normal file
58
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FiX } from 'react-icons/fi'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
onClick={(e) => {
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<FiX className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/ui/Select.tsx
Normal file
45
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
options: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, error, options, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-4 py-2 border rounded-lg bg-white
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
||||
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||
${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Select.displayName = 'Select'
|
||||
43
src/components/ui/TextArea.tsx
Normal file
43
src/components/ui/TextArea.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
({ label, error, helperText, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-4 py-2 border rounded-lg resize-none
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
||||
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||
${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${className}
|
||||
`}
|
||||
rows={3}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TextArea.displayName = 'TextArea'
|
||||
5
src/components/ui/index.ts
Normal file
5
src/components/ui/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Button } from './Button'
|
||||
export { Input } from './Input'
|
||||
export { TextArea } from './TextArea'
|
||||
export { Select } from './Select'
|
||||
export { Modal } from './Modal'
|
||||
77
src/lib/auth.ts
Normal file
77
src/lib/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextAuthOptions } from 'next-auth'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import GoogleProvider from 'next-auth/providers/google'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma) as any,
|
||||
providers: [
|
||||
// Google OAuth Provider
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
|
||||
// Email/Password Provider
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error('Vui lòng nhập email và mật khẩu')
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
throw new Error('Email hoặc mật khẩu không đúng')
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password,
|
||||
user.password
|
||||
)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error('Email hoặc mật khẩu không đúng')
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
error: '/login',
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string
|
||||
}
|
||||
return session
|
||||
},
|
||||
},
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
}
|
||||
13
src/lib/prisma.ts
Normal file
13
src/lib/prisma.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
86
src/lib/utils.ts
Normal file
86
src/lib/utils.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string | null): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
return d.toLocaleDateString('vi-VN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDateInput(date: Date | string | null): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export function isOverdue(deadline: Date | string | null): boolean {
|
||||
if (!deadline) return false
|
||||
return new Date(deadline) < new Date()
|
||||
}
|
||||
|
||||
export function isToday(date: Date | string): boolean {
|
||||
const d = new Date(date)
|
||||
const today = new Date()
|
||||
return (
|
||||
d.getDate() === today.getDate() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getFullYear() === today.getFullYear()
|
||||
)
|
||||
}
|
||||
|
||||
export function isThisWeek(date: Date | string): boolean {
|
||||
const d = new Date(date)
|
||||
const today = new Date()
|
||||
const weekStart = new Date(today)
|
||||
weekStart.setDate(today.getDate() - today.getDay())
|
||||
weekStart.setHours(0, 0, 0, 0)
|
||||
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekStart.getDate() + 7)
|
||||
|
||||
return d >= weekStart && d < weekEnd
|
||||
}
|
||||
|
||||
export function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'TODO':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-300'
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-300'
|
||||
case 'DONE':
|
||||
return 'bg-green-100 text-green-800 border-green-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'TODO':
|
||||
return 'Chưa làm'
|
||||
case 'IN_PROGRESS':
|
||||
return 'Đang làm'
|
||||
case 'DONE':
|
||||
return 'Hoàn thành'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function getDeadlineColor(deadline: Date | string | null, status: string): string {
|
||||
if (!deadline) return ''
|
||||
if (status === 'DONE') return 'text-green-600'
|
||||
if (isOverdue(deadline)) return 'text-red-600 font-semibold'
|
||||
if (isToday(deadline)) return 'text-orange-600 font-semibold'
|
||||
return 'text-gray-600'
|
||||
}
|
||||
71
src/lib/validations.ts
Normal file
71
src/lib/validations.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const TaskStatus = {
|
||||
TODO: 'TODO',
|
||||
IN_PROGRESS: 'IN_PROGRESS',
|
||||
DONE: 'DONE',
|
||||
} as const
|
||||
|
||||
export type TaskStatusType = keyof typeof TaskStatus
|
||||
|
||||
export const createTaskSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, 'Tiêu đề không được để trống')
|
||||
.max(200, 'Tiêu đề tối đa 200 ký tự'),
|
||||
description: z
|
||||
.string()
|
||||
.max(1000, 'Mô tả tối đa 1000 ký tự')
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).default('TODO'),
|
||||
deadline: z.string().datetime().optional().nullable(),
|
||||
})
|
||||
|
||||
export const updateTaskSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, 'Tiêu đề không được để trống')
|
||||
.max(200, 'Tiêu đề tối đa 200 ký tự')
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.max(1000, 'Mô tả tối đa 1000 ký tự')
|
||||
.optional()
|
||||
.nullable(),
|
||||
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
|
||||
deadline: z.string().datetime().optional().nullable(),
|
||||
})
|
||||
|
||||
export const registerSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'Tên phải có ít nhất 2 ký tự')
|
||||
.max(50, 'Tên tối đa 50 ký tự'),
|
||||
email: z.string().email('Email không hợp lệ'),
|
||||
password: z
|
||||
.string()
|
||||
.min(6, 'Mật khẩu phải có ít nhất 6 ký tự')
|
||||
.max(100, 'Mật khẩu tối đa 100 ký tự'),
|
||||
})
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Email không hợp lệ'),
|
||||
password: z.string().min(1, 'Vui lòng nhập mật khẩu'),
|
||||
})
|
||||
|
||||
export type CreateTaskInput = z.infer<typeof createTaskSchema>
|
||||
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>
|
||||
export type RegisterInput = z.infer<typeof registerSchema>
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
|
||||
// Task filters
|
||||
export const taskFiltersSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'ALL']).optional(),
|
||||
deadline: z.enum(['today', 'this_week', 'overdue', 'all']).optional(),
|
||||
sortBy: z.enum(['deadline', 'status', 'createdAt']).optional(),
|
||||
sortOrder: z.enum(['asc', 'desc']).optional(),
|
||||
})
|
||||
|
||||
export type TaskFilters = z.infer<typeof taskFiltersSchema>
|
||||
31
src/middleware.ts
Normal file
31
src/middleware.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { withAuth } from 'next-auth/middleware'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export default withAuth(
|
||||
function middleware(req) {
|
||||
return NextResponse.next()
|
||||
},
|
||||
{
|
||||
callbacks: {
|
||||
authorized: ({ token, req }) => {
|
||||
// Các route cần bảo vệ
|
||||
const protectedPaths = ['/api/tasks']
|
||||
const isProtectedApiRoute = protectedPaths.some((path) =>
|
||||
req.nextUrl.pathname.startsWith(path)
|
||||
)
|
||||
|
||||
if (isProtectedApiRoute) {
|
||||
return !!token
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/api/tasks/:path*',
|
||||
],
|
||||
}
|
||||
24
src/types/index.ts
Normal file
24
src/types/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Task } from '@prisma/client'
|
||||
|
||||
export type TaskWithUser = Task & {
|
||||
user?: {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
19
src/types/next-auth.d.ts
vendored
Normal file
19
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import { DefaultSession } from 'next-auth'
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string
|
||||
} & DefaultSession['user']
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
35
tailwind.config.ts
Normal file
35
tailwind.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
status: {
|
||||
todo: '#fbbf24',
|
||||
inprogress: '#3b82f6',
|
||||
done: '#22c55e',
|
||||
overdue: '#ef4444',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
4
vercel.json
Normal file
4
vercel.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"buildCommand": "prisma generate && next build",
|
||||
"framework": "nextjs"
|
||||
}
|
||||
Reference in New Issue
Block a user