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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
.trae
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ import type { Metric } from '@/types/database';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Plus, Pencil, Trash2, Gauge, Loader2, TrendingUp, TrendingDown } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Gauge, Loader2, TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
import { getErrorMessage } from '@/lib/error-utils';
|
import { getErrorMessage } from '@/lib/error-utils';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
const metricSchema = z.object({
|
const metricSchema = z.object({
|
||||||
name: z.string().min(1, '名称不能为空'),
|
name: z.string().min(1, '名称不能为空'),
|
||||||
@@ -35,6 +45,9 @@ export default function AdminMetricsPage() {
|
|||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingMetric, setEditingMetric] = useState<Metric | null>(null);
|
const [editingMetric, setEditingMetric] = useState<Metric | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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>({
|
const form = useForm<MetricFormValues>({
|
||||||
resolver: zodResolver(metricSchema),
|
resolver: zodResolver(metricSchema),
|
||||||
@@ -121,17 +134,26 @@ export default function AdminMetricsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (metricId: string) => {
|
const requestDelete = (metricId: string) => {
|
||||||
if (!confirm('确定要删除此指标吗?')) return;
|
setDeleteMetricId(metricId);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteMetricId) return;
|
||||||
|
setIsDeleting(true);
|
||||||
try {
|
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('删除失败');
|
if (!res.ok) throw new Error('删除失败');
|
||||||
|
|
||||||
toast.success('指标已删除');
|
toast.success('指标已删除');
|
||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(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)}>
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(metric)}>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -327,6 +349,27 @@ export default function AdminMetricsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ import type { Template } from '@/types/database';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Plus, Pencil, Trash2, Files, Loader2, X } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Files, Loader2, X } from 'lucide-react';
|
||||||
import { getErrorMessage } from '@/lib/error-utils';
|
import { getErrorMessage } from '@/lib/error-utils';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
const templateItemSchema = z.object({
|
const templateItemSchema = z.object({
|
||||||
title: z.string().min(1, '标题不能为空'),
|
title: z.string().min(1, '标题不能为空'),
|
||||||
@@ -44,6 +54,9 @@ export default function AdminTemplatesPage() {
|
|||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
|
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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>({
|
const form = useForm<TemplateFormValues>({
|
||||||
resolver: zodResolver(templateSchema),
|
resolver: zodResolver(templateSchema),
|
||||||
@@ -135,17 +148,26 @@ export default function AdminTemplatesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (templateId: string) => {
|
const requestDelete = (templateId: string) => {
|
||||||
if (!confirm('确定要删除此模板吗?')) return;
|
setDeleteTemplateId(templateId);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTemplateId) return;
|
||||||
|
setIsDeleting(true);
|
||||||
try {
|
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('删除失败');
|
if (!res.ok) throw new Error('删除失败');
|
||||||
|
|
||||||
toast.success('模板已删除');
|
toast.success('模板已删除');
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(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)}>
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(template)}>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,6 +453,27 @@ export default function AdminTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,22 @@ import type { User } from '@/types/database';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getErrorMessage } from '@/lib/error-utils';
|
import { getErrorMessage } from '@/lib/error-utils';
|
||||||
import { Plus, Pencil, Trash2, Users, Loader2, Crown, Briefcase, User as UserIcon } from 'lucide-react';
|
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({
|
const userSchema = z.object({
|
||||||
name: z.string().min(1, '名称不能为空'),
|
name: z.string().min(1, '名称不能为空'),
|
||||||
role: z.enum(['owner', 'pm', 'member']),
|
role: z.enum(['owner', 'pm', 'member']),
|
||||||
|
username: z.string().min(1, '账号不能为空'),
|
||||||
|
password: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type UserFormValues = z.infer<typeof userSchema>;
|
type UserFormValues = z.infer<typeof userSchema>;
|
||||||
@@ -38,25 +50,41 @@ export default function AdminUsersPage() {
|
|||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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>({
|
const form = useForm<UserFormValues>({
|
||||||
resolver: zodResolver(userSchema),
|
resolver: zodResolver(userSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
role: 'member',
|
role: 'member',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: UserFormValues) => {
|
const onSubmit = async (values: UserFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
if (!editingUser && !values.password) {
|
||||||
|
throw new Error('请设置初始密码');
|
||||||
|
}
|
||||||
|
|
||||||
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users';
|
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users';
|
||||||
const method = editingUser ? 'PATCH' : 'POST';
|
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, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -81,21 +109,33 @@ export default function AdminUsersPage() {
|
|||||||
form.reset({
|
form.reset({
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
username: user.username || '',
|
||||||
|
password: '',
|
||||||
});
|
});
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (userId: string) => {
|
const requestDelete = (userId: string) => {
|
||||||
if (!confirm('确定要删除此用户吗?')) return;
|
setDeleteUserId(userId);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteUserId) return;
|
||||||
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/users/${userId}`, { method: 'DELETE' });
|
const res = await fetch(`/api/users/${deleteUserId}`, { method: 'DELETE' });
|
||||||
if (!res.ok) throw new Error('删除失败');
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data?.error || '删除失败');
|
||||||
|
|
||||||
toast.success('用户已删除');
|
toast.success('用户已删除');
|
||||||
refreshUsers();
|
refreshUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="role"
|
name="role"
|
||||||
@@ -197,6 +265,7 @@ export default function AdminUsersPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>用户</TableHead>
|
<TableHead>用户</TableHead>
|
||||||
|
<TableHead>账号</TableHead>
|
||||||
<TableHead>角色</TableHead>
|
<TableHead>角色</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
<TableHead>创建时间</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
@@ -217,6 +286,9 @@ export default function AdminUsersPage() {
|
|||||||
<span className="font-medium">{user.name}</span>
|
<span className="font-medium">{user.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{user.username || '-'}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RoleIcon className="h-4 w-4 text-muted-foreground" />
|
<RoleIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -233,8 +305,8 @@ export default function AdminUsersPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(user.id)}
|
onClick={() => requestDelete(user.id)}
|
||||||
disabled={user.id === currentUser?.id}
|
disabled={user.role === 'owner'}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -246,6 +318,27 @@ export default function AdminUsersPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</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 { supabase } from '@/lib/supabase';
|
||||||
import { determineKRStatus, determineProjectStatus } from '@/lib/kr-utils';
|
import { determineKRStatus, determineProjectStatus } from '@/lib/kr-utils';
|
||||||
import type { KR, Checkin } from '@/types/database';
|
import type { KR, Checkin } from '@/types/database';
|
||||||
|
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const krId = searchParams.get('kr_id');
|
const krId = searchParams.get('kr_id');
|
||||||
const projectId = searchParams.get('project_id');
|
const projectId = searchParams.get('project_id');
|
||||||
@@ -21,6 +28,34 @@ export async function GET(request: NextRequest) {
|
|||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false });
|
.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 (krId) query = query.eq('kr_id', krId);
|
||||||
if (projectId) query = query.eq('project_id', projectId);
|
if (projectId) query = query.eq('project_id', projectId);
|
||||||
if (userId) query = query.eq('user_id', userId);
|
if (userId) query = query.eq('user_id', userId);
|
||||||
@@ -36,6 +71,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(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 body = await request.json();
|
||||||
const { kr_id, project_id, user_id, date, current_value, confidence, reason_code, note } = body;
|
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;
|
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
|
const { data: recentCheckins } = await supabase
|
||||||
.from('checkins')
|
.from('checkins')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -91,7 +145,7 @@ export async function POST(request: NextRequest) {
|
|||||||
.insert({
|
.insert({
|
||||||
kr_id,
|
kr_id,
|
||||||
project_id,
|
project_id,
|
||||||
user_id,
|
user_id: ctx!.isAdmin ? user_id : ctx!.user.id,
|
||||||
date: checkinDate,
|
date: checkinDate,
|
||||||
current_value,
|
current_value,
|
||||||
delta_value,
|
delta_value,
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { data, error } = await supabase
|
||||||
.from('krs')
|
.from('krs')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -26,6 +33,10 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
|
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);
|
return NextResponse.json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +44,12 @@ export async function PATCH(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 body = await request.json();
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
@@ -96,6 +113,12 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { error } = await supabase
|
||||||
.from('krs')
|
.from('krs')
|
||||||
.delete()
|
.delete()
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const projectId = searchParams.get('project_id');
|
const projectId = searchParams.get('project_id');
|
||||||
const objectiveId = searchParams.get('objective_id');
|
const objectiveId = searchParams.get('objective_id');
|
||||||
@@ -19,6 +26,19 @@ export async function GET(request: NextRequest) {
|
|||||||
`)
|
`)
|
||||||
.order('period_start', { ascending: false });
|
.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 (projectId) query = query.eq('project_id', projectId);
|
||||||
if (objectiveId) query = query.eq('objective_id', objectiveId);
|
if (objectiveId) query = query.eq('objective_id', objectiveId);
|
||||||
if (ownerUserId) query = query.eq('owner_user_id', ownerUserId);
|
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) {
|
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 body = await request.json();
|
||||||
const {
|
const {
|
||||||
project_id,
|
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) {
|
if (target_value <= 0) {
|
||||||
return NextResponse.json({ error: '目标值必须大于0' }, { status: 400 });
|
return NextResponse.json({ error: '目标值必须大于0' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { data, error } = await supabase
|
||||||
.from('meetings')
|
.from('meetings')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -23,6 +30,10 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
|
return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
|
||||||
|
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +41,25 @@ export async function PATCH(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 body = await request.json();
|
||||||
const { summary, decisions, risks, next_period_commitments } = body;
|
const { summary, decisions, risks, next_period_commitments } = body;
|
||||||
|
|
||||||
@@ -57,6 +87,25 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { error } = await supabase
|
||||||
.from('meetings')
|
.from('meetings')
|
||||||
.delete()
|
.delete()
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const projectId = searchParams.get('project_id');
|
const projectId = searchParams.get('project_id');
|
||||||
|
|
||||||
@@ -14,6 +21,19 @@ export async function GET(request: NextRequest) {
|
|||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false });
|
.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);
|
if (projectId) query = query.eq('project_id', projectId);
|
||||||
|
|
||||||
const { data, error } = await query;
|
const { data, error } = await query;
|
||||||
@@ -26,6 +46,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(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 body = await request.json();
|
||||||
const { project_id, period_start, period_end, summary, decisions, risks, next_period_commitments, created_by } = body;
|
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
|
const { data, error } = await supabase
|
||||||
.from('meetings')
|
.from('meetings')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -46,7 +76,7 @@ export async function POST(request: NextRequest) {
|
|||||||
decisions,
|
decisions,
|
||||||
risks,
|
risks,
|
||||||
next_period_commitments,
|
next_period_commitments,
|
||||||
created_by,
|
created_by: ctx!.isAdmin ? created_by : ctx!.user.id,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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')
|
.from('metrics')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', params.id)
|
.eq('id', params.id);
|
||||||
.maybeSingle();
|
|
||||||
|
if (!ctx!.isAdmin) query = query.eq('is_active', true);
|
||||||
|
|
||||||
|
const { data, error } = await query.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
@@ -26,6 +36,12 @@ export async function PATCH(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 body = await request.json();
|
||||||
const { name, unit, direction, category, is_active } = body;
|
const { name, unit, direction, category, is_active } = body;
|
||||||
|
|
||||||
@@ -54,6 +70,12 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { error } = await supabase
|
||||||
.from('metrics')
|
.from('metrics')
|
||||||
.delete()
|
.delete()
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const activeOnly = searchParams.get('active_only') === 'true';
|
const activeOnly = searchParams.get('active_only') === 'true';
|
||||||
const category = searchParams.get('category');
|
const category = searchParams.get('category');
|
||||||
|
|
||||||
let query = supabase.from('metrics').select('*').order('name', { ascending: true });
|
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);
|
if (category) query = query.eq('category', category);
|
||||||
|
|
||||||
const { data, error } = await query;
|
const { data, error } = await query;
|
||||||
@@ -21,6 +28,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(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 body = await request.json();
|
||||||
const { name, unit, direction, category } = body;
|
const { name, unit, direction, category } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { data, error } = await supabase
|
||||||
.from('objectives')
|
.from('objectives')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -21,6 +28,10 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: '目标不存在' }, { status: 404 });
|
return NextResponse.json({ error: '目标不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
|
||||||
|
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +39,12 @@ export async function PATCH(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 body = await request.json();
|
||||||
const { title, description, period_start, period_end } = body;
|
const { title, description, period_start, period_end } = body;
|
||||||
|
|
||||||
@@ -55,6 +72,12 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { error } = await supabase
|
||||||
.from('objectives')
|
.from('objectives')
|
||||||
.delete()
|
.delete()
|
||||||
|
|||||||
@@ -2,17 +2,35 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const projectId = searchParams.get('project_id');
|
const projectId = searchParams.get('project_id');
|
||||||
|
|
||||||
let query = supabase.from('objectives').select('*').order('period_start', { ascending: false });
|
let query = supabase.from('objectives').select('*').order('period_start', { ascending: false });
|
||||||
|
|
||||||
if (projectId) {
|
if (!ctx!.isAdmin) {
|
||||||
query = query.eq('project_id', projectId);
|
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;
|
const { data, error } = await query;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -23,6 +41,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(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 body = await request.json();
|
||||||
const { project_id, title, description, period_start, period_end, created_by } = body;
|
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 });
|
return NextResponse.json({ error: '结束日期不能早于开始日期' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, project_id)) {
|
||||||
|
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('objectives')
|
.from('objectives')
|
||||||
.insert({ project_id, title, description, period_start, period_end, created_by })
|
.insert({ project_id, title, description, period_start, period_end, created_by })
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { data, error } = await supabase
|
||||||
.from('project_members')
|
.from('project_members')
|
||||||
.select('user_id, users(id, name, role, avatar_url)')
|
.select('user_id, users(id, name, role, avatar_url)')
|
||||||
@@ -21,6 +31,12 @@ export async function POST(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 body = await request.json();
|
||||||
const { user_id } = body;
|
const { user_id } = body;
|
||||||
|
|
||||||
@@ -45,6 +61,12 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 { searchParams } = new URL(request.url);
|
||||||
const userId = searchParams.get('user_id');
|
const userId = searchParams.get('user_id');
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,21 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { data, error } = await supabase
|
||||||
.from('projects')
|
.from('projects')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -37,6 +47,12 @@ export async function PATCH(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 body = await request.json();
|
||||||
const { name, description, owner_id, pm_id, project_type, status, member_ids } = body;
|
const { name, description, owner_id, pm_id, project_type, status, member_ids } = body;
|
||||||
|
|
||||||
@@ -78,6 +94,12 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { error } = await supabase
|
||||||
.from('projects')
|
.from('projects')
|
||||||
.delete()
|
.delete()
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { getAuthContext } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
const { data, error } = await supabase
|
const ctx = await getAuthContext(request);
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
.from('projects')
|
.from('projects')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
@@ -13,6 +19,15 @@ export async function GET() {
|
|||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false });
|
.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) {
|
if (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -21,6 +36,14 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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 body = await request.json();
|
||||||
const { name, description, owner_id, pm_id, project_type, member_ids } = body;
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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')
|
.from('templates')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', params.id)
|
.eq('id', params.id);
|
||||||
.maybeSingle();
|
|
||||||
|
if (!ctx!.isAdmin) query = query.eq('is_active', true);
|
||||||
|
|
||||||
|
const { data, error } = await query.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
@@ -26,6 +36,12 @@ export async function PATCH(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 body = await request.json();
|
||||||
const { name, description, project_type, tags, items, is_active } = body;
|
const { name, description, project_type, tags, items, is_active } = body;
|
||||||
|
|
||||||
@@ -55,6 +71,12 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { error } = await supabase
|
||||||
.from('templates')
|
.from('templates')
|
||||||
.delete()
|
.delete()
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const activeOnly = searchParams.get('active_only') === 'true';
|
const activeOnly = searchParams.get('active_only') === 'true';
|
||||||
const projectType = searchParams.get('project_type');
|
const projectType = searchParams.get('project_type');
|
||||||
|
|
||||||
let query = supabase.from('templates').select('*').order('name', { ascending: true });
|
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);
|
if (projectType) query = query.eq('project_type', projectType);
|
||||||
|
|
||||||
const { data, error } = await query;
|
const { data, error } = await query;
|
||||||
@@ -21,6 +28,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(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 body = await request.json();
|
||||||
const { name, description, project_type, tags, items } = body;
|
const { name, description, project_type, tags, items } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { data, error } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('*')
|
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||||
.eq('id', params.id)
|
.eq('id', params.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -26,14 +36,30 @@ export async function PATCH(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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 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
|
const { data, error } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.update({ name, role, avatar_url, updated_at: new Date().toISOString() })
|
.update(updateData)
|
||||||
.eq('id', params.id)
|
.eq('id', params.id)
|
||||||
.select()
|
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -47,6 +73,30 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ 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
|
const { error } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.delete()
|
.delete()
|
||||||
|
|||||||
@@ -2,11 +2,54 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { supabase } from '@/lib/supabase';
|
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('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
|
const { data, error } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('*')
|
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||||
|
.in('id', ids)
|
||||||
.order('created_at', { ascending: true });
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -17,17 +60,35 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(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 body = await request.json();
|
||||||
const { name, role, avatar_url } = body;
|
const { name, role, avatar_url, username, password } = body;
|
||||||
|
|
||||||
if (!name || !role) {
|
if (!name || !role) {
|
||||||
return NextResponse.json({ error: '名称和角色为必填项' }, { status: 400 });
|
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
|
const { data, error } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.insert({ name, role, avatar_url })
|
.insert({
|
||||||
.select()
|
name,
|
||||||
|
role,
|
||||||
|
avatar_url,
|
||||||
|
username: username || null,
|
||||||
|
password_hash,
|
||||||
|
})
|
||||||
|
.select('id,name,role,avatar_url,username,created_at,updated_at')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ import {
|
|||||||
isKRActive,
|
isKRActive,
|
||||||
} from '@/lib/kr-utils';
|
} from '@/lib/kr-utils';
|
||||||
import { REASON_CODES, PERIOD_TYPES, DIRECTION_LABELS } from '@/lib/constants';
|
import { REASON_CODES, PERIOD_TYPES, DIRECTION_LABELS } from '@/lib/constants';
|
||||||
import type { KR, User, Checkin, Objective } from '@/types/database';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { TaskEditDialog } from '@/components/task-edit-dialog';
|
||||||
|
import type { KR, User, Checkin, Objective, Task, TaskStep } from '@/types/database';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -43,7 +44,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
CheckSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { getErrorMessage } from '@/lib/error-utils';
|
import { getErrorMessage } from '@/lib/error-utils';
|
||||||
|
|
||||||
interface KRWithRelations extends KR {
|
interface KRWithRelations extends KR {
|
||||||
@@ -56,6 +59,8 @@ interface CheckinWithRelations extends Checkin {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
|
||||||
const checkinSchema = z.object({
|
const checkinSchema = z.object({
|
||||||
current_value: z.number(),
|
current_value: z.number(),
|
||||||
confidence: z.number().min(0).max(100),
|
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 [kr, setKR] = useState<KRWithRelations | null>(null);
|
||||||
const [checkins, setCheckins] = useState<CheckinWithRelations[]>([]);
|
const [checkins, setCheckins] = useState<CheckinWithRelations[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<(Task & { steps: TaskStep[] })[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [isCheckinOpen, setIsCheckinOpen] = useState(false);
|
const [isCheckinOpen, setIsCheckinOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = 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 () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [krRes, checkinsRes] = await Promise.all([
|
const [krRes, checkinsRes, tasksRes] = await Promise.all([
|
||||||
fetch(`/api/krs/${krId}`),
|
fetch(`/api/krs/${krId}`),
|
||||||
fetch(`/api/checkins?kr_id=${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) {
|
if (krRes.ok) {
|
||||||
const krData = await krRes.json();
|
const krData = await krRes.json();
|
||||||
setKR(krData);
|
setKR(krData);
|
||||||
|
setLoadError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkinsRes.ok) {
|
if (checkinsRes.ok) {
|
||||||
const checkinsData = await checkinsRes.json();
|
const checkinsData = await checkinsRes.json();
|
||||||
setCheckins(checkinsData);
|
setCheckins(checkinsData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tasksRes.ok) {
|
||||||
|
const tasksData = await tasksRes.json();
|
||||||
|
setTasks(tasksData);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch KR data:', error);
|
console.error('Failed to fetch KR data:', error);
|
||||||
|
setLoadError('加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -248,7 +276,7 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
|||||||
if (!kr) {
|
if (!kr) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
|
<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">
|
<Button asChild className="mt-4" variant="outline">
|
||||||
<Link href="/my-krs">返回我的 KR</Link>
|
<Link href="/my-krs">返回我的 KR</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -263,6 +291,24 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
|||||||
const isOwner = currentUser?.id === kr.owner_user_id;
|
const isOwner = currentUser?.id === kr.owner_user_id;
|
||||||
const TrendIcon = kr.direction === 'increase' ? TrendingUp : TrendingDown;
|
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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -400,18 +446,37 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
|||||||
name="current_value"
|
name="current_value"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>当前值 ({kr.unit})</FormLabel>
|
<FormLabel>当前状态</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
{isBooleanMetric ? (
|
||||||
type="number"
|
<RadioGroup
|
||||||
step="any"
|
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||||
{...field}
|
defaultValue={field.value.toString()}
|
||||||
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
|
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)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
目标值: {kr.target_value} {kr.unit}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
目标值: {kr.target_value} {kr.unit}
|
|
||||||
</p>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -508,9 +573,12 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
|||||||
<ProgressRing progress={progress} status={kr.status} size={100} strokeWidth={8} />
|
<ProgressRing progress={progress} status={kr.status} size={100} strokeWidth={8} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-3xl font-bold text-slate-800">
|
<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>
|
||||||
<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">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<TrendIcon className="h-4 w-4 text-muted-foreground" />
|
<TrendIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -545,6 +613,93 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">打卡记录</CardTitle>
|
<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-1 pb-4">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{checkin.current_value} {kr.unit}
|
{isBooleanMetric
|
||||||
|
? (checkin.current_value >= 1 ? '已完成' : '待完成')
|
||||||
|
: `${checkin.current_value} ${kr.unit}`
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{new Date(checkin.created_at).toLocaleString('zh-CN', {
|
{new Date(checkin.created_at).toLocaleString('zh-CN', {
|
||||||
@@ -646,6 +804,15 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TaskEditDialog
|
||||||
|
open={isTaskDialogOpen}
|
||||||
|
onOpenChange={setIsTaskDialogOpen}
|
||||||
|
krId={krId}
|
||||||
|
projectId={kr.project_id}
|
||||||
|
projectTitle={kr.project?.name || ''}
|
||||||
|
task={editingTask}
|
||||||
|
onSuccess={fetchData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Toaster } from '@/components/ui/sonner';
|
|||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'OKR 执行系统 - 目标管理',
|
title: '兜爸目标管理',
|
||||||
description: '让目标被持续看见、被拆解、被跟进、被纠偏、被完成',
|
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() {
|
export default function MyKRsPage() {
|
||||||
const { currentUser } = useUser();
|
const { currentUser, users } = useUser();
|
||||||
const [krs, setKRs] = useState<KRWithRelations[]>([]);
|
const [krs, setKRs] = useState<KRWithRelations[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
async function fetchKRs() {
|
async function fetchKRs() {
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +58,8 @@ export default function MyKRsPage() {
|
|||||||
const completedKRs = activeKRs.filter(kr => calculateProgress(kr) >= 1);
|
const completedKRs = activeKRs.filter(kr => calculateProgress(kr) >= 1);
|
||||||
const pastKRs = myKRs.filter(kr => !isKRActive(kr));
|
const pastKRs = myKRs.filter(kr => !isKRActive(kr));
|
||||||
|
|
||||||
|
const isOwner = currentUser?.role === 'owner';
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
@@ -64,13 +78,54 @@ export default function MyKRsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
<div className="mb-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">
|
<p className="text-muted-foreground mt-1">
|
||||||
{currentUser?.name} 负责的执行目标
|
{isOwner
|
||||||
|
? '查看所有成员的当前周期目标执行情况'
|
||||||
|
: `${currentUser?.name} 负责的执行目标`
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
{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>
|
<Card>
|
||||||
<CardContent className="pt-6 text-center">
|
<CardContent className="pt-6 text-center">
|
||||||
<Target className="h-8 w-8 text-slate-300 mx-auto mb-2" />
|
<Target className="h-8 w-8 text-slate-300 mx-auto mb-2" />
|
||||||
@@ -142,6 +197,8 @@ export default function MyKRsPage() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div className="mb-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">
|
<p className="text-muted-foreground mt-1">
|
||||||
{currentUser?.role === 'owner' ? '全局视角 - 所有项目健康度一览' : `${ROLE_LABELS[currentUser?.role || 'member']}视角`}
|
{currentUser?.role === 'owner' ? '全局视角 - 所有项目健康度一览' : `${ROLE_LABELS[currentUser?.role || 'member']}视角`}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import { ProgressRing } from '@/components/progress-ring';
|
|||||||
import { useUser } from '@/components/user-context';
|
import { useUser } from '@/components/user-context';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 { 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { calculateProgress, getDaysRemaining, formatDateRange, isKRActive } from '@/lib/kr-utils';
|
import { calculateProgress, getDaysRemaining, formatDateRange, isKRActive } from '@/lib/kr-utils';
|
||||||
import { REASON_CODES, PERIOD_TYPES } from '@/lib/constants';
|
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 { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -27,8 +30,11 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2
|
Loader2,
|
||||||
|
CheckSquare,
|
||||||
|
User as UserIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { TaskEditDialog } from '@/components/task-edit-dialog';
|
||||||
|
|
||||||
interface ProjectWithRelations extends Project {
|
interface ProjectWithRelations extends Project {
|
||||||
owner: User | null;
|
owner: User | null;
|
||||||
@@ -42,29 +48,79 @@ interface KRWithRelations extends KR {
|
|||||||
|
|
||||||
export default function ProjectDetailPage({ projectId }: { projectId: string }) {
|
export default function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||||
// Force re-compile
|
// Force re-compile
|
||||||
const { currentUser } = useUser();
|
const { currentUser, users } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [project, setProject] = useState<ProjectWithRelations | null>(null);
|
const [project, setProject] = useState<ProjectWithRelations | null>(null);
|
||||||
const [objectives, setObjectives] = useState<Objective[]>([]);
|
const [objectives, setObjectives] = useState<Objective[]>([]);
|
||||||
const [krs, setKRs] = useState<KRWithRelations[]>([]);
|
const [krs, setKRs] = useState<KRWithRelations[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isProjectEditDialogOpen, setIsProjectEditDialogOpen] = useState(false);
|
||||||
const [isObjectiveDialogOpen, setIsObjectiveDialogOpen] = useState(false);
|
const [isObjectiveDialogOpen, setIsObjectiveDialogOpen] = useState(false);
|
||||||
const [editingObjective, setEditingObjective] = useState<Objective | null>(null);
|
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 () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [projectRes, objectivesRes, krsRes] = await Promise.all([
|
const [projectRes, objectivesRes, krsRes, tasksRes] = await Promise.all([
|
||||||
fetch(`/api/projects/${projectId}`),
|
fetch(`/api/projects/${projectId}`),
|
||||||
fetch(`/api/objectives?project_id=${projectId}`),
|
fetch(`/api/objectives?project_id=${projectId}`),
|
||||||
fetch(`/api/krs?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) {
|
if (projectRes.ok) {
|
||||||
const projectData = await projectRes.json();
|
const projectData = await projectRes.json();
|
||||||
setProject(projectData);
|
setProject(projectData);
|
||||||
|
setLoadError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectivesRes.ok) {
|
if (objectivesRes.ok) {
|
||||||
@@ -76,8 +132,14 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
|||||||
const krsData = await krsRes.json();
|
const krsData = await krsRes.json();
|
||||||
setKRs(krsData);
|
setKRs(krsData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tasksRes.ok) {
|
||||||
|
const tasksData = await tasksRes.json();
|
||||||
|
setTasks(tasksData);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch project data:', error);
|
console.error('Failed to fetch project data:', error);
|
||||||
|
setLoadError('加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -88,6 +150,8 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
|||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const canEdit = currentUser?.role === 'owner' || currentUser?.id === project?.pm_id;
|
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 () => {
|
const handleDeleteProject = async () => {
|
||||||
setIsDeleting(true);
|
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();
|
e.preventDefault();
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${projectId}`, {
|
const res = await fetch(`/api/projects/${projectId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(editForm),
|
||||||
name: formData.get('name'),
|
|
||||||
description: formData.get('description'),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error('更新失败');
|
if (!res.ok) throw new Error('更新失败');
|
||||||
|
|
||||||
toast.success('项目已更新');
|
toast.success('项目已更新');
|
||||||
|
setIsProjectEditDialogOpen(false);
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('更新失败');
|
toast.error('更新失败');
|
||||||
@@ -129,16 +190,25 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteObjective = async (objectiveId: string) => {
|
const requestDeleteObjective = (objectiveId: string) => {
|
||||||
if (!confirm('确定要删除此目标吗?关联的 KR 也会被解除关联。')) return;
|
setDeleteObjectiveId(objectiveId);
|
||||||
|
setIsDeleteObjectiveOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteObjective = async () => {
|
||||||
|
if (!deleteObjectiveId) return;
|
||||||
|
setIsDeletingObjective(true);
|
||||||
try {
|
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('删除失败');
|
if (!res.ok) throw new Error('删除失败');
|
||||||
toast.success('目标已删除');
|
toast.success('目标已删除');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.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 activeKRs = krs.filter(isKRActive);
|
||||||
const completedKRs = activeKRs.filter(kr => calculateProgress(kr) >= 1);
|
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) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8 text-center">
|
<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>
|
</div>
|
||||||
|
|
||||||
{canEdit && (
|
{canAddKR && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{currentUser?.role === 'owner' && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<Dialog>
|
{currentUser?.role === 'owner' && (
|
||||||
|
<>
|
||||||
|
<Dialog open={isProjectEditDialogOpen} onOpenChange={setIsProjectEditDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>编辑项目</DialogTitle>
|
<DialogTitle>编辑项目</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleEditProject} className="space-y-4">
|
<form onSubmit={handleEditProject} className="space-y-4">
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Label htmlFor="name">项目名称</Label>
|
<div>
|
||||||
<Input id="name" name="name" defaultValue={project.name} required />
|
<Label htmlFor="name">项目名称</Label>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<Label htmlFor="description">项目描述</Label>
|
<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>
|
</div>
|
||||||
<Button type="submit" disabled={isEditing} className="w-full">
|
<Button type="submit" disabled={isEditing} className="w-full">
|
||||||
{isEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
{isEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
@@ -294,6 +486,8 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
|||||||
新建目标
|
新建目标
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
||||||
<Link href={`/projects/${projectId}/krs/new`}>
|
<Link href={`/projects/${projectId}/krs/new`}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
@@ -401,7 +595,7 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-rose-500 hover:text-rose-600"
|
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" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -478,6 +672,100 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -541,6 +829,28 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ const formSchema = z.object({
|
|||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
export default function NewKRPage() {
|
export default function NewKRPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -45,6 +48,8 @@ export default function NewKRPage() {
|
|||||||
const [metrics, setMetrics] = useState<Metric[]>([]);
|
const [metrics, setMetrics] = useState<Metric[]>([]);
|
||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
|
||||||
|
const [isBooleanMetric, setIsBooleanMetric] = useState(false);
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
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) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
if (new Date(values.period_end) < new Date(values.period_start)) {
|
if (new Date(values.period_end) < new Date(values.period_start)) {
|
||||||
toast.error('结束日期不能早于开始日期');
|
toast.error('结束日期不能早于开始日期');
|
||||||
@@ -299,6 +317,12 @@ export default function NewKRPage() {
|
|||||||
</div>
|
</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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -328,6 +352,7 @@ export default function NewKRPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -336,7 +361,7 @@ export default function NewKRPage() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>方向</FormLabel>
|
<FormLabel>方向</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isBooleanMetric}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -367,6 +392,7 @@ export default function NewKRPage() {
|
|||||||
step="any"
|
step="any"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
|
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
disabled={isBooleanMetric}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -2,26 +2,20 @@
|
|||||||
|
|
||||||
import { useState, useEffect, ReactNode } from 'react';
|
import { useState, useEffect, ReactNode } from 'react';
|
||||||
import { UserProvider, useUser } from './user-context';
|
import { UserProvider, useUser } from './user-context';
|
||||||
import { UserSwitcher } from './user-switcher';
|
|
||||||
import { MainNav } from './main-nav';
|
import { MainNav } from './main-nav';
|
||||||
import { InitWizard } from './init-wizard';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
function AppContent({ children }: { children: ReactNode }) {
|
function AppContent({ children }: { children: ReactNode }) {
|
||||||
const { isLoading, isInitialized, users, refreshUsers } = useUser();
|
const { isLoading, isInitialized, isAuthenticated } = useUser();
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && users.length === 0) {
|
if (!isLoading && isInitialized && !isAuthenticated && pathname !== '/login') {
|
||||||
setShowWizard(true);
|
router.replace('/login');
|
||||||
}
|
}
|
||||||
}, [isLoading, users.length]);
|
}, [isAuthenticated, isInitialized, isLoading, pathname, router]);
|
||||||
|
|
||||||
const handleInitComplete = async () => {
|
|
||||||
await refreshUsers();
|
|
||||||
setShowWizard(false);
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -34,10 +28,6 @@ function AppContent({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showWizard) {
|
|
||||||
return <InitWizard onComplete={handleInitComplete} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<MainNav />
|
<MainNav />
|
||||||
<main className="pt-14 pb-24 min-h-screen bg-slate-50">
|
<main className="pt-14 pb-24 min-h-screen bg-slate-50">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</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">
|
<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" />
|
<Target className="h-8 w-8 text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">欢迎使用 OKR 执行系统</CardTitle>
|
<CardTitle className="text-2xl">欢迎使用 兜爸目标管理</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
系统检测到数据库为空,点击下方按钮初始化演示数据,开始体验完整功能
|
系统检测到数据库为空,点击下方按钮初始化演示数据,开始体验完整功能
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUser } from './user-context';
|
import { useUser } from './user-context';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
@@ -13,12 +15,14 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Gauge,
|
Gauge,
|
||||||
Files,
|
Files,
|
||||||
|
CheckSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const mainNavItems = [
|
const mainNavItems = [
|
||||||
{ href: '/', label: '驾驶舱', icon: LayoutDashboard },
|
{ href: '/', label: '总览', icon: LayoutDashboard },
|
||||||
{ href: '/projects', label: '项目', icon: FolderKanban },
|
{ href: '/projects', label: '项目', icon: FolderKanban },
|
||||||
{ href: '/my-krs', label: '我的KR', icon: Target },
|
{ href: '/my-krs', label: '我的KR', icon: Target },
|
||||||
|
{ href: '/my-tasks', label: '我的计划', icon: CheckSquare },
|
||||||
{ href: '/meeting', label: '例会', icon: CalendarClock },
|
{ href: '/meeting', label: '例会', icon: CalendarClock },
|
||||||
{ href: '/analysis', label: '分析', icon: BarChart3 },
|
{ href: '/analysis', label: '分析', icon: BarChart3 },
|
||||||
];
|
];
|
||||||
@@ -31,7 +35,8 @@ const adminNavItems = [
|
|||||||
|
|
||||||
export function MainNav() {
|
export function MainNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { currentUser } = useUser();
|
const router = useRouter();
|
||||||
|
const { currentUser, logout } = useUser();
|
||||||
const isOwner = currentUser?.role === 'owner';
|
const isOwner = currentUser?.role === 'owner';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +46,7 @@ export function MainNav() {
|
|||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
|
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
|
||||||
<Target className="h-6 w-6 text-teal-600" />
|
<Target className="h-6 w-6 text-teal-600" />
|
||||||
<span className="text-slate-800">OKR 执行系统</span>
|
<span className="text-slate-800">兜爸目标管理</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden md:flex items-center gap-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
{mainNavItems.map((item) => {
|
{mainNavItems.map((item) => {
|
||||||
@@ -67,30 +72,46 @@ export function MainNav() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOwner && (
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1">
|
{isOwner && (
|
||||||
<span className="text-xs text-muted-foreground mr-2 hidden lg:block">管理</span>
|
<div className="flex items-center gap-1">
|
||||||
{adminNavItems.map((item) => {
|
<span className="text-xs text-muted-foreground mr-2 hidden lg:block">管理</span>
|
||||||
const Icon = item.icon;
|
{adminNavItems.map((item) => {
|
||||||
const isActive = pathname === item.href;
|
const Icon = item.icon;
|
||||||
return (
|
const isActive = pathname === item.href;
|
||||||
<Link
|
return (
|
||||||
key={item.href}
|
<Link
|
||||||
href={item.href}
|
key={item.href}
|
||||||
className={cn(
|
href={item.href}
|
||||||
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
className={cn(
|
||||||
isActive
|
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
? 'bg-slate-800 text-white'
|
isActive
|
||||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
? 'bg-slate-800 text-white'
|
||||||
)}
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||||
>
|
)}
|
||||||
<Icon className="h-4 w-4" />
|
>
|
||||||
<span className="hidden lg:inline">{item.label}</span>
|
<Icon className="h-4 w-4" />
|
||||||
</Link>
|
<span className="hidden lg:inline">{item.label}</span>
|
||||||
);
|
</Link>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ interface StatusBadgeProps {
|
|||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
showLabel?: boolean;
|
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 colors = STATUS_COLORS[status];
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'h-2 w-2',
|
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'))} />
|
<div className={cn('rounded-full', sizeClasses[size], colors.bg, 'ring-2', colors.border.replace('border', 'ring'))} />
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className={cn('text-sm font-medium', colors.text)}>
|
<span className={cn('text-sm font-medium', colors.text)}>
|
||||||
{colors.label}
|
{text || colors.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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[];
|
users: User[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
setCurrentUserId: (id: string) => void;
|
setCurrentUserId: (id: string) => void;
|
||||||
|
logout: () => void;
|
||||||
refreshUsers: () => Promise<void>;
|
refreshUsers: () => Promise<void>;
|
||||||
|
refreshMe: () => Promise<User | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||||
@@ -19,6 +22,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
const refreshUsers = useCallback(async () => {
|
const refreshUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -34,43 +38,82 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
return [];
|
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(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const fetchedUsers = await refreshUsers();
|
try {
|
||||||
|
await refreshMe();
|
||||||
if (fetchedUsers.length > 0) {
|
} catch (error) {
|
||||||
const storedId = localStorage.getItem('currentUserId');
|
console.error('Failed to initialize auth:', error);
|
||||||
const foundUser = storedId
|
localStorage.removeItem('okrAuthUserId');
|
||||||
? fetchedUsers.find((u: User) => u.id === storedId)
|
localStorage.removeItem('currentUserId');
|
||||||
: null;
|
setCurrentUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
if (foundUser) {
|
setUsers([]);
|
||||||
setCurrentUser(foundUser);
|
} finally {
|
||||||
} else {
|
|
||||||
const owner = fetchedUsers.find((u: User) => u.role === 'owner') || fetchedUsers[0];
|
|
||||||
setCurrentUser(owner);
|
|
||||||
localStorage.setItem('currentUserId', owner.id);
|
|
||||||
}
|
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [refreshUsers]);
|
}, [refreshMe]);
|
||||||
|
|
||||||
const setCurrentUserId = useCallback((id: string) => {
|
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);
|
const user = users.find(u => u.id === id);
|
||||||
if (user) {
|
if (user) {
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
localStorage.setItem('currentUserId', id);
|
localStorage.setItem('currentUserId', id);
|
||||||
|
setIsAuthenticated(true);
|
||||||
}
|
}
|
||||||
}, [users]);
|
}, [users]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('okrAuthUserId');
|
||||||
|
localStorage.removeItem('currentUserId');
|
||||||
|
setCurrentUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsers([]);
|
||||||
|
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ currentUser, users, isLoading, isInitialized, setCurrentUserId, refreshUsers }}>
|
<UserContext.Provider value={{ currentUser, users, isLoading, isInitialized, isAuthenticated, setCurrentUserId, logout, refreshUsers, refreshMe }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: 'standalone', // 输出独立的目录
|
||||||
|
basePath: '/ok-r', // 部署到子路径时需要设置
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
|
webpack: (config, { dev }) => {
|
||||||
|
if (dev) {
|
||||||
|
config.cache = false;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
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;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
username: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -126,6 +127,41 @@ export interface Template {
|
|||||||
updated_at: string;
|
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 {
|
export interface Database {
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
@@ -174,6 +210,16 @@ export interface Database {
|
|||||||
Insert: Omit<Template, 'id' | 'created_at' | 'updated_at'> & { id?: string };
|
Insert: Omit<Template, 'id' | 'created_at' | 'updated_at'> & { id?: string };
|
||||||
Update: Partial<Omit<Template, 'id' | 'created_at'>>;
|
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