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:
100
package-lock.json
generated
100
package-lock.json
generated
@@ -47,87 +47,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@auth/prisma-adapter": {
|
||||||
"version": "2.11.1",
|
"version": "2.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz",
|
||||||
@@ -1427,14 +1346,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.27",
|
"version": "20.19.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||||
@@ -2278,17 +2189,6 @@
|
|||||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
ownedProjects Project[] @relation("OwnedProjects")
|
||||||
|
projectMembers ProjectMember[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ model VerificationToken {
|
|||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
projectId String? // Optional - cho backward compatibility
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
status String @default("TODO") // TODO, IN_PROGRESS, DONE
|
status String @default("TODO") // TODO, IN_PROGRESS, DONE
|
||||||
@@ -82,9 +85,61 @@ model Task {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([projectId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([deadline])
|
@@index([deadline])
|
||||||
@@map("tasks")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
173
src/app/api/invite/[token]/route.ts
Normal file
173
src/app/api/invite/[token]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/app/api/projects/[id]/invite/route.ts
Normal file
219
src/app/api/projects/[id]/invite/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/app/api/projects/[id]/members/route.ts
Normal file
267
src/app/api/projects/[id]/members/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
214
src/app/api/projects/[id]/route.ts
Normal file
214
src/app/api/projects/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/app/api/projects/route.ts
Normal file
149
src/app/api/projects/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/app/invite/[token]/page.tsx
Normal file
242
src/app/invite/[token]/page.tsx
Normal 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ử lý...
|
||||||
|
</>
|
||||||
|
) : 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 có hiệu lực đến {expiresDate.toLocaleDateString('vi-VN')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/app/projects/[id]/settings/page.tsx
Normal file
37
src/app/projects/[id]/settings/page.tsx
Normal 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
49
src/app/projects/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,13 +13,31 @@ export function Navbar() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center space-x-6">
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
<FiCheckSquare className="h-8 w-8 text-primary-600" />
|
<FiCheckSquare className="h-8 w-8 text-primary-600" />
|
||||||
<span className="text-xl font-bold text-gray-900">
|
<span className="text-xl font-bold text-gray-900">
|
||||||
TaskFlow
|
TaskFlow
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
|
|||||||
321
src/components/projects/ProjectSelector.tsx
Normal file
321
src/components/projects/ProjectSelector.tsx
Normal 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 và quản lý 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 có dự án nào</h3>
|
||||||
|
<p className="text-gray-500 mb-4">Tạo dự án đầu tiên để bắt đầu quản lý 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">
|
||||||
|
Mô 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
603
src/components/projects/ProjectSettings.tsx
Normal file
603
src/components/projects/ProjectSettings.tsx
Normal 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 có 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 có 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">Mô 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 và 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
src/components/projects/index.ts
Normal file
2
src/components/projects/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ProjectSelector } from './ProjectSelector'
|
||||||
|
export { ProjectSettings } from './ProjectSettings'
|
||||||
Reference in New Issue
Block a user