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

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