Initial commit: Todo app with Jira-style board

This commit is contained in:
PhongMacbook
2025-12-16 23:13:06 +07:00
commit fecae546e9
44 changed files with 6671 additions and 0 deletions

11
.env.example Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
prisma/dev.db Normal file

Binary file not shown.

90
prisma/schema.prisma Normal file
View 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
View 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()
})

View 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 }

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

View 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
View 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
View 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
View 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
View 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 tài khoản?{' '}
<Link
href="/register"
className="text-primary-600 hover:text-primary-700 font-medium"
>
Đăng 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
View 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 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
View 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 đ quản 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 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
</Button>
</form>
{/* Login Link */}
<p className="text-center text-sm text-gray-600 mt-6">
Đã 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
View 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
</Link>
</div>
)}
</div>
</div>
</div>
</nav>
)
}

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

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

View 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ô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>
)
}

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

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

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

View 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ông việc nào
</h3>
<p className="text-gray-500">
Bấm &quot;Tạo công việc mới&quot; đ 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>
)
}

View 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'

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

View 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'

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

View 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'

View 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'

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"buildCommand": "prisma generate && next build",
"framework": "nextjs"
}