feat: Add Project Management feature

- Add Project, ProjectMember, ProjectInvite models to Prisma schema
- Add API routes for projects CRUD, members management, invite links
- Add ProjectSelector and ProjectSettings components
- Add /projects, /projects/[id]/settings, /invite/[token] pages
- Update Navbar with navigation links
This commit is contained in:
2026-01-05 12:25:04 +07:00
parent 81a38eef72
commit a0f1395cda
14 changed files with 2354 additions and 105 deletions

100
package-lock.json generated
View File

@@ -47,87 +47,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.34.3",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz",
"integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0",
"cookie": "0.6.0",
"jose": "^5.1.3",
"oauth4webapi": "^2.10.4",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^7"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@auth/core/node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@auth/core/node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@auth/core/node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@auth/core/node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/@auth/prisma-adapter": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz",
@@ -1427,14 +1346,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@types/node": {
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
@@ -2278,17 +2189,6 @@
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@@ -26,6 +26,8 @@ model User {
accounts Account[]
sessions Session[]
tasks Task[]
ownedProjects Project[] @relation("OwnedProjects")
projectMembers ProjectMember[]
@@map("users")
}
@@ -74,6 +76,7 @@ model VerificationToken {
model Task {
id String @id @default(cuid())
userId String
projectId String? // Optional - cho backward compatibility
title String
description String?
status String @default("TODO") // TODO, IN_PROGRESS, DONE
@@ -82,9 +85,61 @@ model Task {
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([projectId])
@@index([status])
@@index([deadline])
@@map("tasks")
}
// ==================== PROJECT MODELS ====================
model Project {
id String @id @default(cuid())
name String
description String?
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("OwnedProjects", fields: [ownerId], references: [id], onDelete: Cascade)
members ProjectMember[]
invites ProjectInvite[]
tasks Task[]
@@index([ownerId])
@@map("projects")
}
model ProjectMember {
id String @id @default(cuid())
projectId String
userId String
role String @default("MEMBER") // OWNER, ADMIN, MEMBER
joinedAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([projectId, userId])
@@index([userId])
@@map("project_members")
}
model ProjectInvite {
id String @id @default(cuid())
projectId String
token String @unique @default(cuid())
email String?
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([token])
@@index([projectId])
@@map("project_invites")
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ token: string }>
}
// GET /api/invite/[token] - Lấy thông tin invite
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { token } = await params
const invite = await prisma.projectInvite.findUnique({
where: { token },
include: {
project: {
select: {
id: true,
name: true,
description: true,
owner: {
select: {
name: true,
email: true,
image: true
}
},
_count: {
select: {
members: true
}
}
}
}
}
})
if (!invite) {
return NextResponse.json(
{ success: false, error: 'Link mời không tồn tại' },
{ status: 404 }
)
}
if (invite.used) {
return NextResponse.json(
{ success: false, error: 'Link mời đã được sử dụng' },
{ status: 400 }
)
}
if (new Date() > invite.expiresAt) {
return NextResponse.json(
{ success: false, error: 'Link mời đã hết hạn' },
{ status: 400 }
)
}
return NextResponse.json({
success: true,
data: {
project: invite.project,
expiresAt: invite.expiresAt
}
})
} catch (error) {
console.error('Lỗi lấy thông tin invite:', error)
return NextResponse.json(
{ success: false, error: 'Không thể lấy thông tin lời mời' },
{ status: 500 }
)
}
}
// POST /api/invite/[token] - Chấp nhận lời mời
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { token } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Vui lòng đăng nhập để tham gia dự án' },
{ status: 401 }
)
}
const invite = await prisma.projectInvite.findUnique({
where: { token },
include: {
project: true
}
})
if (!invite) {
return NextResponse.json(
{ success: false, error: 'Link mời không tồn tại' },
{ status: 404 }
)
}
if (invite.used) {
return NextResponse.json(
{ success: false, error: 'Link mời đã được sử dụng' },
{ status: 400 }
)
}
if (new Date() > invite.expiresAt) {
return NextResponse.json(
{ success: false, error: 'Link mời đã hết hạn' },
{ status: 400 }
)
}
// Nếu invite có email, kiểm tra email match
if (invite.email && invite.email !== session.user.email) {
return NextResponse.json(
{ success: false, error: 'Link mời này dành cho email khác' },
{ status: 403 }
)
}
// Kiểm tra đã là member chưa
const existingMember = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: invite.projectId,
userId: session.user.id
}
}
})
if (existingMember) {
return NextResponse.json(
{ success: false, error: 'Bạn đã là thành viên của dự án này' },
{ status: 400 }
)
}
// Thêm user vào project và đánh dấu invite đã dùng
await prisma.$transaction([
prisma.projectMember.create({
data: {
projectId: invite.projectId,
userId: session.user.id,
role: 'MEMBER'
}
}),
prisma.projectInvite.update({
where: { id: invite.id },
data: { used: true }
})
])
return NextResponse.json({
success: true,
data: {
projectId: invite.projectId,
projectName: invite.project.name
},
message: `Đã tham gia dự án "${invite.project.name}"!`
})
} catch (error) {
console.error('Lỗi accept invite:', error)
return NextResponse.json(
{ success: false, error: 'Không thể tham gia dự án' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,219 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/projects/[id]/invite - Lấy danh sách invite links
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Kiểm tra quyền (OWNER hoặc ADMIN)
const membership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền xem invite links' },
{ status: 403 }
)
}
const invites = await prisma.projectInvite.findMany({
where: {
projectId: id,
used: false,
expiresAt: {
gt: new Date()
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json({
success: true,
data: invites
})
} catch (error) {
console.error('Lỗi lấy danh sách invites:', error)
return NextResponse.json(
{ success: false, error: 'Không thể lấy danh sách lời mời' },
{ status: 500 }
)
}
}
// POST /api/projects/[id]/invite - Tạo invite link mới
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Kiểm tra quyền (OWNER hoặc ADMIN)
const membership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền tạo lời mời' },
{ status: 403 }
)
}
// Kiểm tra project tồn tại
const project = await prisma.project.findUnique({
where: { id }
})
if (!project) {
return NextResponse.json(
{ success: false, error: 'Không tìm thấy dự án' },
{ status: 404 }
)
}
const body = await request.json().catch(() => ({}))
const { email, expiresInDays = 7 } = body
// Tính ngày hết hạn (mặc định 7 ngày)
const days = Math.min(Math.max(expiresInDays, 1), 30) // Min 1, max 30 ngày
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + days)
// Tạo invite
const invite = await prisma.projectInvite.create({
data: {
projectId: id,
email: email?.trim() || null,
expiresAt
}
})
// Tạo invite link
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteLink = `${baseUrl}/invite/${invite.token}`
return NextResponse.json(
{
success: true,
data: {
...invite,
link: inviteLink
},
message: 'Tạo link mời thành công!'
},
{ status: 201 }
)
} catch (error) {
console.error('Lỗi tạo invite:', error)
return NextResponse.json(
{ success: false, error: 'Không thể tạo lời mời' },
{ status: 500 }
)
}
}
// DELETE /api/projects/[id]/invite - Xóa/hủy invite link
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const inviteId = searchParams.get('inviteId')
if (!inviteId) {
return NextResponse.json(
{ success: false, error: 'Thiếu thông tin invite' },
{ status: 400 }
)
}
// Kiểm tra quyền (OWNER hoặc ADMIN)
const membership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền hủy lời mời' },
{ status: 403 }
)
}
// Kiểm tra invite thuộc project này
const invite = await prisma.projectInvite.findFirst({
where: {
id: inviteId,
projectId: id
}
})
if (!invite) {
return NextResponse.json(
{ success: false, error: 'Không tìm thấy lời mời' },
{ status: 404 }
)
}
await prisma.projectInvite.delete({
where: { id: inviteId }
})
return NextResponse.json({
success: true,
message: 'Đã hủy lời mời!'
})
} catch (error) {
console.error('Lỗi xóa invite:', error)
return NextResponse.json(
{ success: false, error: 'Không thể hủy lời mời' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,267 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/projects/[id]/members - Lấy danh sách thành viên
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Kiểm tra user có trong project không
const membership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!membership) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền xem thành viên dự án này' },
{ status: 403 }
)
}
const members = await prisma.projectMember.findMany({
where: { projectId: id },
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
},
orderBy: [
{ role: 'asc' }, // OWNER trước
{ joinedAt: 'asc' }
]
})
return NextResponse.json({
success: true,
data: members
})
} catch (error) {
console.error('Lỗi lấy danh sách members:', error)
return NextResponse.json(
{ success: false, error: 'Không thể lấy danh sách thành viên' },
{ status: 500 }
)
}
}
// PATCH /api/projects/[id]/members - Cập nhật role của thành viên
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Kiểm tra quyền (chỉ OWNER mới được đổi role)
const myMembership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!myMembership || myMembership.role !== 'OWNER') {
return NextResponse.json(
{ success: false, error: 'Chỉ chủ dự án mới được thay đổi quyền thành viên' },
{ status: 403 }
)
}
const body = await request.json()
const { userId, role } = body
// Validate
if (!userId) {
return NextResponse.json(
{ success: false, error: 'Thiếu thông tin thành viên' },
{ status: 400 }
)
}
if (!['ADMIN', 'MEMBER'].includes(role)) {
return NextResponse.json(
{ success: false, error: 'Role không hợp lệ' },
{ status: 400 }
)
}
// Không thể đổi role của chính mình (OWNER)
if (userId === session.user.id) {
return NextResponse.json(
{ success: false, error: 'Không thể thay đổi quyền của chính mình' },
{ status: 400 }
)
}
const member = await prisma.projectMember.update({
where: {
projectId_userId: {
projectId: id,
userId
}
},
data: { role },
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
}
})
return NextResponse.json({
success: true,
data: member,
message: 'Cập nhật quyền thành công!'
})
} catch (error) {
console.error('Lỗi cập nhật member role:', error)
return NextResponse.json(
{ success: false, error: 'Không thể cập nhật quyền thành viên' },
{ status: 500 }
)
}
}
// DELETE /api/projects/[id]/members - Xóa thành viên khỏi project
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const userIdToRemove = searchParams.get('userId')
if (!userIdToRemove) {
return NextResponse.json(
{ success: false, error: 'Thiếu thông tin thành viên cần xóa' },
{ status: 400 }
)
}
// Kiểm tra quyền
const myMembership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
// User có thể tự rời khỏi project (trừ OWNER)
const isSelf = userIdToRemove === session.user.id
if (isSelf) {
if (myMembership?.role === 'OWNER') {
return NextResponse.json(
{ success: false, error: 'Chủ dự án không thể rời dự án. Hãy chuyển quyền hoặc xóa dự án.' },
{ status: 400 }
)
}
} else {
// Xóa người khác - cần quyền OWNER hoặc ADMIN
if (!myMembership || !['OWNER', 'ADMIN'].includes(myMembership.role)) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền xóa thành viên' },
{ status: 403 }
)
}
// Kiểm tra role của người bị xóa
const targetMembership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: userIdToRemove
}
}
})
if (!targetMembership) {
return NextResponse.json(
{ success: false, error: 'Thành viên không tồn tại' },
{ status: 404 }
)
}
// ADMIN không thể xóa OWNER hoặc ADMIN khác
if (myMembership.role === 'ADMIN' && ['OWNER', 'ADMIN'].includes(targetMembership.role)) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền xóa thành viên này' },
{ status: 403 }
)
}
// Không ai có thể xóa OWNER
if (targetMembership.role === 'OWNER') {
return NextResponse.json(
{ success: false, error: 'Không thể xóa chủ dự án' },
{ status: 400 }
)
}
}
await prisma.projectMember.delete({
where: {
projectId_userId: {
projectId: id,
userId: userIdToRemove
}
}
})
return NextResponse.json({
success: true,
message: isSelf ? 'Đã rời khỏi dự án!' : 'Đã xóa thành viên khỏi dự án!'
})
} catch (error) {
console.error('Lỗi xóa member:', error)
return NextResponse.json(
{ success: false, error: 'Không thể xóa thành viên' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,214 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/projects/[id] - Lấy chi tiết một project
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Kiểm tra user có quyền xem project này không
const membership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!membership) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền xem dự án này' },
{ status: 403 }
)
}
const project = await prisma.project.findUnique({
where: { id },
include: {
owner: {
select: {
id: true,
name: true,
email: true,
image: true
}
},
members: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
},
orderBy: {
joinedAt: 'asc'
}
},
_count: {
select: {
tasks: true,
invites: true
}
}
}
})
if (!project) {
return NextResponse.json(
{ success: false, error: 'Không tìm thấy dự án' },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
data: {
...project,
myRole: membership.role
}
})
} catch (error) {
console.error('Lỗi lấy chi tiết project:', error)
return NextResponse.json(
{ success: false, error: 'Không thể lấy thông tin dự án' },
{ status: 500 }
)
}
}
// PATCH /api/projects/[id] - Cập nhật project
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Kiểm tra quyền (OWNER hoặc ADMIN)
const membership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) {
return NextResponse.json(
{ success: false, error: 'Bạn không có quyền chỉnh sửa dự án này' },
{ status: 403 }
)
}
const body = await request.json()
const { name, description } = body
// Validate
if (name !== undefined) {
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ success: false, error: 'Tên dự án không được để trống' },
{ status: 400 }
)
}
if (name.length > 100) {
return NextResponse.json(
{ success: false, error: 'Tên dự án không quá 100 ký tự' },
{ status: 400 }
)
}
}
const project = await prisma.project.update({
where: { id },
data: {
...(name !== undefined && { name: name.trim() }),
...(description !== undefined && { description: description?.trim() || null })
}
})
return NextResponse.json({
success: true,
data: project,
message: 'Cập nhật dự án thành công!'
})
} catch (error) {
console.error('Lỗi cập nhật project:', error)
return NextResponse.json(
{ success: false, error: 'Không thể cập nhật dự án' },
{ status: 500 }
)
}
}
// DELETE /api/projects/[id] - Xóa project
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const session = await getServerSession(authOptions)
const { id } = await params
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Chỉ OWNER mới được xóa project
const membership = await prisma.projectMember.findUnique({
where: {
projectId_userId: {
projectId: id,
userId: session.user.id
}
}
})
if (!membership || membership.role !== 'OWNER') {
return NextResponse.json(
{ success: false, error: 'Chỉ chủ dự án mới được xóa dự án' },
{ status: 403 }
)
}
await prisma.project.delete({
where: { id }
})
return NextResponse.json({
success: true,
message: 'Xóa dự án thành công!'
})
} catch (error) {
console.error('Lỗi xóa project:', error)
return NextResponse.json(
{ success: false, error: 'Không thể xóa dự án' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// GET /api/projects - Lấy danh sách projects của user
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
// Lấy tất cả projects mà user là member
const projects = await prisma.project.findMany({
where: {
members: {
some: {
userId: session.user.id
}
}
},
include: {
owner: {
select: {
id: true,
name: true,
email: true,
image: true
}
},
members: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
}
},
_count: {
select: {
tasks: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
// Thêm thông tin role của current user vào mỗi project
const projectsWithRole = projects.map(project => {
const memberInfo = project.members.find(m => m.userId === session.user.id)
return {
...project,
myRole: memberInfo?.role || 'MEMBER'
}
})
return NextResponse.json({
success: true,
data: projectsWithRole
})
} catch (error) {
console.error('Lỗi lấy danh sách projects:', error)
return NextResponse.json(
{ success: false, error: 'Không thể lấy danh sách dự án' },
{ status: 500 }
)
}
}
// POST /api/projects - Tạo project mới
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Chưa đăng nhập' },
{ status: 401 }
)
}
const body = await request.json()
const { name, description } = body
// Validate
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ success: false, error: 'Tên dự án không được để trống' },
{ status: 400 }
)
}
if (name.length > 100) {
return NextResponse.json(
{ success: false, error: 'Tên dự án không quá 100 ký tự' },
{ status: 400 }
)
}
// Tạo project và thêm owner làm member trong một transaction
const project = await prisma.$transaction(async (tx) => {
// Tạo project
const newProject = await tx.project.create({
data: {
name: name.trim(),
description: description?.trim() || null,
ownerId: session.user.id
}
})
// Thêm owner làm member với role OWNER
await tx.projectMember.create({
data: {
projectId: newProject.id,
userId: session.user.id,
role: 'OWNER'
}
})
return newProject
})
return NextResponse.json(
{
success: true,
data: project,
message: 'Tạo dự án thành công!'
},
{ status: 201 }
)
} catch (error) {
console.error('Lỗi tạo project:', error)
return NextResponse.json(
{ success: false, error: 'Không thể tạo dự án' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,242 @@
'use client'
import { useState, useEffect, use } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { FiFolder, FiUsers, FiCheckCircle, FiAlertCircle, FiLogIn } from 'react-icons/fi'
import toast from 'react-hot-toast'
interface InviteInfo {
project: {
id: string
name: string
description: string | null
owner: {
name: string | null
email: string
image: string | null
}
_count: {
members: number
}
}
expiresAt: string
}
export default function InvitePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params)
const { data: session, status } = useSession()
const router = useRouter()
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null)
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchInvite = async () => {
try {
const response = await fetch(`/api/invite/${token}`)
const data = await response.json()
if (data.success) {
setInviteInfo(data.data)
} else {
setError(data.error || 'Link mời không hợp lệ')
}
} catch (err) {
setError('Không thể tải thông tin lời mời')
} finally {
setLoading(false)
}
}
fetchInvite()
}, [token])
const handleAccept = async () => {
if (status !== 'authenticated') {
// Lưu token vào localStorage để sau khi login redirect lại
localStorage.setItem('pendingInvite', token)
router.push('/login')
return
}
setAccepting(true)
try {
const response = await fetch(`/api/invite/${token}`, {
method: 'POST'
})
const data = await response.json()
if (data.success) {
toast.success(data.message)
router.push('/')
} else {
toast.error(data.error || 'Không thể tham gia dự án')
}
} catch (err) {
toast.error('Đã có lỗi xảy ra')
} finally {
setAccepting(false)
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary-600 border-t-transparent" />
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
<div className="h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FiAlertCircle className="h-8 w-8 text-red-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Lời mời không hợp lệ</h1>
<p className="text-gray-500 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Về trang chủ
</button>
</div>
</div>
)
}
if (!inviteInfo) return null
const expiresDate = new Date(inviteInfo.expiresAt)
const isExpired = expiresDate < new Date()
if (isExpired) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
<div className="h-16 w-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FiAlertCircle className="h-8 w-8 text-yellow-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Link đã hết hạn</h1>
<p className="text-gray-500 mb-6">
Link mời này đã hết hạn vào {expiresDate.toLocaleDateString('vi-VN')}.
Vui lòng liên hệ quản trị viên dự án đ nhận link mới.
</p>
<button
onClick={() => router.push('/')}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Về trang chủ
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-purple-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-primary-600 to-purple-600 p-6 text-center">
<div className="h-16 w-16 bg-white/20 backdrop-blur rounded-full flex items-center justify-center mx-auto mb-4">
<FiFolder className="h-8 w-8 text-white" />
</div>
<h1 className="text-xl font-bold text-white">Bạn đưc mời tham gia dự án</h1>
</div>
{/* Content */}
<div className="p-6">
{/* Project info */}
<div className="bg-gray-50 rounded-xl p-4 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-2">{inviteInfo.project.name}</h2>
{inviteInfo.project.description && (
<p className="text-gray-600 text-sm mb-3">{inviteInfo.project.description}</p>
)}
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center">
<FiUsers className="h-4 w-4 mr-1" />
{inviteInfo.project._count.members} thành viên
</div>
</div>
{/* Owner info */}
<div className="flex items-center mt-4 pt-4 border-t">
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-sm font-medium">
{inviteInfo.project.owner.image ? (
<img
src={inviteInfo.project.owner.image}
alt=""
className="h-full w-full rounded-full object-cover"
/>
) : (
(inviteInfo.project.owner.name || inviteInfo.project.owner.email).charAt(0).toUpperCase()
)}
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">
{inviteInfo.project.owner.name || 'Chưa đặt tên'}
</p>
<p className="text-xs text-gray-500">Chủ dự án</p>
</div>
</div>
</div>
{/* User status */}
{status === 'authenticated' && session?.user && (
<div className="flex items-center mb-4 p-3 bg-green-50 rounded-lg">
<FiCheckCircle className="h-5 w-5 text-green-500 mr-2" />
<div>
<p className="text-sm font-medium text-green-700">Đăng nhập với</p>
<p className="text-xs text-green-600">{session.user.email}</p>
</div>
</div>
)}
{/* Actions */}
<div className="space-y-3">
<button
onClick={handleAccept}
disabled={accepting}
className="w-full flex items-center justify-center px-4 py-3 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 transition-colors font-medium"
>
{accepting ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent mr-2" />
Đang xử ...
</>
) : status === 'authenticated' ? (
<>
<FiCheckCircle className="h-5 w-5 mr-2" />
Tham gia dự án
</>
) : (
<>
<FiLogIn className="h-5 w-5 mr-2" />
Đăng nhập đ tham gia
</>
)}
</button>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-3 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-xl transition-colors"
>
Đ sau
</button>
</div>
{/* Expiry notice */}
<p className="text-center text-xs text-gray-400 mt-4">
Link hiệu lực đến {expiresDate.toLocaleDateString('vi-VN')}
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import { use } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { ProjectSettings } from '@/components/projects'
export default function ProjectSettingsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const { data: session, status } = useSession()
const router = useRouter()
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
}
}, [status, router])
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 (!session?.user?.id) {
return null
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<ProjectSettings projectId={id} currentUserId={session.user.id} />
</div>
)
}

