Files
ok_r/app/krs/[id]/client-page.tsx
2026-01-25 21:55:29 +08:00

652 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { StatusBadge, StatusDot } from '@/components/status-badge';
import { ProgressRing } from '@/components/progress-ring';
import { useUser } from '@/components/user-context';
import { Label } from '@/components/ui/label';
import {
calculateProgress,
calculateExpectedProgress,
getDaysRemaining,
formatDateRange,
isKRActive,
} from '@/lib/kr-utils';
import { REASON_CODES, PERIOD_TYPES, DIRECTION_LABELS } from '@/lib/constants';
import type { KR, User, Checkin, Objective } from '@/types/database';
import { toast } from 'sonner';
import {
ArrowLeft,
Plus,
Clock,
TrendingUp,
TrendingDown,
Target,
AlertTriangle,
Calendar,
User as UserIcon,
HandHelping,
Loader2,
Pencil,
Trash2,
} from 'lucide-react';
import { getErrorMessage } from '@/lib/error-utils';
interface KRWithRelations extends KR {
owner: User | null;
project: { id: string; name: string; status: string } | null;
objective: Objective | null;
}
interface CheckinWithRelations extends Checkin {
user: User | null;
}
const checkinSchema = z.object({
current_value: z.number(),
confidence: z.number().min(0).max(100),
reason_code: z.string().optional(),
note: z.string().optional(),
});
type CheckinFormValues = z.infer<typeof checkinSchema>;
export default function KRDetailPage({ krId }: { krId: string }) {
const router = useRouter();
const { currentUser } = useUser();
const [kr, setKR] = useState<KRWithRelations | null>(null);
const [checkins, setCheckins] = useState<CheckinWithRelations[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCheckinOpen, setIsCheckinOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const fetchData = useCallback(async () => {
try {
const [krRes, checkinsRes] = await Promise.all([
fetch(`/api/krs/${krId}`),
fetch(`/api/checkins?kr_id=${krId}`),
]);
if (krRes.ok) {
const krData = await krRes.json();
setKR(krData);
}
if (checkinsRes.ok) {
const checkinsData = await checkinsRes.json();
setCheckins(checkinsData);
}
} catch (error) {
console.error('Failed to fetch KR data:', error);
} finally {
setIsLoading(false);
}
}, [krId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const form = useForm<CheckinFormValues>({
resolver: zodResolver(checkinSchema),
defaultValues: {
current_value: kr?.current_value || 0,
confidence: kr?.confidence || 80,
reason_code: '',
note: '',
},
});
useEffect(() => {
if (kr) {
form.reset({
current_value: kr.current_value,
confidence: kr.confidence,
reason_code: kr.reason_code || '',
note: '',
});
}
}, [kr, form]);
const onCheckinSubmit = async (values: CheckinFormValues) => {
if (!kr) return;
setIsSubmitting(true);
try {
const res = await fetch('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kr_id: kr.id,
project_id: kr.project_id,
user_id: currentUser?.id,
current_value: values.current_value,
confidence: values.confidence,
reason_code: values.reason_code || null,
note: values.note || null,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || '打卡失败');
}
toast.success('打卡成功');
setIsCheckinOpen(false);
fetchData();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
const toggleNeedHelp = async () => {
if (!kr) return;
try {
const res = await fetch(`/api/krs/${kr.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
need_owner_help: !kr.need_owner_help,
skipReasonCodeCheck: true,
}),
});
if (res.ok) {
toast.success(kr.need_owner_help ? '已取消求助' : '已发送求助请求');
fetchData();
}
} catch (error) {
toast.error('操作失败');
}
};
const handleDeleteKR = async () => {
if (!kr) return;
setIsDeleting(true);
try {
const res = await fetch(`/api/krs/${kr.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('KR 已删除');
router.push(`/projects/${kr.project_id}`);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsDeleting(false);
}
};
const handleEditKR = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!kr) return;
setIsEditing(true);
const formData = new FormData(e.currentTarget);
try {
const res = await fetch(`/api/krs/${kr.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.get('title'),
metric_name: formData.get('metric_name'),
unit: formData.get('unit'),
target_value: parseFloat(formData.get('target_value') as string),
period_start: formData.get('period_start'),
period_end: formData.get('period_end'),
}),
});
if (!res.ok) throw new Error('更新失败');
toast.success('KR 已更新');
setIsEditDialogOpen(false);
fetchData();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsEditing(false);
}
};
if (isLoading) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-slate-200 rounded w-24" />
<div className="h-10 bg-slate-200 rounded w-64" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 h-96 bg-slate-200 rounded-lg" />
<div className="h-64 bg-slate-200 rounded-lg" />
</div>
</div>
</div>
);
}
if (!kr) {
return (
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
<p className="text-muted-foreground">KR 不存在</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/my-krs">返回我的 KR</Link>
</Button>
</div>
);
}
const progress = calculateProgress(kr);
const expectedProgress = calculateExpectedProgress(kr);
const daysLeft = getDaysRemaining(kr.period_end);
const isActive = isKRActive(kr);
const isOwner = currentUser?.id === kr.owner_user_id;
const TrendIcon = kr.direction === 'increase' ? TrendingUp : TrendingDown;
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<Link
href="/my-krs"
className="inline-flex items-center text-sm text-muted-foreground hover:text-slate-800 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-1" />
返回我的 KR
</Link>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-slate-800">{kr.title}</h1>
<StatusBadge status={kr.status} />
{kr.need_owner_help && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded flex items-center gap-1">
<HandHelping className="h-3 w-3" />
需要帮助
</span>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Link href={`/projects/${kr.project_id}`} className="hover:text-slate-800">
{kr.project?.name}
</Link>
<span>|</span>
<span className="flex items-center gap-1">
<UserIcon className="h-4 w-4" />
{kr.owner?.name || '未分配'}
</span>
<span>|</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDateRange(kr.period_start, kr.period_end)}
</span>
</div>
</div>
{isActive && (
<div className="flex gap-2">
{currentUser?.role === 'owner' && (
<>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑 KR</DialogTitle>
</DialogHeader>
<form onSubmit={handleEditKR} className="space-y-4">
<div>
<Label htmlFor="kr-title">KR 标题</Label>
<Input id="kr-title" name="title" defaultValue={kr.title} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="kr-metric">指标名称</Label>
<Input id="kr-metric" name="metric_name" defaultValue={kr.metric_name} required />
</div>
<div>
<Label htmlFor="kr-unit">单位</Label>
<Input id="kr-unit" name="unit" defaultValue={kr.unit} required />
</div>
</div>
<div>
<Label htmlFor="kr-target">目标值</Label>
<Input type="number" step="any" id="kr-target" name="target_value" defaultValue={kr.target_value} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="kr-start">开始日期</Label>
<Input type="date" id="kr-start" name="period_start" defaultValue={kr.period_start} required />
</div>
<div>
<Label htmlFor="kr-end">结束日期</Label>
<Input type="date" id="kr-end" name="period_end" defaultValue={kr.period_end} required />
</div>
</div>
<Button type="submit" disabled={isEditing} className="w-full">
{isEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
保存修改
</Button>
</form>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon" className="text-rose-500 hover:text-rose-600">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定要删除此 KR 吗?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除 KR 及其所有打卡记录。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteKR}
className="bg-rose-600 hover:bg-rose-700"
disabled={isDeleting}
>
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
<Dialog open={isCheckinOpen} onOpenChange={setIsCheckinOpen}>
<DialogTrigger asChild>
<Button className="bg-teal-600 hover:bg-teal-700">
<Plus className="h-4 w-4 mr-2" />
打卡更新
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>KR 进度打卡</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onCheckinSubmit)} className="space-y-4">
<FormField
control={form.control}
name="current_value"
render={({ field }) => (
<FormItem>
<FormLabel>当前值 ({kr.unit})</FormLabel>
<FormControl>
<Input
type="number"
step="any"
{...field}
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<p className="text-xs text-muted-foreground">
目标值: {kr.target_value} {kr.unit}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confidence"
render={({ field }) => (
<FormItem>
<FormLabel>信心度 ({field.value}%)</FormLabel>
<FormControl>
<Input
type="range"
min="0"
max="100"
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
className="w-full"
/>
</FormControl>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reason_code"
render={({ field }) => (
<FormItem>
<FormLabel>问题原因 (如有)</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "unspecified" ? "" : value)}
value={field.value || "unspecified"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择原因类型" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="unspecified">无问题</SelectItem>
{Object.entries(REASON_CODES).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="note"
render={({ field }) => (
<FormItem>
<FormLabel>备注</FormLabel>
<FormControl>
<Textarea placeholder="补充说明" rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
提交打卡
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-6">
<ProgressRing progress={progress} status={kr.status} size={100} strokeWidth={8} />
<div>
<p className="text-3xl font-bold text-slate-800">
{kr.current_value} / {kr.target_value}
</p>
<p className="text-muted-foreground">{kr.unit}</p>
<div className="flex items-center gap-2 mt-2">
<TrendIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{DIRECTION_LABELS[kr.direction]}
</span>
</div>
</div>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground mb-1">期望进度</p>
<p className="text-2xl font-semibold text-slate-600">
{Math.round(expectedProgress * 100)}%
</p>
{isActive && (
<p className={`text-sm ${daysLeft <= 3 ? 'text-amber-600' : 'text-muted-foreground'}`}>
<Clock className="h-3 w-3 inline mr-1" />
剩余 {daysLeft} 天
</p>
)}
</div>
</div>
{kr.reason_code && kr.status !== 'green' && (
<div className="bg-amber-50 rounded-lg p-4 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5" />
<div>
<p className="font-medium text-amber-800">{REASON_CODES[kr.reason_code]}</p>
{kr.note && <p className="text-sm text-amber-700 mt-1">{kr.note}</p>}
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">打卡记录</CardTitle>
</CardHeader>
<CardContent>
{checkins.length === 0 ? (
<p className="text-center text-muted-foreground py-8">暂无打卡记录</p>
) : (
<div className="space-y-4">
{checkins.map((checkin, index) => (
<div key={checkin.id} className="flex items-start gap-4">
<div className="flex flex-col items-center">
<StatusDot status={checkin.status_snapshot} size="md" />
{index < checkins.length - 1 && (
<div className="w-0.5 h-12 bg-slate-200 mt-2" />
)}
</div>
<div className="flex-1 pb-4">
<div className="flex items-center justify-between mb-1">
<span className="font-medium">
{checkin.current_value} {kr.unit}
</span>
<span className="text-sm text-muted-foreground">
{new Date(checkin.created_at).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="text-sm text-muted-foreground">
{checkin.user?.name} | 信心度 {checkin.confidence}%
{checkin.delta_value !== null && checkin.delta_value !== 0 && (
<span className={checkin.delta_value > 0 ? 'text-emerald-600' : 'text-rose-600'}>
{' '}({checkin.delta_value > 0 ? '+' : ''}{checkin.delta_value})
</span>
)}
</div>
{checkin.reason_code && (
<p className="text-sm text-amber-600 mt-1">
{REASON_CODES[checkin.reason_code]}
</p>
)}
{checkin.note && (
<p className="text-sm text-slate-600 mt-1">{checkin.note}</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">KR 详情</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">周期类型</p>
<p className="font-medium">{PERIOD_TYPES[kr.period_type]}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">指标名称</p>
<p className="font-medium">{kr.metric_name}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">信心度</p>
<p className="font-medium">{kr.confidence}%</p>
</div>
{kr.objective && (
<div>
<p className="text-sm text-muted-foreground">关联目标</p>
<p className="font-medium">{kr.objective.title}</p>
</div>
)}
</CardContent>
</Card>
{isOwner && isActive && (
<Card>
<CardHeader>
<CardTitle className="text-lg">快捷操作</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">请求老板帮助</p>
<p className="text-sm text-muted-foreground">遇到困难时发送求助</p>
</div>
<Switch checked={kr.need_owner_help} onCheckedChange={toggleNeedHelp} />
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}