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:
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="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 */}
|
||||
|
||||
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