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