feat: 实现用户登录认证与权限控制系统
- 新增用户登录页面与API端点(/api/auth/login、/api/auth/logout、/api/auth/me) - 引入基于密码哈希的认证机制,支持默认管理员账号登录 - 创建访问控制模块(lib/access-control.ts),实现基于角色的权限验证 - 在现有API端点中集成权限检查,限制非管理员用户只能访问其所属项目数据 - 更新应用布局,要求用户登录后才能访问主界面 - 扩展用户表结构,增加用户名和密码哈希字段 - 调整项目、KR、目标、会议记录等资源的查询逻辑,根据用户权限过滤数据 - 改进管理页面(指标、模板)的删除操作,使用确认对话框替代直接确认 - 优化“我的KR”页面,为管理员提供全员KR概览视图 - 修复.gitignore文件中的拼写错误(.trae → .travis) - 添加数据库迁移脚本,支持任务直接关联项目、禁用RLS、添加求助字段等 - 配置Next.js以支持独立输出和子路径部署
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,3 +34,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.trae
|
||||
|
||||
@@ -18,6 +18,16 @@ import type { Metric } from '@/types/database';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Pencil, Trash2, Gauge, Loader2, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const metricSchema = z.object({
|
||||
name: z.string().min(1, '名称不能为空'),
|
||||
@@ -35,6 +45,9 @@ export default function AdminMetricsPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingMetric, setEditingMetric] = useState<Metric | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deleteMetricId, setDeleteMetricId] = useState<string | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const form = useForm<MetricFormValues>({
|
||||
resolver: zodResolver(metricSchema),
|
||||
@@ -121,17 +134,26 @@ export default function AdminMetricsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (metricId: string) => {
|
||||
if (!confirm('确定要删除此指标吗?')) return;
|
||||
const requestDelete = (metricId: string) => {
|
||||
setDeleteMetricId(metricId);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteMetricId) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/metrics/${metricId}`, { method: 'DELETE' });
|
||||
const res = await fetch(`/api/metrics/${deleteMetricId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('删除失败');
|
||||
|
||||
toast.success('指标已删除');
|
||||
fetchMetrics();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteMetricId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,7 +331,7 @@ export default function AdminMetricsPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(metric)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(metric.id)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => requestDelete(metric.id)}>
|
||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -327,6 +349,27 @@ export default function AdminMetricsPage() {
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除指标?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后无法恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ import type { Template } from '@/types/database';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Pencil, Trash2, Files, Loader2, X } from 'lucide-react';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const templateItemSchema = z.object({
|
||||
title: z.string().min(1, '标题不能为空'),
|
||||
@@ -44,6 +54,9 @@ export default function AdminTemplatesPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deleteTemplateId, setDeleteTemplateId] = useState<string | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const form = useForm<TemplateFormValues>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
@@ -135,17 +148,26 @@ export default function AdminTemplatesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (templateId: string) => {
|
||||
if (!confirm('确定要删除此模板吗?')) return;
|
||||
const requestDelete = (templateId: string) => {
|
||||
setDeleteTemplateId(templateId);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTemplateId) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/templates/${templateId}`, { method: 'DELETE' });
|
||||
const res = await fetch(`/api/templates/${deleteTemplateId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('删除失败');
|
||||
|
||||
toast.success('模板已删除');
|
||||
fetchTemplates();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTemplateId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -395,7 +417,7 @@ export default function AdminTemplatesPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(template)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(template.id)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => requestDelete(template.id)}>
|
||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -431,6 +453,27 @@ export default function AdminTemplatesPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除模板?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后无法恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,22 @@ import type { User } from '@/types/database';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
import { Plus, Pencil, Trash2, Users, Loader2, Crown, Briefcase, User as UserIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const userSchema = z.object({
|
||||
name: z.string().min(1, '名称不能为空'),
|
||||
role: z.enum(['owner', 'pm', 'member']),
|
||||
username: z.string().min(1, '账号不能为空'),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
type UserFormValues = z.infer<typeof userSchema>;
|
||||
@@ -38,25 +50,41 @@ export default function AdminUsersPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
role: 'member',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: UserFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (!editingUser && !values.password) {
|
||||
throw new Error('请设置初始密码');
|
||||
}
|
||||
|
||||
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users';
|
||||
const method = editingUser ? 'PATCH' : 'POST';
|
||||
|
||||
const payload = {
|
||||
name: values.name,
|
||||
role: values.role,
|
||||
username: values.username,
|
||||
password: values.password ? values.password : undefined,
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -81,21 +109,33 @@ export default function AdminUsersPage() {
|
||||
form.reset({
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
username: user.username || '',
|
||||
password: '',
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm('确定要删除此用户吗?')) return;
|
||||
const requestDelete = (userId: string) => {
|
||||
setDeleteUserId(userId);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteUserId) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/users/${userId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('删除失败');
|
||||
const res = await fetch(`/api/users/${deleteUserId}`, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || '删除失败');
|
||||
|
||||
toast.success('用户已删除');
|
||||
refreshUsers();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,6 +191,34 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>账号 *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="输入登录账号" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{editingUser ? '重置密码' : '密码 *'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={editingUser ? '留空则不修改' : '输入初始密码'} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
@@ -197,6 +265,7 @@ export default function AdminUsersPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>账号</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
@@ -217,6 +286,9 @@ export default function AdminUsersPage() {
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.username || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleIcon className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -233,8 +305,8 @@ export default function AdminUsersPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={user.id === currentUser?.id}
|
||||
onClick={() => requestDelete(user.id)}
|
||||
disabled={user.role === 'owner'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||
</Button>
|
||||
@@ -246,6 +318,27 @@ export default function AdminUsersPage() {
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后无法恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
117
app/api/auth/login/route.ts
Normal file
117
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { hashPassword, verifyPassword } from '@/lib/password';
|
||||
|
||||
const DEFAULT_OWNER_USERNAME = 'baicaimiao';
|
||||
const DEFAULT_OWNER_PASSWORD = 'baicaimiao123';
|
||||
const DEFAULT_OWNER_NAME = '兜爸';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
||||
const password = typeof body.password === 'string' ? body.password : '';
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: '请输入账号和密码' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: userByUsername, error: usernameError } = await supabase
|
||||
.from('users')
|
||||
.select('id,name,role,avatar_url,username,password_hash,created_at,updated_at')
|
||||
.eq('username', username)
|
||||
.maybeSingle();
|
||||
|
||||
if (usernameError) {
|
||||
return NextResponse.json({ error: usernameError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (userByUsername?.password_hash) {
|
||||
const ok = verifyPassword(password, userByUsername.password_hash);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: '账号或密码错误' }, { status: 401 });
|
||||
}
|
||||
const { password_hash, ...safeUser } = userByUsername as any;
|
||||
const res = NextResponse.json({ user: safeUser });
|
||||
res.cookies.set('okr_user_id', safeUser.id, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
if (username === DEFAULT_OWNER_USERNAME && password === DEFAULT_OWNER_PASSWORD) {
|
||||
const { data: ownerUser, error: ownerError } = await supabase
|
||||
.from('users')
|
||||
.select('id,name,role,avatar_url,username,password_hash,created_at,updated_at')
|
||||
.eq('role', 'owner')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (ownerError) {
|
||||
return NextResponse.json({ error: ownerError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(DEFAULT_OWNER_PASSWORD);
|
||||
|
||||
if (ownerUser) {
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
name: DEFAULT_OWNER_NAME,
|
||||
username: DEFAULT_OWNER_USERNAME,
|
||||
password_hash: passwordHash,
|
||||
role: 'owner',
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', ownerUser.id)
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ user: updated });
|
||||
res.cookies.set('okr_user_id', updated.id, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
const { data: created, error: createError } = await supabase
|
||||
.from('users')
|
||||
.insert({
|
||||
name: DEFAULT_OWNER_NAME,
|
||||
role: 'owner',
|
||||
avatar_url: null,
|
||||
username: DEFAULT_OWNER_USERNAME,
|
||||
password_hash: passwordHash,
|
||||
})
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
return NextResponse.json({ error: createError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ user: created });
|
||||
res.cookies.set('okr_user_id', created.id, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '账号或密码错误' }, { status: 401 });
|
||||
}
|
||||
15
app/api/auth/logout/route.ts
Normal file
15
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ success: true });
|
||||
res.cookies.set('okr_user_id', '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
});
|
||||
return res;
|
||||
}
|
||||
27
app/api/auth/me/route.ts
Normal file
27
app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = request.cookies.get('okr_user_id')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ user: null }, { status: 200 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ user: null }, { status: 200 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ user: data }, { status: 200 });
|
||||
}
|
||||
@@ -4,8 +4,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { determineKRStatus, determineProjectStatus } from '@/lib/kr-utils';
|
||||
import type { KR, Checkin } from '@/types/database';
|
||||
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const krId = searchParams.get('kr_id');
|
||||
const projectId = searchParams.get('project_id');
|
||||
@@ -21,6 +28,34 @@ export async function GET(request: NextRequest) {
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (userId && userId !== ctx!.user.id) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (projectId && !hasProjectAccess(ctx!, projectId)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (krId) {
|
||||
const { data: kr, error: krErr } = await supabase
|
||||
.from('krs')
|
||||
.select('id,project_id')
|
||||
.eq('id', krId)
|
||||
.maybeSingle();
|
||||
if (krErr) return NextResponse.json({ error: krErr.message }, { status: 500 });
|
||||
if (!kr) return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
|
||||
if (!hasProjectAccess(ctx!, kr.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectId && !krId) {
|
||||
if (ctx!.accessibleProjectIds.length === 0) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
query = query.in('project_id', ctx!.accessibleProjectIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (krId) query = query.eq('kr_id', krId);
|
||||
if (projectId) query = query.eq('project_id', projectId);
|
||||
if (userId) query = query.eq('user_id', userId);
|
||||
@@ -36,6 +71,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { kr_id, project_id, user_id, date, current_value, confidence, reason_code, note } = body;
|
||||
|
||||
@@ -58,6 +99,19 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const kr = krData as KR;
|
||||
|
||||
if (kr.project_id !== project_id) {
|
||||
return NextResponse.json({ error: '项目与 KR 不匹配' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (!hasProjectAccess(ctx!, project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (user_id && user_id !== ctx!.user.id) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const { data: recentCheckins } = await supabase
|
||||
.from('checkins')
|
||||
.select('*')
|
||||
@@ -91,7 +145,7 @@ export async function POST(request: NextRequest) {
|
||||
.insert({
|
||||
kr_id,
|
||||
project_id,
|
||||
user_id,
|
||||
user_id: ctx!.isAdmin ? user_id : ctx!.user.id,
|
||||
date: checkinDate,
|
||||
current_value,
|
||||
delta_value,
|
||||
|
||||
@@ -2,11 +2,18 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('krs')
|
||||
.select(`
|
||||
@@ -26,6 +33,10 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
@@ -33,6 +44,12 @@ export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
@@ -96,6 +113,12 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('krs')
|
||||
.delete()
|
||||
|
||||
@@ -2,8 +2,15 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('project_id');
|
||||
const objectiveId = searchParams.get('objective_id');
|
||||
@@ -19,6 +26,19 @@ export async function GET(request: NextRequest) {
|
||||
`)
|
||||
.order('period_start', { ascending: false });
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (projectId) {
|
||||
if (!hasProjectAccess(ctx!, projectId)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
if (ctx!.accessibleProjectIds.length === 0) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
query = query.in('project_id', ctx!.accessibleProjectIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) query = query.eq('project_id', projectId);
|
||||
if (objectiveId) query = query.eq('objective_id', objectiveId);
|
||||
if (ownerUserId) query = query.eq('owner_user_id', ownerUserId);
|
||||
@@ -34,6 +54,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
project_id,
|
||||
@@ -59,6 +85,10 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (target_value <= 0) {
|
||||
return NextResponse.json({ error: '目标值必须大于0' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('meetings')
|
||||
.select(`
|
||||
@@ -23,6 +30,10 @@ export async function GET(
|
||||
return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
@@ -30,6 +41,25 @@ export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
const { data: meeting, error: mErr } = await supabase
|
||||
.from('meetings')
|
||||
.select('id,project_id')
|
||||
.eq('id', params.id)
|
||||
.maybeSingle();
|
||||
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
|
||||
if (!meeting) return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
|
||||
if (!hasProjectAccess(ctx!, meeting.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { summary, decisions, risks, next_period_commitments } = body;
|
||||
|
||||
@@ -57,6 +87,25 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
const { data: meeting, error: mErr } = await supabase
|
||||
.from('meetings')
|
||||
.select('id,project_id')
|
||||
.eq('id', params.id)
|
||||
.maybeSingle();
|
||||
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
|
||||
if (!meeting) return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
|
||||
if (!hasProjectAccess(ctx!, meeting.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('meetings')
|
||||
.delete()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('project_id');
|
||||
|
||||
@@ -14,6 +21,19 @@ export async function GET(request: NextRequest) {
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (projectId) {
|
||||
if (!hasProjectAccess(ctx!, projectId)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
if (ctx!.accessibleProjectIds.length === 0) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
query = query.in('project_id', ctx!.accessibleProjectIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) query = query.eq('project_id', projectId);
|
||||
|
||||
const { data, error } = await query;
|
||||
@@ -26,6 +46,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { project_id, period_start, period_end, summary, decisions, risks, next_period_commitments, created_by } = body;
|
||||
|
||||
@@ -36,6 +62,10 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('meetings')
|
||||
.insert({
|
||||
@@ -46,7 +76,7 @@ export async function POST(request: NextRequest) {
|
||||
decisions,
|
||||
risks,
|
||||
next_period_commitments,
|
||||
created_by,
|
||||
created_by: ctx!.isAdmin ? created_by : ctx!.user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('metrics')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.maybeSingle();
|
||||
.eq('id', params.id);
|
||||
|
||||
if (!ctx!.isAdmin) query = query.eq('is_active', true);
|
||||
|
||||
const { data, error } = await query.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
@@ -26,6 +36,12 @@ export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, unit, direction, category, is_active } = body;
|
||||
|
||||
@@ -54,6 +70,12 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('metrics')
|
||||
.delete()
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const activeOnly = searchParams.get('active_only') === 'true';
|
||||
const category = searchParams.get('category');
|
||||
|
||||
let query = supabase.from('metrics').select('*').order('name', { ascending: true });
|
||||
|
||||
if (activeOnly) query = query.eq('is_active', true);
|
||||
if (!ctx!.isAdmin || activeOnly) query = query.eq('is_active', true);
|
||||
if (category) query = query.eq('category', category);
|
||||
|
||||
const { data, error } = await query;
|
||||
@@ -21,6 +28,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, unit, direction, category } = body;
|
||||
|
||||
|
||||
@@ -2,11 +2,18 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('objectives')
|
||||
.select('*')
|
||||
@@ -21,6 +28,10 @@ export async function GET(
|
||||
return NextResponse.json({ error: '目标不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
@@ -28,6 +39,12 @@ export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, period_start, period_end } = body;
|
||||
|
||||
@@ -55,6 +72,12 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('objectives')
|
||||
.delete()
|
||||
|
||||
@@ -2,16 +2,34 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('project_id');
|
||||
|
||||
let query = supabase.from('objectives').select('*').order('period_start', { ascending: false });
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (projectId) {
|
||||
query = query.eq('project_id', projectId);
|
||||
if (!hasProjectAccess(ctx!, projectId)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
if (ctx!.accessibleProjectIds.length === 0) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
query = query.in('project_id', ctx!.accessibleProjectIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) query = query.eq('project_id', projectId);
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
@@ -23,6 +41,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { project_id, title, description, period_start, period_end, created_by } = body;
|
||||
|
||||
@@ -34,6 +58,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '结束日期不能早于开始日期' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('objectives')
|
||||
.insert({ project_id, title, description, period_start, period_end, created_by })
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
if (!hasProjectAccess(ctx!, params.id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('project_members')
|
||||
.select('user_id, users(id, name, role, avatar_url)')
|
||||
@@ -21,6 +31,12 @@ export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { user_id } = body;
|
||||
|
||||
@@ -45,6 +61,12 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('user_id');
|
||||
|
||||
|
||||
@@ -2,11 +2,21 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
if (!hasProjectAccess(ctx!, params.id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.select(`
|
||||
@@ -37,6 +47,12 @@ export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, owner_id, pm_id, project_type, status, member_ids } = body;
|
||||
|
||||
@@ -78,6 +94,12 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.delete()
|
||||
|
||||
@@ -2,9 +2,15 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { getAuthContext } from '@/lib/access-control';
|
||||
|
||||
export async function GET() {
|
||||
const { data, error } = await supabase
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('projects')
|
||||
.select(`
|
||||
*,
|
||||
@@ -13,6 +19,15 @@ export async function GET() {
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!ctx.isAdmin) {
|
||||
if (ctx.accessibleProjectIds.length === 0) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
query = query.in('id', ctx.accessibleProjectIds);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
@@ -21,6 +36,14 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
if (!ctx.isAdmin) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, owner_id, pm_id, project_type, member_ids } = body;
|
||||
|
||||
|
||||
240
app/api/task-steps/route.ts
Normal file
240
app/api/task-steps/route.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
async function canAccessTask(userId: string, isAdmin: boolean, accessibleProjectIds: string[], taskId: string) {
|
||||
if (isAdmin) return { ok: true as const };
|
||||
const { data: task, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('id,project_id,owner_user_id,executor_ids,help_requested_to_user_id')
|
||||
.eq('id', taskId)
|
||||
.maybeSingle();
|
||||
if (error) return { ok: false as const, status: 500, error: error.message };
|
||||
if (!task) return { ok: false as const, status: 404, error: '计划不存在' };
|
||||
if (task.project_id && accessibleProjectIds.includes(task.project_id)) return { ok: true as const };
|
||||
if (task.owner_user_id === userId) return { ok: true as const };
|
||||
if (task.help_requested_to_user_id === userId) return { ok: true as const };
|
||||
if (Array.isArray(task.executor_ids) && task.executor_ids.includes(userId)) return { ok: true as const };
|
||||
return { ok: false as const, status: 403, error: '无权限' };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const task_id = searchParams.get('task_id');
|
||||
const help_requested_to_user_id = searchParams.get('help_requested_to_user_id');
|
||||
|
||||
let query = supabase
|
||||
.from('task_steps')
|
||||
.select(`
|
||||
*,
|
||||
task:tasks(
|
||||
id,
|
||||
title,
|
||||
project_id,
|
||||
kr_id,
|
||||
owner_user_id,
|
||||
help_requested_to_user_id,
|
||||
help_requested_by_user_id,
|
||||
help_requested_at
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (help_requested_to_user_id && help_requested_to_user_id !== ctx!.user.id) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (task_id) {
|
||||
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, task_id);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
}
|
||||
if (!task_id && !help_requested_to_user_id) {
|
||||
query = query.or(`executor_id.eq.${ctx!.user.id},help_requested_to_user_id.eq.${ctx!.user.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (task_id) query = query.eq('task_id', task_id);
|
||||
if (help_requested_to_user_id) query = query.eq('help_requested_to_user_id', help_requested_to_user_id);
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
task_id,
|
||||
content,
|
||||
note,
|
||||
executor_id,
|
||||
start_date,
|
||||
end_date,
|
||||
is_completed,
|
||||
help_requested_to_user_id,
|
||||
help_requested_by_user_id,
|
||||
} = body;
|
||||
|
||||
const isHelpRequested = Boolean(help_requested_to_user_id);
|
||||
|
||||
if (!task_id) {
|
||||
return NextResponse.json({ error: '计划ID为必填项' }, { status: 400 });
|
||||
}
|
||||
|
||||
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, task_id);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('task_steps')
|
||||
.insert({
|
||||
task_id,
|
||||
content,
|
||||
note,
|
||||
executor_id,
|
||||
start_date,
|
||||
end_date,
|
||||
is_completed,
|
||||
help_requested_to_user_id: help_requested_to_user_id || null,
|
||||
help_requested_by_user_id: isHelpRequested ? (ctx!.isAdmin ? (help_requested_by_user_id || null) : ctx!.user.id) : null,
|
||||
help_requested_at: isHelpRequested ? new Date().toISOString() : null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
id,
|
||||
content,
|
||||
note,
|
||||
executor_id,
|
||||
start_date,
|
||||
end_date,
|
||||
is_completed,
|
||||
help_requested_to_user_id,
|
||||
help_requested_by_user_id,
|
||||
} = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing step id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: stepRow, error: stepErr } = await supabase
|
||||
.from('task_steps')
|
||||
.select('id,task_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (stepErr) return NextResponse.json({ error: stepErr.message }, { status: 500 });
|
||||
if (!stepRow) return NextResponse.json({ error: '步骤不存在' }, { status: 404 });
|
||||
|
||||
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, stepRow.task_id);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (content !== undefined) updateData.content = content;
|
||||
if (note !== undefined) updateData.note = note;
|
||||
if (executor_id !== undefined) updateData.executor_id = executor_id;
|
||||
if (start_date !== undefined) updateData.start_date = start_date;
|
||||
if (end_date !== undefined) updateData.end_date = end_date;
|
||||
if (is_completed !== undefined) updateData.is_completed = is_completed;
|
||||
|
||||
if (help_requested_to_user_id !== undefined) {
|
||||
updateData.help_requested_to_user_id = help_requested_to_user_id;
|
||||
if (help_requested_to_user_id) {
|
||||
updateData.help_requested_by_user_id = ctx!.isAdmin ? (help_requested_by_user_id || null) : ctx!.user.id;
|
||||
updateData.help_requested_at = new Date().toISOString();
|
||||
} else {
|
||||
updateData.help_requested_by_user_id = null;
|
||||
updateData.help_requested_at = null;
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('task_steps')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: stepRow, error: stepErr } = await supabase
|
||||
.from('task_steps')
|
||||
.select('id,task_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (stepErr) return NextResponse.json({ error: stepErr.message }, { status: 500 });
|
||||
if (!stepRow) return NextResponse.json({ error: '步骤不存在' }, { status: 404 });
|
||||
|
||||
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, stepRow.task_id);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('task_steps')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
118
app/api/tasks/[id]/route.ts
Normal file
118
app/api/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
async function canAccessTask(userId: string, isAdmin: boolean, accessibleProjectIds: string[], taskId: string) {
|
||||
if (isAdmin) return { ok: true as const };
|
||||
|
||||
const { data: task, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('id,project_id,owner_user_id,executor_ids,help_requested_to_user_id')
|
||||
.eq('id', taskId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) return { ok: false as const, status: 500, error: error.message };
|
||||
if (!task) return { ok: false as const, status: 404, error: '计划不存在' };
|
||||
|
||||
if (task.project_id && accessibleProjectIds.includes(task.project_id)) return { ok: true as const };
|
||||
if (task.owner_user_id === userId) return { ok: true as const };
|
||||
if (task.help_requested_to_user_id === userId) return { ok: true as const };
|
||||
if (Array.isArray(task.executor_ids) && task.executor_ids.includes(userId)) return { ok: true as const };
|
||||
|
||||
return { ok: false as const, status: 403, error: '无权限' };
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, params.id);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
urgency,
|
||||
owner_user_id,
|
||||
executor_ids,
|
||||
start_date,
|
||||
end_date,
|
||||
help_requested_to_user_id,
|
||||
help_requested_by_user_id,
|
||||
} = body;
|
||||
|
||||
const updateData: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
if (urgency !== undefined) updateData.urgency = urgency;
|
||||
if (owner_user_id !== undefined) updateData.owner_user_id = owner_user_id;
|
||||
if (executor_ids !== undefined) updateData.executor_ids = executor_ids;
|
||||
if (start_date !== undefined) updateData.start_date = start_date;
|
||||
if (end_date !== undefined) updateData.end_date = end_date;
|
||||
|
||||
if (help_requested_to_user_id !== undefined) {
|
||||
updateData.help_requested_to_user_id = help_requested_to_user_id;
|
||||
if (help_requested_to_user_id) {
|
||||
updateData.help_requested_by_user_id = help_requested_by_user_id || null;
|
||||
updateData.help_requested_at = new Date().toISOString();
|
||||
} else {
|
||||
updateData.help_requested_by_user_id = null;
|
||||
updateData.help_requested_at = null;
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.update(updateData)
|
||||
.eq('id', params.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, params.id);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('tasks')
|
||||
.delete()
|
||||
.eq('id', params.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
189
app/api/tasks/route.ts
Normal file
189
app/api/tasks/route.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const kr_id = searchParams.get('kr_id');
|
||||
const project_id = searchParams.get('project_id');
|
||||
const user_id = searchParams.get('user_id');
|
||||
const help_requested_to_user_id = searchParams.get('help_requested_to_user_id');
|
||||
const include_help = searchParams.get('include_help') === 'true';
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (user_id && user_id !== ctx!.user.id) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (help_requested_to_user_id && help_requested_to_user_id !== ctx!.user.id) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (project_id && !hasProjectAccess(ctx!, project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (kr_id) {
|
||||
const { data: kr, error: krErr } = await supabase
|
||||
.from('krs')
|
||||
.select('id,project_id')
|
||||
.eq('id', kr_id)
|
||||
.maybeSingle();
|
||||
if (krErr) return NextResponse.json({ error: krErr.message }, { status: 500 });
|
||||
if (!kr) return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
|
||||
if (!hasProjectAccess(ctx!, kr.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseQuery = () =>
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select(`
|
||||
*,
|
||||
steps:task_steps(*)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
const applyParams = (query: ReturnType<typeof baseQuery>) => {
|
||||
let q = query;
|
||||
if (kr_id) {
|
||||
q = q.eq('kr_id', kr_id);
|
||||
} else if (project_id) {
|
||||
q = q.eq('project_id', project_id).is('kr_id', null);
|
||||
}
|
||||
|
||||
if (help_requested_to_user_id) {
|
||||
q = q.eq('help_requested_to_user_id', help_requested_to_user_id);
|
||||
} else if (user_id) {
|
||||
if (include_help) {
|
||||
q = q.or(`owner_user_id.eq.${user_id},help_requested_to_user_id.eq.${user_id}`);
|
||||
} else {
|
||||
q = q.eq('owner_user_id', user_id);
|
||||
}
|
||||
}
|
||||
|
||||
return q;
|
||||
};
|
||||
|
||||
if (ctx!.isAdmin) {
|
||||
const { data, error } = await applyParams(baseQuery());
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
if (project_id || kr_id) {
|
||||
const { data, error } = await applyParams(baseQuery());
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
const relatedTasks = new Map<string, any>();
|
||||
const addRows = (rows: any[] | null) => {
|
||||
if (!rows) return;
|
||||
for (const row of rows) {
|
||||
relatedTasks.set(row.id, row);
|
||||
}
|
||||
};
|
||||
|
||||
const projectIds = ctx!.accessibleProjectIds;
|
||||
|
||||
const builders: any[] = [];
|
||||
if (projectIds.length > 0) {
|
||||
builders.push(applyParams(baseQuery().in('project_id', projectIds)));
|
||||
}
|
||||
builders.push(applyParams(baseQuery().or(`owner_user_id.eq.${ctx!.user.id},help_requested_to_user_id.eq.${ctx!.user.id}`)));
|
||||
builders.push(applyParams(baseQuery().contains('executor_ids', [ctx!.user.id])));
|
||||
|
||||
const results = await Promise.all(builders.map(b => b));
|
||||
for (const r of results) {
|
||||
if (r.error) {
|
||||
return NextResponse.json({ error: r.error.message }, { status: 500 });
|
||||
}
|
||||
addRows(r.data);
|
||||
}
|
||||
|
||||
return NextResponse.json(Array.from(relatedTasks.values()));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
kr_id,
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
urgency,
|
||||
owner_user_id,
|
||||
executor_ids,
|
||||
start_date,
|
||||
end_date,
|
||||
help_requested_to_user_id,
|
||||
help_requested_by_user_id,
|
||||
} = body;
|
||||
|
||||
const isHelpRequested = Boolean(help_requested_to_user_id);
|
||||
|
||||
if (!ctx!.isAdmin) {
|
||||
if (project_id && !hasProjectAccess(ctx!, project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
if (kr_id) {
|
||||
const { data: kr, error: krErr } = await supabase
|
||||
.from('krs')
|
||||
.select('id,project_id')
|
||||
.eq('id', kr_id)
|
||||
.maybeSingle();
|
||||
if (krErr) return NextResponse.json({ error: krErr.message }, { status: 500 });
|
||||
if (!kr) return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
|
||||
if (!hasProjectAccess(ctx!, kr.project_id)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.insert({
|
||||
kr_id: kr_id || null,
|
||||
project_id: project_id || null,
|
||||
title,
|
||||
description,
|
||||
status: status || 'todo',
|
||||
priority: priority || 3,
|
||||
urgency: urgency || 3,
|
||||
owner_user_id,
|
||||
executor_ids: executor_ids || [],
|
||||
start_date,
|
||||
end_date,
|
||||
help_requested_to_user_id: help_requested_to_user_id || null,
|
||||
help_requested_by_user_id: help_requested_by_user_id || null,
|
||||
help_requested_at: isHelpRequested ? new Date().toISOString() : null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('templates')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.maybeSingle();
|
||||
.eq('id', params.id);
|
||||
|
||||
if (!ctx!.isAdmin) query = query.eq('is_active', true);
|
||||
|
||||
const { data, error } = await query.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
@@ -26,6 +36,12 @@ export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, project_type, tags, items, is_active } = body;
|
||||
|
||||
@@ -55,6 +71,12 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('templates')
|
||||
.delete()
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const activeOnly = searchParams.get('active_only') === 'true';
|
||||
const projectType = searchParams.get('project_type');
|
||||
|
||||
let query = supabase.from('templates').select('*').order('name', { ascending: true });
|
||||
|
||||
if (activeOnly) query = query.eq('is_active', true);
|
||||
if (!ctx!.isAdmin || activeOnly) query = query.eq('is_active', true);
|
||||
if (projectType) query = query.eq('project_type', projectType);
|
||||
|
||||
const { data, error } = await query;
|
||||
@@ -21,6 +28,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, project_type, tags, items } = body;
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
if (!ctx!.isAdmin && ctx!.user.id !== params.id) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.eq('id', params.id)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -26,14 +36,30 @@ export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, role, avatar_url } = body;
|
||||
const { name, role, avatar_url, username, password } = body;
|
||||
|
||||
const updateData: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (role !== undefined) updateData.role = role;
|
||||
if (avatar_url !== undefined) updateData.avatar_url = avatar_url;
|
||||
if (username !== undefined) updateData.username = username || null;
|
||||
if (password) {
|
||||
const { hashPassword } = await import('@/lib/password');
|
||||
updateData.password_hash = hashPassword(String(password));
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.update({ name, role, avatar_url, updated_at: new Date().toISOString() })
|
||||
.update(updateData)
|
||||
.eq('id', params.id)
|
||||
.select()
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
@@ -47,6 +73,30 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { data: user, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('id,role')
|
||||
.eq('id', params.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (userError) {
|
||||
return NextResponse.json({ error: userError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.role === 'owner') {
|
||||
return NextResponse.json({ error: '不能删除老板账号' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('users')
|
||||
.delete()
|
||||
|
||||
@@ -2,11 +2,54 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAuthenticated(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
if (ctx!.isAdmin) {
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
const projectIds = ctx!.accessibleProjectIds;
|
||||
const allowedUserIds = new Set<string>([ctx!.user.id]);
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
const [membersRes, projectsRes] = await Promise.all([
|
||||
supabase.from('project_members').select('user_id').in('project_id', projectIds),
|
||||
supabase.from('projects').select('owner_id,pm_id').in('id', projectIds),
|
||||
]);
|
||||
|
||||
if (membersRes.data) {
|
||||
for (const row of membersRes.data as Array<{ user_id: string }>) {
|
||||
allowedUserIds.add(row.user_id);
|
||||
}
|
||||
}
|
||||
if (projectsRes.data) {
|
||||
for (const row of projectsRes.data as Array<{ owner_id: string | null; pm_id: string | null }>) {
|
||||
if (row.owner_id) allowedUserIds.add(row.owner_id);
|
||||
if (row.pm_id) allowedUserIds.add(row.pm_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = Array.from(allowedUserIds);
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.in('id', ids)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
@@ -17,17 +60,35 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ctx = await getAuthContext(request);
|
||||
const auth = ensureAdmin(ctx);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, role, avatar_url } = body;
|
||||
const { name, role, avatar_url, username, password } = body;
|
||||
|
||||
if (!name || !role) {
|
||||
return NextResponse.json({ error: '名称和角色为必填项' }, { status: 400 });
|
||||
}
|
||||
|
||||
let password_hash: string | null = null;
|
||||
if (password) {
|
||||
const { hashPassword } = await import('@/lib/password');
|
||||
password_hash = hashPassword(String(password));
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.insert({ name, role, avatar_url })
|
||||
.select()
|
||||
.insert({
|
||||
name,
|
||||
role,
|
||||
avatar_url,
|
||||
username: username || null,
|
||||
password_hash,
|
||||
})
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -27,8 +27,9 @@ import {
|
||||
isKRActive,
|
||||
} from '@/lib/kr-utils';
|
||||
import { REASON_CODES, PERIOD_TYPES, DIRECTION_LABELS } from '@/lib/constants';
|
||||
import type { KR, User, Checkin, Objective } from '@/types/database';
|
||||
import { toast } from 'sonner';
|
||||
import { TaskEditDialog } from '@/components/task-edit-dialog';
|
||||
import type { KR, User, Checkin, Objective, Task, TaskStep } from '@/types/database';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
@@ -43,7 +44,9 @@ import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
interface KRWithRelations extends KR {
|
||||
@@ -56,6 +59,8 @@ interface CheckinWithRelations extends Checkin {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
|
||||
const checkinSchema = z.object({
|
||||
current_value: z.number(),
|
||||
confidence: z.number().min(0).max(100),
|
||||
@@ -71,31 +76,54 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
|
||||
const [kr, setKR] = useState<KRWithRelations | null>(null);
|
||||
const [checkins, setCheckins] = useState<CheckinWithRelations[]>([]);
|
||||
const [tasks, setTasks] = useState<(Task & { steps: TaskStep[] })[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isCheckinOpen, setIsCheckinOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<(Task & { steps: TaskStep[] }) | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [krRes, checkinsRes] = await Promise.all([
|
||||
const [krRes, checkinsRes, tasksRes] = await Promise.all([
|
||||
fetch(`/api/krs/${krId}`),
|
||||
fetch(`/api/checkins?kr_id=${krId}`),
|
||||
fetch(`/api/tasks?kr_id=${krId}`),
|
||||
]);
|
||||
|
||||
if (krRes.status === 403) {
|
||||
setLoadError('你没有权限查看此 KR');
|
||||
setKR(null);
|
||||
return;
|
||||
}
|
||||
if (krRes.status === 404) {
|
||||
setLoadError('KR 不存在');
|
||||
setKR(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (krRes.ok) {
|
||||
const krData = await krRes.json();
|
||||
setKR(krData);
|
||||
setLoadError(null);
|
||||
}
|
||||
|
||||
if (checkinsRes.ok) {
|
||||
const checkinsData = await checkinsRes.json();
|
||||
setCheckins(checkinsData);
|
||||
}
|
||||
|
||||
if (tasksRes.ok) {
|
||||
const tasksData = await tasksRes.json();
|
||||
setTasks(tasksData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch KR data:', error);
|
||||
setLoadError('加载失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -248,7 +276,7 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
if (!kr) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
|
||||
<p className="text-muted-foreground">KR 不存在</p>
|
||||
<p className="text-muted-foreground">{loadError || 'KR 不存在'}</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link href="/my-krs">返回我的 KR</Link>
|
||||
</Button>
|
||||
@@ -263,6 +291,24 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
const isOwner = currentUser?.id === kr.owner_user_id;
|
||||
const TrendIcon = kr.direction === 'increase' ? TrendingUp : TrendingDown;
|
||||
|
||||
const handleTaskStatusChange = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: newStatus as any } : t));
|
||||
toast.success('计划状态已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const isBooleanMetric = kr?.unit === '状态' && kr?.metric_name === '完成状态';
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
@@ -400,18 +446,37 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
name="current_value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>当前值 ({kr.unit})</FormLabel>
|
||||
<FormLabel>当前状态</FormLabel>
|
||||
<FormControl>
|
||||
{isBooleanMetric ? (
|
||||
<RadioGroup
|
||||
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||
defaultValue={field.value.toString()}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="0" id="r1" />
|
||||
<Label htmlFor="r1">待完成</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="r2" />
|
||||
<Label htmlFor="r2">已完成</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
目标值: {kr.target_value} {kr.unit}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -508,9 +573,12 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
<ProgressRing progress={progress} status={kr.status} size={100} strokeWidth={8} />
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-slate-800">
|
||||
{kr.current_value} / {kr.target_value}
|
||||
{isBooleanMetric
|
||||
? (kr.current_value >= 1 ? '已完成' : '待完成')
|
||||
: `${kr.current_value} / ${kr.target_value}`
|
||||
}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{kr.unit}</p>
|
||||
{!isBooleanMetric && <p className="text-muted-foreground">{kr.unit}</p>}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<TrendIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -545,6 +613,93 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5 text-indigo-600" />
|
||||
执行计划 ({tasks.length})
|
||||
</CardTitle>
|
||||
{isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-indigo-600 border-indigo-200 hover:bg-indigo-50"
|
||||
onClick={() => {
|
||||
setEditingTask(null);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建计划
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed rounded-lg bg-slate-50">
|
||||
<p className="text-muted-foreground text-sm">暂无计划,拆解 KR 为具体执行步骤</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group p-3 bg-white border rounded-lg hover:shadow-sm transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="mt-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTaskStatusChange(task.id, task.status === 'done' ? 'todo' : 'done');
|
||||
}}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded border flex items-center justify-center ${task.status === 'done' ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300 hover:border-indigo-500'}`}>
|
||||
{task.status === 'done' && <CheckSquare className="h-3.5 w-3.5 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className={`font-medium text-slate-800 truncate ${task.status === 'done' ? 'line-through text-slate-400' : ''}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<StatusBadge
|
||||
status={task.status === 'done' ? 'green' : (task.status === 'in_progress' ? 'yellow' : 'red')}
|
||||
size="sm"
|
||||
text={task.status === 'done' ? '已完成' : (task.status === 'in_progress' ? '进行中' : '待办')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{task.owner_user_id && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
{currentUser?.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDateRange(task.start_date || '', task.end_date || '')}
|
||||
</span>
|
||||
{task.steps.length > 0 && (
|
||||
<span className="bg-slate-100 px-1.5 rounded">
|
||||
{task.steps.filter(s => s.is_completed).length}/{task.steps.length} 步骤
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">打卡记录</CardTitle>
|
||||
@@ -565,7 +720,10 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
<div className="flex-1 pb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium">
|
||||
{checkin.current_value} {kr.unit}
|
||||
{isBooleanMetric
|
||||
? (checkin.current_value >= 1 ? '已完成' : '待完成')
|
||||
: `${checkin.current_value} ${kr.unit}`
|
||||
}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(checkin.created_at).toLocaleString('zh-CN', {
|
||||
@@ -646,6 +804,15 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TaskEditDialog
|
||||
open={isTaskDialogOpen}
|
||||
onOpenChange={setIsTaskDialogOpen}
|
||||
krId={krId}
|
||||
projectId={kr.project_id}
|
||||
projectTitle={kr.project?.name || ''}
|
||||
task={editingTask}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Toaster } from '@/components/ui/sonner';
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OKR 执行系统 - 目标管理',
|
||||
title: '兜爸目标管理',
|
||||
description: '让目标被持续看见、被拆解、被跟进、被纠偏、被完成',
|
||||
};
|
||||
|
||||
|
||||
80
app/login/page.tsx
Normal file
80
app/login/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { useUser } from '@/components/user-context';
|
||||
|
||||
const DEFAULT_USERNAME = 'baicaimiao';
|
||||
const DEFAULT_PASSWORD = 'baicaimiao123';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isInitialized, isLoading, refreshMe } = useUser();
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && !isLoading && isAuthenticated) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [isAuthenticated, isInitialized, isLoading, router]);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || '登录失败');
|
||||
}
|
||||
|
||||
const userId = data?.user?.id as string | undefined;
|
||||
if (!userId) throw new Error('登录失败');
|
||||
|
||||
await refreshMe();
|
||||
toast.success('登录成功');
|
||||
router.replace('/');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="username">账号</Label>
|
||||
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full bg-teal-600 hover:bg-teal-700" disabled={isSubmitting}>
|
||||
{isSubmitting ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,22 @@ interface KRWithRelations extends KR {
|
||||
}
|
||||
|
||||
export default function MyKRsPage() {
|
||||
const { currentUser } = useUser();
|
||||
const { currentUser, users } = useUser();
|
||||
const [krs, setKRs] = useState<KRWithRelations[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Group KRs by owner for owner view
|
||||
const krsByOwner = users.map(user => ({
|
||||
user,
|
||||
krs: krs.filter(kr => kr.owner_user_id === user.id && isKRActive(kr))
|
||||
})).filter(group => group.krs.length > 0 || group.user.role !== 'owner') // Filter out users with no active KRs unless it's a member/pm we want to track
|
||||
.sort((a, b) => {
|
||||
// Sort by KR count descending (users with KRs first)
|
||||
if (a.krs.length > 0 && b.krs.length === 0) return -1;
|
||||
if (a.krs.length === 0 && b.krs.length > 0) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchKRs() {
|
||||
try {
|
||||
@@ -46,6 +58,8 @@ export default function MyKRsPage() {
|
||||
const completedKRs = activeKRs.filter(kr => calculateProgress(kr) >= 1);
|
||||
const pastKRs = myKRs.filter(kr => !isKRActive(kr));
|
||||
|
||||
const isOwner = currentUser?.role === 'owner';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
@@ -64,12 +78,53 @@ export default function MyKRsPage() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-800">我的 KR</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800">
|
||||
{isOwner ? '全员 KR 概览' : '我的 KR'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{currentUser?.name} 负责的执行目标
|
||||
{isOwner
|
||||
? '查看所有成员的当前周期目标执行情况'
|
||||
: `${currentUser?.name} 负责的执行目标`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isOwner ? (
|
||||
<div className="space-y-8">
|
||||
{krsByOwner.map(({ user, krs }) => (
|
||||
<div key={user.id} className="space-y-4">
|
||||
<div className="flex items-center gap-3 border-b pb-2">
|
||||
<div className="h-8 w-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">
|
||||
{user.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">{user.name}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{krs.length} 个进行中的 KR
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{krs.length === 0 ? (
|
||||
<div className="p-8 text-center bg-slate-50 rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-sm">暂无进行中的 KR</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{krs.map(kr => <KRCard key={kr.id} kr={kr} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{krsByOwner.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">没有任何成员有正在进行的 KR</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
@@ -142,6 +197,8 @@ export default function MyKRsPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
485
app/my-tasks/page.tsx
Normal file
485
app/my-tasks/page.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { StatusBadge } from '@/components/status-badge';
|
||||
import { useUser } from '@/components/user-context';
|
||||
import { formatDateRange } from '@/lib/kr-utils';
|
||||
import type { Task, TaskStep, User } from '@/types/database';
|
||||
import { CheckSquare, Clock, Calendar, AlertTriangle, User as UserIcon, Plus } from 'lucide-react';
|
||||
import { TaskEditDialog } from '@/components/task-edit-dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TaskWithRelations extends Task {
|
||||
steps: TaskStep[];
|
||||
owner: User | null;
|
||||
// We might want project info too, but the API doesn't return it joined yet.
|
||||
// We can fetch projects separately or just show task details.
|
||||
}
|
||||
|
||||
interface HelpStepWithRelations extends TaskStep {
|
||||
task: Pick<Task, 'id' | 'title' | 'project_id' | 'kr_id' | 'owner_user_id' | 'help_requested_to_user_id' | 'help_requested_by_user_id' | 'help_requested_at'> | null;
|
||||
}
|
||||
|
||||
export default function MyTasksPage() {
|
||||
const { currentUser, users } = useUser();
|
||||
const [tasks, setTasks] = useState<TaskWithRelations[]>([]);
|
||||
const [helpSteps, setHelpSteps] = useState<HelpStepWithRelations[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Dialog state
|
||||
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<(Task & { steps: TaskStep[] }) | null>(null);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const isOwnerView = currentUser?.role === 'owner';
|
||||
const url = isOwnerView
|
||||
? '/api/tasks'
|
||||
: `/api/tasks?user_id=${currentUser?.id}&include_help=true`;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Enrich with owner info locally since API doesn't join
|
||||
const enrichedData = data.map((t: any) => ({
|
||||
...t,
|
||||
owner: users.find(u => u.id === t.owner_user_id) || null
|
||||
}));
|
||||
setTasks(enrichedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHelpSteps = async () => {
|
||||
if (!currentUser?.id || currentUser.role === 'owner') return;
|
||||
try {
|
||||
const res = await fetch(`/api/task-steps?help_requested_to_user_id=${currentUser.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setHelpSteps(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch help steps:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (users.length > 0 && currentUser) {
|
||||
fetchTasks();
|
||||
fetchHelpSteps();
|
||||
}
|
||||
}, [users, currentUser]);
|
||||
|
||||
const handleTaskStatusChange = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: newStatus as any } : t));
|
||||
toast.success('计划状态已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
// Group Tasks by owner for owner view
|
||||
const tasksByOwner = users.map(user => ({
|
||||
user,
|
||||
tasks: tasks.filter(t => t.owner_user_id === user.id && t.status !== 'done')
|
||||
})).filter(group => group.tasks.length > 0 || group.user.role !== 'owner')
|
||||
.sort((a, b) => {
|
||||
if (a.tasks.length > 0 && b.tasks.length === 0) return -1;
|
||||
if (a.tasks.length === 0 && b.tasks.length > 0) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const myTasks = tasks.filter(t => t.owner_user_id === currentUser?.id);
|
||||
const incomingHelpTasks = tasks.filter(t => t.help_requested_to_user_id === currentUser?.id && t.owner_user_id !== currentUser?.id);
|
||||
const activeTasks = myTasks.filter(t => t.status !== 'done');
|
||||
const completedTasks = myTasks.filter(t => t.status === 'done');
|
||||
|
||||
// "Pending" tasks can be defined as todo/in_progress/expected
|
||||
const todoTasks = activeTasks.filter(t => t.status === 'todo');
|
||||
const inProgressTasks = activeTasks.filter(t => t.status === 'in_progress');
|
||||
|
||||
const isOwner = currentUser?.role === 'owner';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-slate-200 rounded w-32" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-24 bg-slate-200 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">
|
||||
{isOwner ? '全员计划概览' : '我的计划'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{isOwner
|
||||
? '查看所有成员的计划执行情况'
|
||||
: `${currentUser?.name} 的计划清单`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{!isOwner && (
|
||||
<Button onClick={() => {
|
||||
setEditingTask(null);
|
||||
setIsTaskDialogOpen(true);
|
||||
}} className="bg-indigo-600 hover:bg-indigo-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建计划
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOwner ? (
|
||||
<div className="space-y-8">
|
||||
{tasksByOwner.map(({ user, tasks }) => (
|
||||
<div key={user.id} className="space-y-4">
|
||||
<div className="flex items-center gap-3 border-b pb-2">
|
||||
<div className="h-8 w-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">
|
||||
{user.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">{user.name}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tasks.length} 个进行中的计划
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="p-8 text-center bg-slate-50 rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-sm">暂无进行中的计划</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{tasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tasksByOwner.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">没有任何成员有正在进行的计划</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckSquare className="h-8 w-8 text-slate-300 mx-auto mb-2" />
|
||||
<p className="text-3xl font-bold text-slate-800">{activeTasks.length}</p>
|
||||
<p className="text-sm text-muted-foreground">待完成</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<Clock className="h-8 w-8 text-indigo-300 mx-auto mb-2" />
|
||||
<p className="text-3xl font-bold text-indigo-600">{inProgressTasks.length}</p>
|
||||
<p className="text-sm text-muted-foreground">进行中</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<AlertTriangle className="h-8 w-8 text-rose-300 mx-auto mb-2" />
|
||||
<p className="text-3xl font-bold text-rose-600">
|
||||
{activeTasks.filter(t => t.priority >= 4).length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">高优先级</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="active" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">进行中 ({activeTasks.length})</TabsTrigger>
|
||||
<TabsTrigger value="help_plans">协助我的计划 ({incomingHelpTasks.length})</TabsTrigger>
|
||||
<TabsTrigger value="help_steps">协助我的步骤 ({helpSteps.length})</TabsTrigger>
|
||||
<TabsTrigger value="completed">已完成 ({completedTasks.length})</TabsTrigger>
|
||||
<TabsTrigger value="all">全部 ({myTasks.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="active" className="space-y-4">
|
||||
{activeTasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">暂无进行中的计划</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
activeTasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="help_plans" className="space-y-4">
|
||||
{incomingHelpTasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">暂无需要你协助的计划</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
incomingHelpTasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
variant="help"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="help_steps" className="space-y-4">
|
||||
{helpSteps.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">暂无需要你协助的步骤</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
helpSteps.map(step => (
|
||||
<Card
|
||||
key={step.id}
|
||||
className="border-amber-200 bg-amber-50/40 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => {
|
||||
if (step.task) {
|
||||
const task = tasks.find(t => t.id === step.task?.id);
|
||||
if (task) {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded border border-amber-200">
|
||||
协助步骤
|
||||
</span>
|
||||
{step.help_requested_by_user_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
发起人:{users.find(u => u.id === step.help_requested_by_user_id)?.name || '未知'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-medium text-slate-800 truncate">{step.content}</p>
|
||||
{step.task?.title && (
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
来自计划:{step.task.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-2 flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDateRange(step.start_date || '', step.end_date || '')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<StatusBadge status={step.is_completed ? 'green' : 'yellow'} size="sm" text={step.is_completed ? '已完成' : '待协助'} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="completed" className="space-y-4">
|
||||
{completedTasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">暂无已完成的计划</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
completedTasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
{myTasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">暂无计划</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
myTasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TaskEditDialog
|
||||
open={isTaskDialogOpen}
|
||||
onOpenChange={setIsTaskDialogOpen}
|
||||
projectId="" // Global task or we assume user selects project in dialog if we enable it?
|
||||
// Wait, TaskEditDialog requires projectId for context.
|
||||
// If we create task from here, it might not belong to a project initially or we need to select one.
|
||||
// But the dialog currently assumes projectId is passed for display context "Project: xxx".
|
||||
// Let's pass a placeholder or modify dialog to allow selecting project if not provided.
|
||||
// For now, let's pass empty string and "个人任务" or similar.
|
||||
projectTitle="个人/全局计划"
|
||||
task={editingTask}
|
||||
onSuccess={fetchTasks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskCard({ task, onStatusChange, onClick, variant }: { task: TaskWithRelations; onStatusChange: (id: string, status: string) => void; onClick: () => void; variant?: 'normal' | 'help' }) {
|
||||
const { currentUser, users } = useUser();
|
||||
const isDone = task.status === 'done';
|
||||
const isIncomingHelp = variant === 'help' || (task.help_requested_to_user_id && task.help_requested_to_user_id === currentUser?.id && task.owner_user_id !== currentUser?.id);
|
||||
const isOutgoingHelp = task.help_requested_to_user_id && task.owner_user_id === currentUser?.id;
|
||||
const requesterName = task.help_requested_by_user_id ? (users.find(u => u.id === task.help_requested_by_user_id)?.name || '未知') : null;
|
||||
const helperName = task.help_requested_to_user_id ? (users.find(u => u.id === task.help_requested_to_user_id)?.name || '未知') : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`hover:shadow-md transition-shadow cursor-pointer ${isDone ? 'opacity-70 bg-slate-50' : ''} ${isIncomingHelp ? 'border-amber-200 bg-amber-50/40' : ''} ${isOutgoingHelp ? 'border-indigo-200 bg-indigo-50/40' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="mt-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStatusChange(task.id, isDone ? 'todo' : 'done');
|
||||
}}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${isDone ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300 hover:border-indigo-500'}`}>
|
||||
{isDone && <CheckSquare className="h-3.5 w-3.5 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className={`font-medium text-slate-800 truncate ${isDone ? 'line-through text-slate-500' : ''}`}>
|
||||
{task.title}
|
||||
</h3>
|
||||
<StatusBadge
|
||||
status={task.status === 'done' ? 'green' : (task.status === 'in_progress' ? 'yellow' : 'red')}
|
||||
size="sm"
|
||||
text={task.status === 'done' ? '已完成' : (task.status === 'in_progress' ? '进行中' : '待办')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(isIncomingHelp || isOutgoingHelp) && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${isIncomingHelp ? 'bg-amber-100 text-amber-800 border-amber-200' : 'bg-indigo-100 text-indigo-800 border-indigo-200'}`}>
|
||||
{isIncomingHelp ? '协助请求' : '已请求协助'}
|
||||
</span>
|
||||
{isIncomingHelp && requesterName && (
|
||||
<span className="text-xs text-muted-foreground">发起人:{requesterName}</span>
|
||||
)}
|
||||
{isOutgoingHelp && helperName && (
|
||||
<span className="text-xs text-muted-foreground">协助人:{helperName}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{task.owner && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
{task.owner.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDateRange(task.start_date || '', task.end_date || '')}
|
||||
</span>
|
||||
{task.steps.length > 0 && (
|
||||
<span className="bg-slate-100 px-1.5 rounded text-xs">
|
||||
{task.steps.filter(s => s.is_completed).length}/{task.steps.length} 步骤
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-800">目标驾驶舱</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800">目标总览</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{currentUser?.role === 'owner' ? '全局视角 - 所有项目健康度一览' : `${ROLE_LABELS[currentUser?.role || 'member']}视角`}
|
||||
</p>
|
||||
|
||||
@@ -10,12 +10,15 @@ import { ProgressRing } from '@/components/progress-ring';
|
||||
import { useUser } from '@/components/user-context';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { calculateProgress, getDaysRemaining, formatDateRange, isKRActive } from '@/lib/kr-utils';
|
||||
import { REASON_CODES, PERIOD_TYPES } from '@/lib/constants';
|
||||
import type { Project, User, Objective, KR } from '@/types/database';
|
||||
import type { Project, User, Objective, KR, Task, TaskStep } from '@/types/database';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -27,8 +30,11 @@ import {
|
||||
Clock,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2
|
||||
Loader2,
|
||||
CheckSquare,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { TaskEditDialog } from '@/components/task-edit-dialog';
|
||||
|
||||
interface ProjectWithRelations extends Project {
|
||||
owner: User | null;
|
||||
@@ -42,29 +48,79 @@ interface KRWithRelations extends KR {
|
||||
|
||||
export default function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
// Force re-compile
|
||||
const { currentUser } = useUser();
|
||||
const { currentUser, users } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const [project, setProject] = useState<ProjectWithRelations | null>(null);
|
||||
const [objectives, setObjectives] = useState<Objective[]>([]);
|
||||
const [krs, setKRs] = useState<KRWithRelations[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isProjectEditDialogOpen, setIsProjectEditDialogOpen] = useState(false);
|
||||
const [isObjectiveDialogOpen, setIsObjectiveDialogOpen] = useState(false);
|
||||
const [editingObjective, setEditingObjective] = useState<Objective | null>(null);
|
||||
const [deleteObjectiveId, setDeleteObjectiveId] = useState<string | null>(null);
|
||||
const [isDeleteObjectiveOpen, setIsDeleteObjectiveOpen] = useState(false);
|
||||
const [isDeletingObjective, setIsDeletingObjective] = useState(false);
|
||||
const [tasks, setTasks] = useState<(Task & { steps: TaskStep[] })[]>([]);
|
||||
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<(Task & { steps: TaskStep[] }) | null>(null);
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
project_type: '',
|
||||
owner_id: '',
|
||||
pm_id: '',
|
||||
member_ids: [] as string[],
|
||||
});
|
||||
|
||||
const initEditForm = useCallback(() => {
|
||||
if (project) {
|
||||
setEditForm({
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
project_type: project.project_type || '通用',
|
||||
owner_id: project.owner_id || '',
|
||||
pm_id: project.pm_id || '',
|
||||
member_ids: project.members?.map(m => m.user_id) || [],
|
||||
});
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isProjectEditDialogOpen) {
|
||||
initEditForm();
|
||||
}
|
||||
}, [isProjectEditDialogOpen, initEditForm]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [projectRes, objectivesRes, krsRes] = await Promise.all([
|
||||
const [projectRes, objectivesRes, krsRes, tasksRes] = await Promise.all([
|
||||
fetch(`/api/projects/${projectId}`),
|
||||
fetch(`/api/objectives?project_id=${projectId}`),
|
||||
fetch(`/api/krs?project_id=${projectId}`),
|
||||
fetch(`/api/tasks?project_id=${projectId}`),
|
||||
]);
|
||||
|
||||
if (projectRes.status === 403) {
|
||||
setLoadError('你没有权限查看此项目');
|
||||
setProject(null);
|
||||
return;
|
||||
}
|
||||
if (projectRes.status === 404) {
|
||||
setLoadError('项目不存在');
|
||||
setProject(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectRes.ok) {
|
||||
const projectData = await projectRes.json();
|
||||
setProject(projectData);
|
||||
setLoadError(null);
|
||||
}
|
||||
|
||||
if (objectivesRes.ok) {
|
||||
@@ -76,8 +132,14 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
const krsData = await krsRes.json();
|
||||
setKRs(krsData);
|
||||
}
|
||||
|
||||
if (tasksRes.ok) {
|
||||
const tasksData = await tasksRes.json();
|
||||
setTasks(tasksData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project data:', error);
|
||||
setLoadError('加载失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -88,6 +150,8 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
}, [fetchData]);
|
||||
|
||||
const canEdit = currentUser?.role === 'owner' || currentUser?.id === project?.pm_id;
|
||||
const isMember = project?.members?.some(m => m.user_id === currentUser?.id);
|
||||
const canAddKR = canEdit || isMember;
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -103,24 +167,21 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProject = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleEditProject = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsEditing(true);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
description: formData.get('description'),
|
||||
}),
|
||||
body: JSON.stringify(editForm),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('更新失败');
|
||||
|
||||
toast.success('项目已更新');
|
||||
setIsProjectEditDialogOpen(false);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('更新失败');
|
||||
@@ -129,16 +190,25 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteObjective = async (objectiveId: string) => {
|
||||
if (!confirm('确定要删除此目标吗?关联的 KR 也会被解除关联。')) return;
|
||||
const requestDeleteObjective = (objectiveId: string) => {
|
||||
setDeleteObjectiveId(objectiveId);
|
||||
setIsDeleteObjectiveOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteObjective = async () => {
|
||||
if (!deleteObjectiveId) return;
|
||||
setIsDeletingObjective(true);
|
||||
try {
|
||||
const res = await fetch(`/api/objectives/${objectiveId}`, { method: 'DELETE' });
|
||||
const res = await fetch(`/api/objectives/${deleteObjectiveId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('删除失败');
|
||||
toast.success('目标已删除');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('删除失败');
|
||||
} finally {
|
||||
setIsDeletingObjective(false);
|
||||
setIsDeleteObjectiveOpen(false);
|
||||
setDeleteObjectiveId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,6 +244,23 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskStatusChange = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('更新失败');
|
||||
|
||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: newStatus as any } : t));
|
||||
toast.success('计划状态已更新');
|
||||
} catch (error) {
|
||||
toast.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const activeKRs = krs.filter(isKRActive);
|
||||
const completedKRs = activeKRs.filter(kr => calculateProgress(kr) >= 1);
|
||||
|
||||
@@ -196,6 +283,22 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button variant="outline" onClick={() => router.push('/projects')}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
返回项目列表
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">{loadError}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8 text-center">
|
||||
@@ -229,28 +332,117 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
{canAddKR && (
|
||||
<div className="flex gap-2">
|
||||
{canEdit && (
|
||||
<>
|
||||
{currentUser?.role === 'owner' && (
|
||||
<>
|
||||
<Dialog>
|
||||
<Dialog open={isProjectEditDialogOpen} onOpenChange={setIsProjectEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑项目</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleEditProject} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name">项目名称</Label>
|
||||
<Input id="name" name="name" defaultValue={project.name} required />
|
||||
<Input
|
||||
id="name"
|
||||
value={editForm.name}
|
||||
onChange={e => setEditForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="project_type">项目类型</Label>
|
||||
<Input
|
||||
id="project_type"
|
||||
value={editForm.project_type}
|
||||
onChange={e => setEditForm(prev => ({ ...prev, project_type: e.target.value }))}
|
||||
placeholder="如:研发、市场、运营"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="owner">负责人</Label>
|
||||
<Select
|
||||
value={editForm.owner_id}
|
||||
onValueChange={val => setEditForm(prev => ({ ...prev, owner_id: val }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择负责人" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pm">项目经理</Label>
|
||||
<Select
|
||||
value={editForm.pm_id}
|
||||
onValueChange={val => setEditForm(prev => ({ ...prev, pm_id: val }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择项目经理" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block">项目成员</Label>
|
||||
<ScrollArea className="h-[120px] w-full border rounded-md p-4">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{users.map(user => (
|
||||
<div key={user.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`member-${user.id}`}
|
||||
checked={editForm.member_ids.includes(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setEditForm(prev => {
|
||||
if (checked) {
|
||||
return { ...prev, member_ids: [...prev.member_ids, user.id] };
|
||||
} else {
|
||||
return { ...prev, member_ids: prev.member_ids.filter(id => id !== user.id) };
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`member-${user.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{user.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">项目描述</Label>
|
||||
<Textarea id="description" name="description" defaultValue={project.description || ''} />
|
||||
<Textarea
|
||||
id="description"
|
||||
value={editForm.description}
|
||||
onChange={e => setEditForm(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isEditing} className="w-full">
|
||||
{isEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
@@ -294,6 +486,8 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
新建目标
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
||||
<Link href={`/projects/${projectId}/krs/new`}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
@@ -401,7 +595,7 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-rose-500 hover:text-rose-600"
|
||||
onClick={() => handleDeleteObjective(obj.id)}
|
||||
onClick={() => requestDeleteObjective(obj.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -478,6 +672,100 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<TaskEditDialog
|
||||
open={isTaskDialogOpen}
|
||||
onOpenChange={setIsTaskDialogOpen}
|
||||
task={editingTask}
|
||||
projectId={projectId}
|
||||
projectTitle={project?.name || ''}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5 text-indigo-600" />
|
||||
执行计划 ({tasks.length})
|
||||
</CardTitle>
|
||||
{canAddKR && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-indigo-600 border-indigo-200 hover:bg-indigo-50"
|
||||
onClick={() => {
|
||||
setEditingTask(null);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建计划
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed rounded-lg bg-slate-50">
|
||||
<p className="text-muted-foreground text-sm">暂无计划,可添加项目级执行计划</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group p-3 bg-white border rounded-lg hover:shadow-sm transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setIsTaskDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="mt-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTaskStatusChange(task.id, task.status === 'done' ? 'todo' : 'done');
|
||||
}}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded border flex items-center justify-center ${task.status === 'done' ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300 hover:border-indigo-500'}`}>
|
||||
{task.status === 'done' && <CheckSquare className="h-3.5 w-3.5 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className={`font-medium text-slate-800 truncate ${task.status === 'done' ? 'line-through text-slate-400' : ''}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<StatusBadge
|
||||
status={task.status === 'done' ? 'green' : (task.status === 'in_progress' ? 'yellow' : 'red')}
|
||||
size="sm"
|
||||
text={task.status === 'done' ? '已完成' : (task.status === 'in_progress' ? '进行中' : '待办')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{task.owner_user_id && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
{users.find(u => u.id === task.owner_user_id)?.name || '未知'}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDateRange(task.start_date || '', task.end_date || '')}
|
||||
</span>
|
||||
{task.steps.length > 0 && (
|
||||
<span className="bg-slate-100 px-1.5 rounded">
|
||||
{task.steps.filter(s => s.is_completed).length}/{task.steps.length} 步骤
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -541,6 +829,28 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isDeleteObjectiveOpen} onOpenChange={setIsDeleteObjectiveOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定要删除此目标吗?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
关联的 KR 将会被解除关联。此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingObjective}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteObjective}
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
disabled={isDeletingObjective}
|
||||
>
|
||||
{isDeletingObjective && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ const formSchema = z.object({
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function NewKRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -45,6 +48,8 @@ export default function NewKRPage() {
|
||||
const [metrics, setMetrics] = useState<Metric[]>([]);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
|
||||
const [isBooleanMetric, setIsBooleanMetric] = useState(false);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
@@ -130,6 +135,19 @@ export default function NewKRPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBooleanToggle = (checked: boolean) => {
|
||||
setIsBooleanMetric(checked);
|
||||
if (checked) {
|
||||
form.setValue('metric_name', '完成状态');
|
||||
form.setValue('unit', '状态');
|
||||
form.setValue('target_value', 1);
|
||||
} else {
|
||||
form.setValue('metric_name', '');
|
||||
form.setValue('unit', '');
|
||||
form.setValue('target_value', 0);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (new Date(values.period_end) < new Date(values.period_start)) {
|
||||
toast.error('结束日期不能早于开始日期');
|
||||
@@ -299,6 +317,12 @@ export default function NewKRPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2 my-4">
|
||||
<Switch id="boolean-mode" onCheckedChange={handleBooleanToggle} />
|
||||
<Label htmlFor="boolean-mode">这是里程碑式 KR(仅需标记完成/未完成)</Label>
|
||||
</div>
|
||||
|
||||
{!isBooleanMetric && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -328,6 +352,7 @@ export default function NewKRPage() {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
@@ -336,7 +361,7 @@ export default function NewKRPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>方向</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isBooleanMetric}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -367,6 +392,7 @@ export default function NewKRPage() {
|
||||
step="any"
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
disabled={isBooleanMetric}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -2,26 +2,20 @@
|
||||
|
||||
import { useState, useEffect, ReactNode } from 'react';
|
||||
import { UserProvider, useUser } from './user-context';
|
||||
import { UserSwitcher } from './user-switcher';
|
||||
import { MainNav } from './main-nav';
|
||||
import { InitWizard } from './init-wizard';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
function AppContent({ children }: { children: ReactNode }) {
|
||||
const { isLoading, isInitialized, users, refreshUsers } = useUser();
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const { isLoading, isInitialized, isAuthenticated } = useUser();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && users.length === 0) {
|
||||
setShowWizard(true);
|
||||
if (!isLoading && isInitialized && !isAuthenticated && pathname !== '/login') {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [isLoading, users.length]);
|
||||
|
||||
const handleInitComplete = async () => {
|
||||
await refreshUsers();
|
||||
setShowWizard(false);
|
||||
window.location.reload();
|
||||
};
|
||||
}, [isAuthenticated, isInitialized, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -34,10 +28,6 @@ function AppContent({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (showWizard) {
|
||||
return <InitWizard onComplete={handleInitComplete} />;
|
||||
}
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -49,13 +39,27 @@ function AppContent({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname === '/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
||||
<p className="text-sm text-muted-foreground">跳转登录...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainNav />
|
||||
<main className="pt-14 pb-24 min-h-screen bg-slate-50">
|
||||
{children}
|
||||
</main>
|
||||
<UserSwitcher />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function InitWizard({ onComplete }: InitWizardProps) {
|
||||
<div className="mx-auto w-16 h-16 bg-teal-100 rounded-full flex items-center justify-center">
|
||||
<Target className="h-8 w-8 text-teal-600" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">欢迎使用 OKR 执行系统</CardTitle>
|
||||
<CardTitle className="text-2xl">欢迎使用 兜爸目标管理</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
系统检测到数据库为空,点击下方按钮初始化演示数据,开始体验完整功能
|
||||
</CardDescription>
|
||||
|
||||
@@ -4,6 +4,8 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUser } from './user-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderKanban,
|
||||
@@ -13,12 +15,14 @@ import {
|
||||
Users,
|
||||
Gauge,
|
||||
Files,
|
||||
CheckSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
const mainNavItems = [
|
||||
{ href: '/', label: '驾驶舱', icon: LayoutDashboard },
|
||||
{ href: '/', label: '总览', icon: LayoutDashboard },
|
||||
{ href: '/projects', label: '项目', icon: FolderKanban },
|
||||
{ href: '/my-krs', label: '我的KR', icon: Target },
|
||||
{ href: '/my-tasks', label: '我的计划', icon: CheckSquare },
|
||||
{ href: '/meeting', label: '例会', icon: CalendarClock },
|
||||
{ href: '/analysis', label: '分析', icon: BarChart3 },
|
||||
];
|
||||
@@ -31,7 +35,8 @@ const adminNavItems = [
|
||||
|
||||
export function MainNav() {
|
||||
const pathname = usePathname();
|
||||
const { currentUser } = useUser();
|
||||
const router = useRouter();
|
||||
const { currentUser, logout } = useUser();
|
||||
const isOwner = currentUser?.role === 'owner';
|
||||
|
||||
return (
|
||||
@@ -41,7 +46,7 @@ export function MainNav() {
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
|
||||
<Target className="h-6 w-6 text-teal-600" />
|
||||
<span className="text-slate-800">OKR 执行系统</span>
|
||||
<span className="text-slate-800">兜爸目标管理</span>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{mainNavItems.map((item) => {
|
||||
@@ -67,6 +72,7 @@ export function MainNav() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isOwner && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground mr-2 hidden lg:block">管理</span>
|
||||
@@ -91,6 +97,21 @@ export function MainNav() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-700 hidden sm:inline">{currentUser?.name}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
logout();
|
||||
router.replace('/login');
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -8,9 +8,10 @@ interface StatusBadgeProps {
|
||||
status: ProjectStatus;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, size = 'md', showLabel = true }: StatusBadgeProps) {
|
||||
export function StatusBadge({ status, size = 'md', showLabel = true, text }: StatusBadgeProps) {
|
||||
const colors = STATUS_COLORS[status];
|
||||
const sizeClasses = {
|
||||
sm: 'h-2 w-2',
|
||||
@@ -23,7 +24,7 @@ export function StatusBadge({ status, size = 'md', showLabel = true }: StatusBad
|
||||
<div className={cn('rounded-full', sizeClasses[size], colors.bg, 'ring-2', colors.border.replace('border', 'ring'))} />
|
||||
{showLabel && (
|
||||
<span className={cn('text-sm font-medium', colors.text)}>
|
||||
{colors.label}
|
||||
{text || colors.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
741
components/task-edit-dialog.tsx
Normal file
741
components/task-edit-dialog.tsx
Normal file
@@ -0,0 +1,741 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Loader2, Calendar, Trash2, Plus, Sparkles, X } from 'lucide-react';
|
||||
import { useUser } from '@/components/user-context';
|
||||
import { toast } from 'sonner';
|
||||
import { formatDateRange } from '@/lib/kr-utils';
|
||||
import type { Task, TaskStep } from '@/types/database';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().min(1, '计划标题不能为空'),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in_progress', 'expected', 'done']),
|
||||
priority: z.number().min(1).max(5),
|
||||
urgency: z.number().min(1).max(5),
|
||||
owner_user_id: z.string().optional(),
|
||||
executor_ids: z.array(z.string()),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
help_requested_to_user_id: z.string().optional(),
|
||||
});
|
||||
|
||||
type TaskFormValues = z.infer<typeof taskSchema>;
|
||||
|
||||
interface TaskEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
krId?: string;
|
||||
projectId: string; // Used for context
|
||||
projectTitle: string;
|
||||
task?: Task & { steps?: TaskStep[] } | null; // If null, creating new plan
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function TaskEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
krId,
|
||||
projectId,
|
||||
projectTitle,
|
||||
task,
|
||||
onSuccess,
|
||||
}: TaskEditDialogProps) {
|
||||
const HELP_NONE_VALUE = '__none__';
|
||||
const { users, currentUser } = useUser();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const [steps, setSteps] = useState<TaskStep[]>([]);
|
||||
const [newStepContent, setNewStepContent] = useState('');
|
||||
const [newStepStartDate, setNewStepStartDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [newStepEndDate, setNewStepEndDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [newStepHelpToUserId, setNewStepHelpToUserId] = useState(HELP_NONE_VALUE);
|
||||
const [isDeleteTaskOpen, setIsDeleteTaskOpen] = useState(false);
|
||||
const [isDeletingTask, setIsDeletingTask] = useState(false);
|
||||
const [deleteStepId, setDeleteStepId] = useState<string | null>(null);
|
||||
const [isDeleteStepOpen, setIsDeleteStepOpen] = useState(false);
|
||||
const [isDeletingStep, setIsDeletingStep] = useState(false);
|
||||
|
||||
// Initialize form
|
||||
const form = useForm<TaskFormValues>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'todo',
|
||||
priority: 3,
|
||||
urgency: 3,
|
||||
owner_user_id: '',
|
||||
executor_ids: [],
|
||||
start_date: new Date().toISOString().split('T')[0],
|
||||
end_date: new Date().toISOString().split('T')[0],
|
||||
help_requested_to_user_id: HELP_NONE_VALUE,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when task changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (task) {
|
||||
form.reset({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status as any,
|
||||
priority: task.priority || 3,
|
||||
urgency: task.urgency || 3,
|
||||
owner_user_id: task.owner_user_id || '',
|
||||
executor_ids: task.executor_ids || [],
|
||||
start_date: task.start_date || '',
|
||||
end_date: task.end_date || '',
|
||||
help_requested_to_user_id: task.help_requested_to_user_id || HELP_NONE_VALUE,
|
||||
});
|
||||
setSteps(task.steps || []);
|
||||
} else {
|
||||
form.reset({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'todo',
|
||||
priority: 3,
|
||||
urgency: 3,
|
||||
owner_user_id: '',
|
||||
executor_ids: [],
|
||||
start_date: new Date().toISOString().split('T')[0],
|
||||
end_date: new Date().toISOString().split('T')[0],
|
||||
help_requested_to_user_id: HELP_NONE_VALUE,
|
||||
});
|
||||
setSteps([]);
|
||||
}
|
||||
setNewStepHelpToUserId(HELP_NONE_VALUE);
|
||||
setIsDeleteTaskOpen(false);
|
||||
setIsDeletingTask(false);
|
||||
setDeleteStepId(null);
|
||||
setIsDeleteStepOpen(false);
|
||||
setIsDeletingStep(false);
|
||||
}
|
||||
}, [open, task, form]);
|
||||
|
||||
const onSubmit = async (values: TaskFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 1. Create or update task
|
||||
const url = task ? `/api/tasks/${task.id}` : '/api/tasks';
|
||||
const method = task ? 'PATCH' : 'POST';
|
||||
|
||||
const taskRes = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kr_id: krId || null,
|
||||
project_id: projectId && projectId !== "" ? projectId : null,
|
||||
title: values.title,
|
||||
description: values.description || null,
|
||||
status: values.status,
|
||||
priority: values.priority,
|
||||
urgency: values.urgency,
|
||||
owner_user_id: values.owner_user_id && values.owner_user_id !== "" ? values.owner_user_id : null,
|
||||
executor_ids: values.executor_ids || [],
|
||||
start_date: values.start_date && values.start_date !== "" ? values.start_date : null,
|
||||
end_date: values.end_date && values.end_date !== "" ? values.end_date : null,
|
||||
help_requested_to_user_id:
|
||||
values.help_requested_to_user_id && values.help_requested_to_user_id !== HELP_NONE_VALUE
|
||||
? values.help_requested_to_user_id
|
||||
: null,
|
||||
help_requested_by_user_id:
|
||||
values.help_requested_to_user_id && values.help_requested_to_user_id !== HELP_NONE_VALUE
|
||||
? (currentUser?.id || null)
|
||||
: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!taskRes.ok) {
|
||||
let errorMessage = '保存计划失败';
|
||||
try {
|
||||
const errorData = await taskRes.json();
|
||||
if (errorData?.error) errorMessage = errorData.error;
|
||||
} catch {
|
||||
const text = await taskRes.text().catch(() => '');
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const savedTask = await taskRes.json();
|
||||
|
||||
// 2. Handle steps (only for new steps added in UI, existing ones are handled individually)
|
||||
// Note: In a real app, we might want to batch this. For now, we'll just save the task.
|
||||
// Steps created during edit are saved immediately in this implementation below.
|
||||
|
||||
// Save any local (temp) steps
|
||||
const localSteps = steps.filter(s => s.id.startsWith('temp-'));
|
||||
if (localSteps.length > 0) {
|
||||
await Promise.all(localSteps.map(step =>
|
||||
fetch('/api/task-steps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
task_id: savedTask.id,
|
||||
content: step.content,
|
||||
start_date: step.start_date,
|
||||
end_date: step.end_date,
|
||||
is_completed: step.is_completed,
|
||||
}),
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
toast.success('计划保存成功');
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTask = async () => {
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
setIsDeletingTask(true);
|
||||
await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
toast.success('计划已删除');
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error('删除失败');
|
||||
} finally {
|
||||
setIsDeletingTask(false);
|
||||
setIsDeleteTaskOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddStep = async () => {
|
||||
if (!newStepContent.trim()) return;
|
||||
|
||||
// If task exists, save step to DB immediately
|
||||
if (task) {
|
||||
try {
|
||||
const res = await fetch('/api/task-steps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
task_id: task.id,
|
||||
content: newStepContent,
|
||||
start_date: newStepStartDate,
|
||||
end_date: newStepEndDate,
|
||||
help_requested_to_user_id: newStepHelpToUserId !== HELP_NONE_VALUE ? newStepHelpToUserId : null,
|
||||
help_requested_by_user_id:
|
||||
newStepHelpToUserId !== HELP_NONE_VALUE ? (currentUser?.id || null) : null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const newStep = await res.json();
|
||||
setSteps([...steps, newStep]);
|
||||
setNewStepContent('');
|
||||
setNewStepHelpToUserId(HELP_NONE_VALUE);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('添加步骤失败');
|
||||
}
|
||||
} else {
|
||||
// If task is new (not saved yet), add to local state with temp ID
|
||||
const tempStep: TaskStep = {
|
||||
id: `temp-${Date.now()}`,
|
||||
task_id: '', // Placeholder
|
||||
content: newStepContent,
|
||||
note: null,
|
||||
executor_id: null,
|
||||
start_date: newStepStartDate,
|
||||
end_date: newStepEndDate,
|
||||
is_completed: false,
|
||||
help_requested_to_user_id: newStepHelpToUserId !== HELP_NONE_VALUE ? newStepHelpToUserId : null,
|
||||
help_requested_by_user_id:
|
||||
newStepHelpToUserId !== HELP_NONE_VALUE ? (currentUser?.id || null) : null,
|
||||
help_requested_at: newStepHelpToUserId !== HELP_NONE_VALUE ? new Date().toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setSteps([...steps, tempStep]);
|
||||
setNewStepContent('');
|
||||
setNewStepHelpToUserId(HELP_NONE_VALUE);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStepComplete = async (step: TaskStep) => {
|
||||
// If it's a local step, just update state
|
||||
if (step.id.startsWith('temp-')) {
|
||||
setSteps(steps.map(s => s.id === step.id ? { ...s, is_completed: !s.is_completed } : s));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/task-steps', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: step.id,
|
||||
is_completed: !step.is_completed,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSteps(steps.map(s => s.id === step.id ? { ...s, is_completed: !s.is_completed } : s));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('更新状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteStepNow = async (stepId: string) => {
|
||||
// If it's a local step, just remove from state
|
||||
if (stepId.startsWith('temp-')) {
|
||||
setSteps(steps.filter(s => s.id !== stepId));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/task-steps?id=${stepId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setSteps(steps.filter(s => s.id !== stepId));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除步骤失败');
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeleteStep = (stepId: string) => {
|
||||
setDeleteStepId(stepId);
|
||||
setIsDeleteStepOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteStep = async () => {
|
||||
if (!deleteStepId) return;
|
||||
try {
|
||||
setIsDeletingStep(true);
|
||||
await deleteStepNow(deleteStepId);
|
||||
} finally {
|
||||
setIsDeletingStep(false);
|
||||
setIsDeleteStepOpen(false);
|
||||
setDeleteStepId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{task ? '编辑计划' : '新建计划'}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1 mt-1">
|
||||
<span className="bg-slate-100 px-1.5 rounded text-xs">项目</span>
|
||||
{projectTitle}
|
||||
</p>
|
||||
</div>
|
||||
{/* Close button is built-in to DialogContent but we can add custom header actions here */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Column: Task Form */}
|
||||
<div className="w-1/2 p-6 border-r overflow-y-auto">
|
||||
<form id="task-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>所属项目/子项目</Label>
|
||||
<div className="mt-1.5 p-2 bg-slate-50 border rounded-md text-sm text-slate-700">
|
||||
└ {projectTitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title">计划标题</Label>
|
||||
<Input
|
||||
id="title"
|
||||
{...form.register('title')}
|
||||
className="mt-1.5"
|
||||
placeholder="输入计划标题"
|
||||
/>
|
||||
{form.formState.errors.title && (
|
||||
<p className="text-red-500 text-xs mt-1">{form.formState.errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">思路</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...form.register('description')}
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
placeholder="描述计划思路或备注..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Label>重要级</Label>
|
||||
<span className="text-xs text-muted-foreground">{form.watch('priority')}/5</span>
|
||||
</div>
|
||||
<Slider
|
||||
defaultValue={[form.watch('priority')]}
|
||||
max={5}
|
||||
step={1}
|
||||
onValueChange={(val) => form.setValue('priority', val[0])}
|
||||
className="[&>.relative>.absolute]:bg-rose-500"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-1 px-1">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
<span>5</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Label>紧急程度</Label>
|
||||
<span className="text-xs text-muted-foreground">{form.watch('urgency')}/5</span>
|
||||
</div>
|
||||
<Slider
|
||||
defaultValue={[form.watch('urgency')]}
|
||||
max={5}
|
||||
step={1}
|
||||
onValueChange={(val) => form.setValue('urgency', val[0])}
|
||||
className="[&>.relative>.absolute]:bg-amber-400"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-1 px-1">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
<span>5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>状态</Label>
|
||||
<Select
|
||||
onValueChange={(val) => form.setValue('status', val as any)}
|
||||
defaultValue={form.watch('status')}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">待办</SelectItem>
|
||||
<SelectItem value="in_progress">进行中</SelectItem>
|
||||
<SelectItem value="expected">预期</SelectItem>
|
||||
<SelectItem value="done">已完成</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>负责人</Label>
|
||||
<Select
|
||||
onValueChange={(val) => form.setValue('owner_user_id', val)}
|
||||
defaultValue={form.watch('owner_user_id')}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue placeholder="选择负责人" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>请求协助</Label>
|
||||
<Select
|
||||
onValueChange={(val) => form.setValue('help_requested_to_user_id', val)}
|
||||
value={form.watch('help_requested_to_user_id') || HELP_NONE_VALUE}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue placeholder="选择需要协助的成员" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={HELP_NONE_VALUE}>不请求协助</SelectItem>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>开始日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...form.register('start_date')}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>截止日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...form.register('end_date')}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block">执行人 (多选)</Label>
|
||||
<ScrollArea className="h-[100px] w-full border rounded-md p-3 bg-slate-50">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{users.map(user => (
|
||||
<div key={user.id} className="flex items-center space-x-2 bg-white px-2 py-1 rounded border">
|
||||
<Checkbox
|
||||
id={`executor-${user.id}`}
|
||||
checked={form.watch('executor_ids').includes(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = form.getValues('executor_ids');
|
||||
if (checked) {
|
||||
form.setValue('executor_ids', [...current, user.id]);
|
||||
} else {
|
||||
form.setValue('executor_ids', current.filter(id => id !== user.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`executor-${user.id}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{user.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Steps */}
|
||||
<div className="w-1/2 p-6 bg-slate-50 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<div className="bg-slate-200 p-1 rounded">
|
||||
<Checkbox checked disabled className="h-4 w-4" />
|
||||
</div>
|
||||
执行步骤
|
||||
</h3>
|
||||
<Button variant="outline" size="sm" className="text-purple-600 border-purple-200 hover:bg-purple-50">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
AI 生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 pr-4 -mr-4 mb-4">
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id || index} className="bg-white p-3 rounded-lg border shadow-sm group">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={step.is_completed}
|
||||
onCheckedChange={() => toggleStepComplete(step)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium text-sm ${step.is_completed ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{step.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{step.start_date || '无日期'}
|
||||
{step.help_requested_to_user_id && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-amber-700 bg-amber-50 border border-amber-200 px-1.5 rounded">
|
||||
请求协助:{users.find(u => u.id === step.help_requested_to_user_id)?.name || '未知'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{step.executor_id && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{users.find(u => u.id === step.executor_id)?.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 text-rose-500 hover:text-rose-600 hover:bg-rose-50"
|
||||
onClick={() => requestDeleteStep(step.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{steps.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无执行步骤,请在下方添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border space-y-3">
|
||||
<Input
|
||||
placeholder="步骤内容"
|
||||
value={newStepContent}
|
||||
onChange={e => setNewStepContent(e.target.value)}
|
||||
className="border-blue-200 focus-visible:ring-blue-500"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="步骤备注(可选)"
|
||||
className="h-20 resize-none"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
type="date"
|
||||
className="text-xs"
|
||||
value={newStepStartDate}
|
||||
onChange={e => setNewStepStartDate(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
className="text-xs"
|
||||
value={newStepEndDate}
|
||||
onChange={e => setNewStepEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">请求协助(可选)</Label>
|
||||
<Select value={newStepHelpToUserId} onValueChange={setNewStepHelpToUserId}>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue placeholder="选择需要协助的成员" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={HELP_NONE_VALUE}>不请求协助</SelectItem>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddStep}
|
||||
className="w-full bg-indigo-50 text-indigo-600 hover:bg-indigo-100 border border-indigo-200"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加步骤
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t flex items-center justify-between bg-white">
|
||||
{task ? (
|
||||
<AlertDialog open={isDeleteTaskOpen} onOpenChange={setIsDeleteTaskOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-rose-500 hover:text-rose-600 hover:bg-rose-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除计划
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除计划?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后无法恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
onClick={handleDeleteTask}
|
||||
disabled={isDeletingTask}
|
||||
>
|
||||
{isDeletingTask ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="task-form"
|
||||
disabled={isSubmitting}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white min-w-[100px]"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
保存更改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isDeleteStepOpen} onOpenChange={setIsDeleteStepOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除步骤?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后无法恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingStep}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
onClick={confirmDeleteStep}
|
||||
disabled={isDeletingStep}
|
||||
>
|
||||
{isDeletingStep ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,11 @@ interface UserContextType {
|
||||
users: User[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
isAuthenticated: boolean;
|
||||
setCurrentUserId: (id: string) => void;
|
||||
logout: () => void;
|
||||
refreshUsers: () => Promise<void>;
|
||||
refreshMe: () => Promise<User | null>;
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
@@ -19,6 +22,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
const refreshUsers = useCallback(async () => {
|
||||
try {
|
||||
@@ -34,43 +38,82 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
const refreshMe = useCallback(async () => {
|
||||
try {
|
||||
const meRes = await fetch('/api/auth/me', { cache: 'no-store' });
|
||||
const meData = await meRes.json().catch(() => ({}));
|
||||
const me = meData?.user as User | null | undefined;
|
||||
|
||||
if (me && me.id) {
|
||||
setCurrentUser(me);
|
||||
setIsAuthenticated(true);
|
||||
localStorage.setItem('okrAuthUserId', me.id);
|
||||
localStorage.setItem('currentUserId', me.id);
|
||||
await refreshUsers();
|
||||
return me;
|
||||
}
|
||||
|
||||
localStorage.removeItem('okrAuthUserId');
|
||||
localStorage.removeItem('currentUserId');
|
||||
setCurrentUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setUsers([]);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh auth:', error);
|
||||
localStorage.removeItem('okrAuthUserId');
|
||||
localStorage.removeItem('currentUserId');
|
||||
setCurrentUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setUsers([]);
|
||||
return null;
|
||||
}
|
||||
}, [refreshUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
setIsLoading(true);
|
||||
const fetchedUsers = await refreshUsers();
|
||||
|
||||
if (fetchedUsers.length > 0) {
|
||||
const storedId = localStorage.getItem('currentUserId');
|
||||
const foundUser = storedId
|
||||
? fetchedUsers.find((u: User) => u.id === storedId)
|
||||
: null;
|
||||
|
||||
if (foundUser) {
|
||||
setCurrentUser(foundUser);
|
||||
} else {
|
||||
const owner = fetchedUsers.find((u: User) => u.role === 'owner') || fetchedUsers[0];
|
||||
setCurrentUser(owner);
|
||||
localStorage.setItem('currentUserId', owner.id);
|
||||
}
|
||||
try {
|
||||
await refreshMe();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
localStorage.removeItem('okrAuthUserId');
|
||||
localStorage.removeItem('currentUserId');
|
||||
setCurrentUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
}, [refreshUsers]);
|
||||
}, [refreshMe]);
|
||||
|
||||
const setCurrentUserId = useCallback((id: string) => {
|
||||
const authUserId = localStorage.getItem('okrAuthUserId');
|
||||
if (!authUserId) return;
|
||||
if (authUserId !== id) return;
|
||||
const user = users.find(u => u.id === id);
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
localStorage.setItem('currentUserId', id);
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}, [users]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('okrAuthUserId');
|
||||
localStorage.removeItem('currentUserId');
|
||||
setCurrentUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setUsers([]);
|
||||
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ currentUser, users, isLoading, isInitialized, setCurrentUserId, refreshUsers }}>
|
||||
<UserContext.Provider value={{ currentUser, users, isLoading, isInitialized, isAuthenticated, setCurrentUserId, logout, refreshUsers, refreshMe }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
73
lib/access-control.ts
Normal file
73
lib/access-control.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { User } from '@/types/database';
|
||||
|
||||
export type AuthContext = {
|
||||
user: User;
|
||||
isAdmin: boolean;
|
||||
accessibleProjectIds: string[];
|
||||
};
|
||||
|
||||
export async function getAuthContext(request: NextRequest): Promise<AuthContext | null> {
|
||||
const userId = request.cookies.get('okr_user_id')?.value;
|
||||
if (!userId) return null;
|
||||
|
||||
const { data: user, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (userError || !user) return null;
|
||||
|
||||
const isAdmin = user.role === 'owner';
|
||||
const accessibleProjectIds = await getAccessibleProjectIds(user.id, isAdmin);
|
||||
return { user, isAdmin, accessibleProjectIds };
|
||||
}
|
||||
|
||||
export async function getAccessibleProjectIds(userId: string, isAdmin: boolean) {
|
||||
if (isAdmin) return [];
|
||||
|
||||
const [projectsRes, memberRes] = await Promise.all([
|
||||
supabase
|
||||
.from('projects')
|
||||
.select('id')
|
||||
.or(`owner_id.eq.${userId},pm_id.eq.${userId}`),
|
||||
supabase
|
||||
.from('project_members')
|
||||
.select('project_id')
|
||||
.eq('user_id', userId),
|
||||
]);
|
||||
|
||||
const ids = new Set<string>();
|
||||
if (projectsRes.data) {
|
||||
for (const row of projectsRes.data as Array<{ id: string }>) ids.add(row.id);
|
||||
}
|
||||
if (memberRes.data) {
|
||||
for (const row of memberRes.data as Array<{ project_id: string }>) ids.add(row.project_id);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export function ensureAuthenticated(ctx: AuthContext | null) {
|
||||
if (!ctx) {
|
||||
return { ok: false as const, status: 401, error: '未登录' };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export function ensureAdmin(ctx: AuthContext | null) {
|
||||
if (!ctx) {
|
||||
return { ok: false as const, status: 401, error: '未登录' };
|
||||
}
|
||||
if (!ctx.isAdmin) {
|
||||
return { ok: false as const, status: 403, error: '无权限' };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export function hasProjectAccess(ctx: AuthContext, projectId: string | null | undefined) {
|
||||
if (ctx.isAdmin) return true;
|
||||
if (!projectId) return false;
|
||||
return ctx.accessibleProjectIds.includes(projectId);
|
||||
}
|
||||
25
lib/password.ts
Normal file
25
lib/password.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const iterations = 120000;
|
||||
const keylen = 32;
|
||||
const digest = 'sha256';
|
||||
const derivedKey = crypto.pbkdf2Sync(password, salt, iterations, keylen, digest).toString('hex');
|
||||
return `${iterations}:${salt}:${derivedKey}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string) {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
const iterations = Number(parts[0]);
|
||||
const salt = parts[1];
|
||||
const expectedHex = parts[2];
|
||||
if (!Number.isFinite(iterations) || iterations <= 0) return false;
|
||||
const keylen = Buffer.from(expectedHex, 'hex').length;
|
||||
if (keylen <= 0) return false;
|
||||
const actual = crypto.pbkdf2Sync(password, salt, iterations, keylen, 'sha256');
|
||||
const expected = Buffer.from(expectedHex, 'hex');
|
||||
if (actual.length !== expected.length) return false;
|
||||
return crypto.timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone', // 输出独立的目录
|
||||
basePath: '/ok-r', // 部署到子路径时需要设置
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
images: { unoptimized: true },
|
||||
webpack: (config, { dev }) => {
|
||||
if (dev) {
|
||||
config.cache = false;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
5746
pnpm-lock.yaml
generated
Normal file
5746
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
supabase/migrations/20260126153000_create_tasks_tables.sql
Normal file
68
supabase/migrations/20260126153000_create_tasks_tables.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
|
||||
-- Create tasks table
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
kr_id UUID NOT NULL REFERENCES krs(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT, -- 思路
|
||||
status TEXT NOT NULL DEFAULT 'todo', -- todo, in_progress, expected, done
|
||||
priority INTEGER DEFAULT 50, -- 重要级 0-100
|
||||
urgency INTEGER DEFAULT 50, -- 紧急程度 0-100
|
||||
owner_user_id UUID REFERENCES users(id), -- 负责人
|
||||
executor_ids UUID[] DEFAULT '{}', -- 执行人列表
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
-- Create task_steps table
|
||||
CREATE TABLE IF NOT EXISTS task_steps (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
note TEXT,
|
||||
executor_id UUID REFERENCES users(id),
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
is_completed BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
-- Add indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_kr_id ON tasks(kr_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_steps_task_id ON task_steps(task_id);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE task_steps ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for tasks
|
||||
CREATE POLICY "Users can view tasks of visible KRs" ON tasks
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM krs
|
||||
WHERE krs.id = tasks.kr_id
|
||||
AND (
|
||||
-- KR visibility logic (simplified for now: if you can see the project, you can see the KR)
|
||||
EXISTS (
|
||||
SELECT 1 FROM projects WHERE projects.id = krs.project_id
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can insert tasks" ON tasks
|
||||
FOR INSERT WITH CHECK (true); -- Allow all authenticated users to create tasks for now
|
||||
|
||||
CREATE POLICY "Users can update tasks" ON tasks
|
||||
FOR UPDATE USING (true);
|
||||
|
||||
CREATE POLICY "Users can delete tasks" ON tasks
|
||||
FOR DELETE USING (true);
|
||||
|
||||
-- RLS Policies for task_steps
|
||||
CREATE POLICY "Users can view steps" ON task_steps FOR SELECT USING (true);
|
||||
CREATE POLICY "Users can insert steps" ON task_steps FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Users can update steps" ON task_steps FOR UPDATE USING (true);
|
||||
CREATE POLICY "Users can delete steps" ON task_steps FOR DELETE USING (true);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add project_id column to tasks table and make kr_id nullable
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
-- Make kr_id nullable since tasks can now belong directly to a project
|
||||
ALTER TABLE tasks
|
||||
ALTER COLUMN kr_id DROP NOT NULL;
|
||||
|
||||
-- Create index for project_id for better query performance
|
||||
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
|
||||
56
supabase/migrations/add_help_request_fields.sql
Normal file
56
supabase/migrations/add_help_request_fields.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
alter table public.tasks
|
||||
add column if not exists help_requested_to_user_id uuid null,
|
||||
add column if not exists help_requested_by_user_id uuid null,
|
||||
add column if not exists help_requested_at timestamptz null;
|
||||
|
||||
alter table public.task_steps
|
||||
add column if not exists help_requested_to_user_id uuid null,
|
||||
add column if not exists help_requested_by_user_id uuid null,
|
||||
add column if not exists help_requested_at timestamptz null;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'tasks_help_requested_to_user_id_fkey'
|
||||
) then
|
||||
alter table public.tasks
|
||||
add constraint tasks_help_requested_to_user_id_fkey
|
||||
foreign key (help_requested_to_user_id) references public.users(id);
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'tasks_help_requested_by_user_id_fkey'
|
||||
) then
|
||||
alter table public.tasks
|
||||
add constraint tasks_help_requested_by_user_id_fkey
|
||||
foreign key (help_requested_by_user_id) references public.users(id);
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'task_steps_help_requested_to_user_id_fkey'
|
||||
) then
|
||||
alter table public.task_steps
|
||||
add constraint task_steps_help_requested_to_user_id_fkey
|
||||
foreign key (help_requested_to_user_id) references public.users(id);
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'task_steps_help_requested_by_user_id_fkey'
|
||||
) then
|
||||
alter table public.task_steps
|
||||
add constraint task_steps_help_requested_by_user_id_fkey
|
||||
foreign key (help_requested_by_user_id) references public.users(id);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create index if not exists tasks_help_requested_to_user_id_idx
|
||||
on public.tasks(help_requested_to_user_id);
|
||||
|
||||
create index if not exists tasks_help_requested_by_user_id_idx
|
||||
on public.tasks(help_requested_by_user_id);
|
||||
|
||||
create index if not exists task_steps_help_requested_to_user_id_idx
|
||||
on public.task_steps(help_requested_to_user_id);
|
||||
|
||||
create index if not exists task_steps_help_requested_by_user_id_idx
|
||||
on public.task_steps(help_requested_by_user_id);
|
||||
15
supabase/migrations/add_user_login_fields.sql
Normal file
15
supabase/migrations/add_user_login_fields.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
alter table public.users
|
||||
add column if not exists username text null,
|
||||
add column if not exists password_hash text null;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_indexes
|
||||
where schemaname = 'public'
|
||||
and indexname = 'users_username_unique'
|
||||
) then
|
||||
create unique index users_username_unique on public.users (username) where username is not null;
|
||||
end if;
|
||||
end $$;
|
||||
2
supabase/migrations/disable_rls_tasks_and_steps.sql
Normal file
2
supabase/migrations/disable_rls_tasks_and_steps.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
alter table public.tasks disable row level security;
|
||||
alter table public.task_steps disable row level security;
|
||||
@@ -9,6 +9,7 @@ export interface User {
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar_url: string | null;
|
||||
username: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -126,6 +127,41 @@ export interface Template {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
kr_id: string | null;
|
||||
project_id: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: 'todo' | 'in_progress' | 'expected' | 'done';
|
||||
priority: number;
|
||||
urgency: number;
|
||||
owner_user_id: string | null;
|
||||
executor_ids: string[];
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
help_requested_to_user_id: string | null;
|
||||
help_requested_by_user_id: string | null;
|
||||
help_requested_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskStep {
|
||||
id: string;
|
||||
task_id: string;
|
||||
content: string;
|
||||
note: string | null;
|
||||
executor_id: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
is_completed: boolean;
|
||||
help_requested_to_user_id: string | null;
|
||||
help_requested_by_user_id: string | null;
|
||||
help_requested_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
@@ -174,6 +210,16 @@ export interface Database {
|
||||
Insert: Omit<Template, 'id' | 'created_at' | 'updated_at'> & { id?: string };
|
||||
Update: Partial<Omit<Template, 'id' | 'created_at'>>;
|
||||
};
|
||||
tasks: {
|
||||
Row: Task;
|
||||
Insert: Omit<Task, 'id' | 'created_at' | 'updated_at'> & { id?: string };
|
||||
Update: Partial<Omit<Task, 'id' | 'created_at'>>;
|
||||
};
|
||||
task_steps: {
|
||||
Row: TaskStep;
|
||||
Insert: Omit<TaskStep, 'id' | 'created_at'> & { id?: string };
|
||||
Update: Partial<Omit<TaskStep, 'id' | 'created_at'>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user