fix: force dynamic for meeting page
This commit is contained in:
651
app/krs/[id]/client-page.tsx
Normal file
651
app/krs/[id]/client-page.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
8
app/krs/[id]/page.tsx
Normal file
8
app/krs/[id]/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
import KRDetailPage from './client-page';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
return <KRDetailPage krId={params.id} />;
|
||||
}
|
||||
Reference in New Issue
Block a user