49
src/app/projects/page.tsx Normal file
View File

@@ -0,0 +1,49 @@
'use client'
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { ProjectSelector } from '@/components/projects'
export default function ProjectsPage() {
const { data: session, status } = useSession()
const router = useRouter()
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
}
}, [status, router])
const handleProjectChange = (projectId: string | null) => {
setSelectedProjectId(projectId)
if (projectId) {
// Lưu project đã chọn vào localStorage
localStorage.setItem('currentProject', projectId)
// Có thể redirect đến trang tasks của project
router.push(`/?project=${projectId}`)
}
}
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 (!session) {
return null
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<ProjectSelector
currentProjectId={selectedProjectId}
onProjectChange={handleProjectChange}
/>
</div>
)
}

View File

@@ -13,13 +13,31 @@ export function Navbar() {
<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">
<div className="flex items-center space-x-6">
<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>
{/* Navigation Links */}
{session?.user && (
<div className="hidden md:flex items-center space-x-4">
<Link
href="/"
className="text-sm font-medium text-gray-600 hover:text-primary-600 transition-colors"
>
Công việc
</Link>
<Link
href="/projects"
className="text-sm font-medium text-gray-600 hover:text-primary-600 transition-colors"
>
Dự án
</Link>
</div>
)}
</div>
{/* User Menu */}

View File

@@ -0,0 +1,321 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { FiPlus, FiFolder, FiUsers, FiCheckSquare, FiChevronRight, FiSettings } from 'react-icons/fi'
import toast from 'react-hot-toast'
interface Project {
id: string
name: string
description: string | null
ownerId: string
createdAt: string
myRole: string
owner: {
id: string
name: string | null
email: string
image: string | null
}
members: Array<{
id: string
role: string
user: {
id: string
name: string | null
email: string
image: string | null
}
}>
_count: {
tasks: number
}
}
interface ProjectSelectorProps {
currentProjectId?: string | null
onProjectChange: (projectId: string | null) => void
compact?: boolean
}
export function ProjectSelector({
currentProjectId,
onProjectChange,
compact = false
}: ProjectSelectorProps) {
const router = useRouter()
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const [newProjectDesc, setNewProjectDesc] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
fetchProjects()
}, [])
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects')
const data = await response.json()
if (data.success) {
setProjects(data.data)
// Tự động chọn project đầu tiên nếu chưa có project nào được chọn
if (!currentProjectId && data.data.length > 0) {
onProjectChange(data.data[0].id)
}
}
} catch (error) {
toast.error('Không thể tải danh sách dự án')
} finally {
setLoading(false)
}
}
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!newProjectName.trim()) {
toast.error('Vui lòng nhập tên dự án')
return
}
setCreating(true)
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newProjectName.trim(),
description: newProjectDesc.trim() || null
})
})
const data = await response.json()
if (data.success) {
toast.success('Tạo dự án thành công!')
setShowCreateModal(false)
setNewProjectName('')
setNewProjectDesc('')
await fetchProjects()
onProjectChange(data.data.id)
} else {
toast.error(data.error || 'Không thể tạo dự án')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
} finally {
setCreating(false)
}
}
const getRoleBadge = (role: string) => {
switch (role) {
case 'OWNER':
return <span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Chủ sở hữu</span>
case 'ADMIN':
return <span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">Quản trị</span>
default:
return <span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded-full">Thành viên</span>
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-primary-600 border-t-transparent" />
</div>
)
}
// Compact mode - dropdown style
if (compact) {
return (
<div className="relative">
<select
value={currentProjectId || ''}
onChange={(e) => onProjectChange(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Tất cả dự án</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
)
}
// Full view mode - Grid of projects
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Dự án của bạn</h2>
<p className="text-gray-500 mt-1">Chọn dự án đ xem quản công việc</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<FiPlus className="h-5 w-5 mr-2" />
Tạo dự án mới
</button>
</div>
{/* Project Grid */}
{projects.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl border-2 border-dashed">
<FiFolder className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Chưa dự án nào</h3>
<p className="text-gray-500 mb-4">Tạo dự án đu tiên đ bắt đu quản công việc</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
<FiPlus className="h-5 w-5 mr-2" />
Tạo dự án
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<div
key={project.id}
onClick={() => onProjectChange(project.id)}
className={`relative p-5 bg-white rounded-xl border-2 cursor-pointer transition-all hover:shadow-lg ${currentProjectId === project.id
? 'border-primary-500 ring-2 ring-primary-100'
: 'border-gray-200 hover:border-primary-300'
}`}
>
{/* Project Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 text-lg mb-1">{project.name}</h3>
{project.description && (
<p className="text-sm text-gray-500 line-clamp-2">{project.description}</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation()
router.push(`/projects/${project.id}/settings`)
}}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<FiSettings className="h-4 w-4" />
</button>
</div>
{/* Stats */}
<div className="flex items-center space-x-4 text-sm text-gray-500 mb-3">
<div className="flex items-center">
<FiCheckSquare className="h-4 w-4 mr-1" />
{project._count.tasks} công việc
</div>
<div className="flex items-center">
<FiUsers className="h-4 w-4 mr-1" />
{project.members.length} thành viên
</div>
</div>
{/* Role & Members */}
<div className="flex items-center justify-between">
{getRoleBadge(project.myRole)}
<div className="flex -space-x-2">
{project.members.slice(0, 4).map((member) => (
<div
key={member.id}
className="h-7 w-7 rounded-full bg-gray-200 border-2 border-white flex items-center justify-center text-xs font-medium text-gray-600"
title={member.user.name || member.user.email}
>
{member.user.image ? (
<img
src={member.user.image}
alt=""
className="h-full w-full rounded-full object-cover"
/>
) : (
(member.user.name || member.user.email).charAt(0).toUpperCase()
)}
</div>
))}
{project.members.length > 4 && (
<div className="h-7 w-7 rounded-full bg-gray-100 border-2 border-white flex items-center justify-center text-xs font-medium text-gray-500">
+{project.members.length - 4}
</div>
)}
</div>
</div>
{/* Selected indicator */}
{currentProjectId === project.id && (
<div className="absolute top-3 left-3 h-3 w-3 bg-primary-500 rounded-full" />
)}
</div>
))}
</div>
)}
{/* Create Project Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">Tạo dự án mới</h3>
<form onSubmit={handleCreateProject}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tên dự án <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="VD: Website công ty"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
maxLength={100}
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
tả
</label>
<textarea
value={newProjectDesc}
onChange={(e) => setNewProjectDesc(e.target.value)}
placeholder="Mô tả ngắn về dự án..."
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
disabled={creating}
>
Hủy
</button>
<button
type="submit"
disabled={creating || !newProjectName.trim()}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{creating ? 'Đang tạo...' : 'Tạo dự án'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,603 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { FiUsers, FiLink, FiCopy, FiTrash2, FiArrowLeft, FiEdit2, FiUserMinus, FiUserPlus, FiCheck, FiX } from 'react-icons/fi'
import toast from 'react-hot-toast'
interface Member {
id: string
role: string
joinedAt: string
user: {
id: string
name: string | null
email: string
image: string | null
}
}
interface Invite {
id: string
token: string
email: string | null
expiresAt: string
used: boolean
createdAt: string
}
interface Project {
id: string
name: string
description: string | null
ownerId: string
myRole: string
owner: {
id: string
name: string | null
email: string
image: string | null
}
}
interface ProjectSettingsProps {
projectId: string
currentUserId: string
}
export function ProjectSettings({ projectId, currentUserId }: ProjectSettingsProps) {
const router = useRouter()
const [project, setProject] = useState<Project | null>(null)
const [members, setMembers] = useState<Member[]>([])
const [invites, setInvites] = useState<Invite[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'members' | 'invites' | 'settings'>('members')
// Edit project state
const [editingName, setEditingName] = useState(false)
const [editingDesc, setEditingDesc] = useState(false)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
// Invite state
const [creatingInvite, setCreatingInvite] = useState(false)
const [inviteLink, setInviteLink] = useState<string | null>(null)
const fetchProject = useCallback(async () => {
try {
const response = await fetch(`/api/projects/${projectId}`)
const data = await response.json()
if (data.success) {
setProject(data.data)
setNewName(data.data.name)
setNewDesc(data.data.description || '')
} else {
toast.error(data.error || 'Không thể tải thông tin dự án')
router.push('/')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
}
}, [projectId, router])
const fetchMembers = useCallback(async () => {
try {
const response = await fetch(`/api/projects/${projectId}/members`)
const data = await response.json()
if (data.success) {
setMembers(data.data)
}
} catch (error) {
console.error('Error fetching members:', error)
}
}, [projectId])
const fetchInvites = useCallback(async () => {
try {
const response = await fetch(`/api/projects/${projectId}/invite`)
const data = await response.json()
if (data.success) {
setInvites(data.data)
}
} catch (error) {
console.error('Error fetching invites:', error)
}
}, [projectId])
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchProject(), fetchMembers(), fetchInvites()])
setLoading(false)
}
loadData()
}, [fetchProject, fetchMembers, fetchInvites])
const handleUpdateProject = async (field: 'name' | 'description') => {
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
[field]: field === 'name' ? newName : newDesc
})
})
const data = await response.json()
if (data.success) {
toast.success('Cập nhật thành công!')
fetchProject()
if (field === 'name') setEditingName(false)
else setEditingDesc(false)
} else {
toast.error(data.error || 'Không thể cập nhật')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
}
}
const handleCreateInvite = async () => {
setCreatingInvite(true)
try {
const response = await fetch(`/api/projects/${projectId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInDays: 7 })
})
const data = await response.json()
if (data.success) {
setInviteLink(data.data.link)
fetchInvites()
toast.success('Tạo link mời thành công!')
} else {
toast.error(data.error || 'Không thể tạo link mời')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
} finally {
setCreatingInvite(false)
}
}
const handleCopyLink = async (link: string) => {
try {
await navigator.clipboard.writeText(link)
toast.success('Đã copy link!')
} catch (error) {
toast.error('Không thể copy link')
}
}
const handleDeleteInvite = async (inviteId: string) => {
try {
const response = await fetch(`/api/projects/${projectId}/invite?inviteId=${inviteId}`, {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
toast.success('Đã hủy link mời!')
fetchInvites()
} else {
toast.error(data.error || 'Không thể hủy link mời')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
}
}
const handleRemoveMember = async (userId: string, isSelf: boolean) => {
const confirmed = confirm(
isSelf
? 'Bạn có chắc muốn rời khỏi dự án này?'
: 'Bạn có chắc muốn xóa thành viên này khỏi dự án?'
)
if (!confirmed) return
try {
const response = await fetch(`/api/projects/${projectId}/members?userId=${userId}`, {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
toast.success(data.message)
if (isSelf) {
router.push('/')
} else {
fetchMembers()
}
} else {
toast.error(data.error || 'Không thể xóa thành viên')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
}
}
const handleChangeRole = async (userId: string, newRole: string) => {
try {
const response = await fetch(`/api/projects/${projectId}/members`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, role: newRole })
})
const data = await response.json()
if (data.success) {
toast.success('Cập nhật quyền thành công!')
fetchMembers()
} else {
toast.error(data.error || 'Không thể cập nhật quyền')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
}
}
const handleDeleteProject = async () => {
const confirmed = confirm('Bạn có chắc muốn XÓA VĨNH VIỄN dự án này? Hành động này không thể hoàn tác!')
if (!confirmed) return
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
toast.success('Đã xóa dự án!')
router.push('/')
} else {
toast.error(data.error || 'Không thể xóa dự án')
}
} catch (error) {
toast.error('Đã có lỗi xảy ra')
}
}
const getRoleBadge = (role: string) => {
switch (role) {
case 'OWNER':
return <span className="px-2 py-1 text-xs bg-purple-100 text-purple-700 rounded-full font-medium">Chủ sở hữu</span>
case 'ADMIN':
return <span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">Quản trị</span>
default:
return <span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full font-medium">Thành viên</span>
}
}
const canManageMembers = project?.myRole === 'OWNER' || project?.myRole === 'ADMIN'
const isOwner = project?.myRole === 'OWNER'
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary-600 border-t-transparent" />
</div>
)
}
if (!project) {
return null
}
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center mb-6">
<button
onClick={() => router.back()}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg mr-3"
>
<FiArrowLeft className="h-5 w-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Cài đt dự án</h1>
<p className="text-gray-500">{project.name}</p>
</div>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg mb-6">
<button
onClick={() => setActiveTab('members')}
className={`flex-1 flex items-center justify-center px-4 py-2 rounded-md text-sm font-medium transition-colors ${activeTab === 'members' ? 'bg-white text-primary-600 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
<FiUsers className="h-4 w-4 mr-2" />
Thành viên ({members.length})
</button>
{canManageMembers && (
<button
onClick={() => setActiveTab('invites')}
className={`flex-1 flex items-center justify-center px-4 py-2 rounded-md text-sm font-medium transition-colors ${activeTab === 'invites' ? 'bg-white text-primary-600 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
<FiLink className="h-4 w-4 mr-2" />
Lời mời ({invites.length})
</button>
)}
{canManageMembers && (
<button
onClick={() => setActiveTab('settings')}
className={`flex-1 flex items-center justify-center px-4 py-2 rounded-md text-sm font-medium transition-colors ${activeTab === 'settings' ? 'bg-white text-primary-600 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
Thông tin
</button>
)}
</div>
{/* Members Tab */}
{activeTab === 'members' && (
<div className="bg-white rounded-xl border shadow-sm">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Danh sách thành viên</h2>
{canManageMembers && (
<button
onClick={() => setActiveTab('invites')}
className="flex items-center text-sm text-primary-600 hover:text-primary-700"
>
<FiUserPlus className="h-4 w-4 mr-1" />
Mời thêm
</button>
)}
</div>
<div className="divide-y">
{members.map((member) => (
<div key={member.id} className="p-4 flex items-center justify-between hover:bg-gray-50">
<div className="flex items-center space-x-3">
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-sm font-medium text-gray-600">
{member.user.image ? (
<img src={member.user.image} alt="" className="h-full w-full rounded-full object-cover" />
) : (
(member.user.name || member.user.email).charAt(0).toUpperCase()
)}
</div>
<div>
<p className="font-medium text-gray-900">{member.user.name || 'Chưa đặt tên'}</p>
<p className="text-sm text-gray-500">{member.user.email}</p>
</div>
</div>
<div className="flex items-center space-x-3">
{getRoleBadge(member.role)}
{/* Role change dropdown for OWNER */}
{isOwner && member.role !== 'OWNER' && (
<select
value={member.role}
onChange={(e) => handleChangeRole(member.user.id, e.target.value)}
className="text-sm border rounded-lg px-2 py-1 focus:ring-2 focus:ring-primary-500"
>
<option value="ADMIN">Quản trị</option>
<option value="MEMBER">Thành viên</option>
</select>
)}
{/* Remove button */}
{(member.user.id === currentUserId && member.role !== 'OWNER') && (
<button
onClick={() => handleRemoveMember(member.user.id, true)}
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg"
title="Rời dự án"
>
<FiUserMinus className="h-4 w-4" />
</button>
)}
{(canManageMembers && member.user.id !== currentUserId && member.role !== 'OWNER') && (
<button
onClick={() => handleRemoveMember(member.user.id, false)}
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg"
title="Xóa khỏi dự án"
>
<FiTrash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Invites Tab */}
{activeTab === 'invites' && canManageMembers && (
<div className="space-y-4">
{/* Create invite */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-gray-900 mb-4">Tạo link mời</h2>
{inviteLink ? (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<input
type="text"
value={inviteLink}
readOnly
className="flex-1 px-3 py-2 border rounded-lg bg-gray-50 text-sm"
/>
<button
onClick={() => handleCopyLink(inviteLink)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center"
>
<FiCopy className="h-4 w-4 mr-2" />
Copy
</button>
</div>
<p className="text-sm text-gray-500">Link này hiệu lực trong 7 ngày</p>
<button
onClick={() => setInviteLink(null)}
className="text-sm text-primary-600 hover:text-primary-700"
>
Tạo link khác
</button>
</div>
) : (
<button
onClick={handleCreateInvite}
disabled={creatingInvite}
className="flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
<FiLink className="h-4 w-4 mr-2" />
{creatingInvite ? 'Đang tạo...' : 'Tạo link mời mới'}
</button>
)}
</div>
{/* Active invites */}
<div className="bg-white rounded-xl border shadow-sm">
<div className="p-4 border-b">
<h2 className="font-semibold text-gray-900">Link mời đang hoạt đng</h2>
</div>
{invites.length === 0 ? (
<div className="p-8 text-center text-gray-500">
Chưa link mời nào
</div>
) : (
<div className="divide-y">
{invites.map((invite) => (
<div key={invite.id} className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-mono text-gray-600">
...{invite.token.slice(-12)}
</p>
<p className="text-xs text-gray-400">
Hết hạn: {new Date(invite.expiresAt).toLocaleDateString('vi-VN')}
</p>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleCopyLink(`${window.location.origin}/invite/${invite.token}`)}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg"
>
<FiCopy className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteInvite(invite.id)}
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg"
>
<FiTrash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && canManageMembers && (
<div className="space-y-4">
{/* Project info */}
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Thông tin dự án</h2>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tên dự án</label>
{editingName ? (
<div className="flex items-center space-x-2">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
maxLength={100}
/>
<button
onClick={() => handleUpdateProject('name')}
className="p-2 text-green-600 hover:bg-green-50 rounded-lg"
>
<FiCheck className="h-5 w-5" />
</button>
<button
onClick={() => {
setEditingName(false)
setNewName(project.name)
}}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg"
>
<FiX className="h-5 w-5" />
</button>
</div>
) : (
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-lg">
<span>{project.name}</span>
<button
onClick={() => setEditingName(true)}
className="p-1 text-gray-500 hover:text-gray-700"
>
<FiEdit2 className="h-4 w-4" />
</button>
</div>
)}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> tả</label>
{editingDesc ? (
<div className="space-y-2">
<textarea
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 resize-none"
rows={3}
/>
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setEditingDesc(false)
setNewDesc(project.description || '')
}}
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg text-sm"
>
Hủy
</button>
<button
onClick={() => handleUpdateProject('description')}
className="px-3 py-1 bg-primary-600 text-white hover:bg-primary-700 rounded-lg text-sm"
>
Lưu
</button>
</div>
</div>
) : (
<div className="flex items-start justify-between px-3 py-2 bg-gray-50 rounded-lg">
<span className="text-gray-600">{project.description || 'Chưa có mô tả'}</span>
<button
onClick={() => setEditingDesc(true)}
className="p-1 text-gray-500 hover:text-gray-700"
>
<FiEdit2 className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
{/* Danger zone */}
{isOwner && (
<div className="bg-red-50 rounded-xl border border-red-200 p-6">
<h2 className="font-semibold text-red-700 mb-2">Vùng nguy hiểm</h2>
<p className="text-sm text-red-600 mb-4">
Xóa dự án sẽ xóa tất cả công việc thành viên. Hành đng này không thể hoàn tác.
</p>
<button
onClick={handleDeleteProject}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Xóa dự án vĩnh viễn
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { ProjectSelector } from './ProjectSelector'
export { ProjectSettings } from './ProjectSettings'