551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
'use client';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
import { useEffect, useState, useMemo } from 'react';
|
||
import { useSearchParams } 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 { StatusDot } from '@/components/status-badge';
|
||
import { ProgressRing } from '@/components/progress-ring';
|
||
import { useUser } from '@/components/user-context';
|
||
import { calculateProgress, formatDateRange } from '@/lib/kr-utils';
|
||
import { REASON_CODES } from '@/lib/constants';
|
||
import type { Project, KR, User, Meeting } from '@/types/database';
|
||
import { toast } from 'sonner';
|
||
import {
|
||
CalendarClock,
|
||
FileText,
|
||
AlertTriangle,
|
||
CheckCircle,
|
||
Loader2,
|
||
Save,
|
||
Presentation,
|
||
} from 'lucide-react';
|
||
import { getErrorMessage } from '@/lib/error-utils';
|
||
|
||
interface ProjectWithRelations extends Project {
|
||
owner: User | null;
|
||
pm: User | null;
|
||
}
|
||
|
||
interface KRWithRelations extends KR {
|
||
owner: User | null;
|
||
}
|
||
|
||
const meetingSchema = z.object({
|
||
summary: z.string().min(1, '总结不能为空'),
|
||
decisions: z.string().optional(),
|
||
risks: z.string().optional(),
|
||
next_period_commitments: z.string().optional(),
|
||
});
|
||
|
||
type MeetingFormValues = z.infer<typeof meetingSchema>;
|
||
|
||
export default function MeetingPage() {
|
||
const searchParams = useSearchParams();
|
||
const initialProjectId = searchParams.get('project_id');
|
||
const { currentUser } = useUser();
|
||
|
||
const [projects, setProjects] = useState<ProjectWithRelations[]>([]);
|
||
const [krs, setKRs] = useState<KRWithRelations[]>([]);
|
||
const [meetings, setMeetings] = useState<Meeting[]>([]);
|
||
const [selectedProjectId, setSelectedProjectId] = useState<string>(initialProjectId || '');
|
||
const [periodStart, setPeriodStart] = useState<string>('');
|
||
const [periodEnd, setPeriodEnd] = useState<string>('');
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [showPresentation, setShowPresentation] = useState(false);
|
||
|
||
const form = useForm<MeetingFormValues>({
|
||
resolver: zodResolver(meetingSchema),
|
||
defaultValues: {
|
||
summary: '',
|
||
decisions: '',
|
||
risks: '',
|
||
next_period_commitments: '',
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
async function fetchData() {
|
||
try {
|
||
const [projectsRes, krsRes, meetingsRes] = await Promise.all([
|
||
fetch('/api/projects'),
|
||
fetch('/api/krs'),
|
||
fetch('/api/meetings'),
|
||
]);
|
||
|
||
if (projectsRes.ok) {
|
||
const data = await projectsRes.json();
|
||
setProjects(data);
|
||
}
|
||
|
||
if (krsRes.ok) {
|
||
const data = await krsRes.json();
|
||
setKRs(data);
|
||
}
|
||
|
||
if (meetingsRes.ok) {
|
||
const data = await meetingsRes.json();
|
||
setMeetings(data);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch data:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
fetchData();
|
||
|
||
const today = new Date();
|
||
const weekAgo = new Date(today);
|
||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||
|
||
setPeriodStart(weekAgo.toISOString().split('T')[0]);
|
||
setPeriodEnd(today.toISOString().split('T')[0]);
|
||
}, []);
|
||
|
||
const periodKRs = useMemo(() => {
|
||
if (!selectedProjectId || !periodStart || !periodEnd) return [];
|
||
|
||
return krs.filter(kr => {
|
||
if (kr.project_id !== selectedProjectId) return false;
|
||
return kr.period_start <= periodEnd && kr.period_end >= periodStart;
|
||
});
|
||
}, [krs, selectedProjectId, periodStart, periodEnd]);
|
||
|
||
const completedKRs = periodKRs.filter(kr => calculateProgress(kr) >= 1);
|
||
const incompleteKRs = periodKRs.filter(kr => calculateProgress(kr) < 1);
|
||
const riskKRs = periodKRs.filter(kr => kr.status === 'red' || kr.status === 'yellow');
|
||
|
||
const generateSummary = () => {
|
||
const totalCount = periodKRs.length;
|
||
const completedCount = completedKRs.length;
|
||
const completionRate = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||
|
||
let summary = `本周期完成情况总结:\n`;
|
||
summary += `- 共 ${totalCount} 个 KR,完成 ${completedCount} 个\n`;
|
||
summary += `- 完成率:${completionRate}%\n\n`;
|
||
|
||
if (completedKRs.length > 0) {
|
||
summary += `已完成:\n`;
|
||
completedKRs.forEach(kr => {
|
||
summary += `- ${kr.title} (${kr.current_value}/${kr.target_value} ${kr.unit})\n`;
|
||
});
|
||
summary += '\n';
|
||
}
|
||
|
||
if (incompleteKRs.length > 0) {
|
||
summary += `未完成:\n`;
|
||
incompleteKRs.forEach(kr => {
|
||
const progress = Math.round(calculateProgress(kr) * 100);
|
||
summary += `- ${kr.title} (进度 ${progress}%`;
|
||
if (kr.reason_code) {
|
||
summary += ` - ${REASON_CODES[kr.reason_code]}`;
|
||
}
|
||
summary += `)\n`;
|
||
});
|
||
}
|
||
|
||
form.setValue('summary', summary);
|
||
|
||
if (riskKRs.length > 0) {
|
||
let risks = '';
|
||
riskKRs.forEach(kr => {
|
||
risks += `- ${kr.title}`;
|
||
if (kr.reason_code) {
|
||
risks += `: ${REASON_CODES[kr.reason_code]}`;
|
||
}
|
||
if (kr.note) {
|
||
risks += ` (${kr.note})`;
|
||
}
|
||
risks += '\n';
|
||
});
|
||
form.setValue('risks', risks);
|
||
}
|
||
};
|
||
|
||
const onSubmit = async (values: MeetingFormValues) => {
|
||
if (!selectedProjectId || !periodStart || !periodEnd) {
|
||
toast.error('请选择项目和周期');
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const res = await fetch('/api/meetings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
project_id: selectedProjectId,
|
||
period_start: periodStart,
|
||
period_end: periodEnd,
|
||
...values,
|
||
created_by: currentUser?.id,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json();
|
||
throw new Error(data.error || '保存失败');
|
||
}
|
||
|
||
toast.success('会议纪要已保存');
|
||
form.reset();
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const selectedProject = projects.find(p => p.id === selectedProjectId);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="max-w-6xl 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="h-64 bg-slate-200 rounded-lg" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (showPresentation) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-900 text-white p-8">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setShowPresentation(false)}
|
||
className="mb-8 text-white border-white hover:bg-slate-800"
|
||
>
|
||
退出演示模式
|
||
</Button>
|
||
|
||
<div className="max-w-4xl mx-auto space-y-8">
|
||
<div className="text-center mb-12">
|
||
<h1 className="text-4xl font-bold mb-4">{selectedProject?.name} 例会</h1>
|
||
<p className="text-xl text-slate-400">
|
||
{formatDateRange(periodStart, periodEnd)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-6 mb-12">
|
||
<Card className="bg-slate-800 border-slate-700">
|
||
<CardContent className="pt-6 text-center">
|
||
<p className="text-5xl font-bold text-white">{periodKRs.length}</p>
|
||
<p className="text-slate-400">总 KR 数</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-emerald-900 border-emerald-700">
|
||
<CardContent className="pt-6 text-center">
|
||
<p className="text-5xl font-bold text-emerald-400">{completedKRs.length}</p>
|
||
<p className="text-emerald-300">已完成</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-rose-900 border-rose-700">
|
||
<CardContent className="pt-6 text-center">
|
||
<p className="text-5xl font-bold text-rose-400">{riskKRs.length}</p>
|
||
<p className="text-rose-300">需关注</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="bg-slate-800 border-slate-700">
|
||
<CardHeader>
|
||
<CardTitle className="text-white">KR 完成情况</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{periodKRs.map(kr => (
|
||
<div key={kr.id} className="flex items-center gap-4 p-3 bg-slate-700 rounded-lg">
|
||
<ProgressRing progress={calculateProgress(kr)} status={kr.status} size={48} />
|
||
<div className="flex-1">
|
||
<p className="font-medium text-white">{kr.title}</p>
|
||
<p className="text-sm text-slate-400">{kr.owner?.name}</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-lg font-semibold text-white">
|
||
{kr.current_value}/{kr.target_value} {kr.unit}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{form.getValues('risks') && (
|
||
<Card className="bg-amber-900 border-amber-700">
|
||
<CardHeader>
|
||
<CardTitle className="text-amber-100 flex items-center gap-2">
|
||
<AlertTriangle className="h-5 w-5" />
|
||
风险与阻塞
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<pre className="text-amber-100 whitespace-pre-wrap text-lg">
|
||
{form.getValues('risks')}
|
||
</pre>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||
<div className="mb-8">
|
||
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
|
||
<CalendarClock className="h-6 w-6 text-teal-600" />
|
||
例会模式
|
||
</h1>
|
||
<p className="text-muted-foreground mt-1">选择项目和周期,自动生成会议总结</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div className="lg:col-span-2 space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">会议设置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="text-sm font-medium mb-2 block">选择项目</label>
|
||
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择项目" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{projects.map(project => (
|
||
<SelectItem key={project.id} value={project.id}>
|
||
{project.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium mb-2 block">开始日期</label>
|
||
<Input
|
||
type="date"
|
||
value={periodStart}
|
||
onChange={e => setPeriodStart(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium mb-2 block">结束日期</label>
|
||
<Input
|
||
type="date"
|
||
value={periodEnd}
|
||
onChange={e => setPeriodEnd(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={generateSummary}
|
||
disabled={!selectedProjectId}
|
||
variant="outline"
|
||
>
|
||
<FileText className="h-4 w-4 mr-2" />
|
||
自动生成总结
|
||
</Button>
|
||
<Button
|
||
onClick={() => setShowPresentation(true)}
|
||
disabled={!selectedProjectId || periodKRs.length === 0}
|
||
variant="outline"
|
||
>
|
||
<Presentation className="h-4 w-4 mr-2" />
|
||
演示模式
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{selectedProjectId && periodKRs.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">
|
||
周期内 KR ({periodKRs.length})
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{periodKRs.map(kr => (
|
||
<div key={kr.id} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
|
||
<ProgressRing progress={calculateProgress(kr)} status={kr.status} size={48} />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-medium text-slate-800 truncate">{kr.title}</p>
|
||
<p className="text-sm text-muted-foreground">{kr.owner?.name}</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="font-medium">
|
||
{kr.current_value}/{kr.target_value} {kr.unit}
|
||
</p>
|
||
{kr.reason_code && (
|
||
<p className="text-xs text-amber-600">{REASON_CODES[kr.reason_code]}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">会议纪要</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="summary"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>总结 *</FormLabel>
|
||
<FormControl>
|
||
<Textarea placeholder="本周期完成情况总结" rows={8} {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="decisions"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>决议</FormLabel>
|
||
<FormControl>
|
||
<Textarea placeholder="会议决议" rows={3} {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="risks"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>风险</FormLabel>
|
||
<FormControl>
|
||
<Textarea placeholder="风险与阻塞" rows={3} {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="next_period_commitments"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>下周期承诺</FormLabel>
|
||
<FormControl>
|
||
<Textarea placeholder="下周期承诺事项" rows={3} {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<Button
|
||
type="submit"
|
||
disabled={isSubmitting || !selectedProjectId}
|
||
className="bg-teal-600 hover:bg-teal-700"
|
||
>
|
||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||
<Save className="h-4 w-4 mr-2" />
|
||
保存会议纪要
|
||
</Button>
|
||
</form>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">周期统计</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||
<span className="text-muted-foreground">总 KR 数</span>
|
||
<span className="text-xl font-bold">{periodKRs.length}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between p-3 bg-emerald-50 rounded-lg">
|
||
<span className="flex items-center gap-2 text-emerald-700">
|
||
<CheckCircle className="h-4 w-4" />
|
||
已完成
|
||
</span>
|
||
<span className="text-xl font-bold text-emerald-700">{completedKRs.length}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
|
||
<span className="flex items-center gap-2 text-amber-700">
|
||
<AlertTriangle className="h-4 w-4" />
|
||
需关注
|
||
</span>
|
||
<span className="text-xl font-bold text-amber-700">{riskKRs.length}</span>
|
||
</div>
|
||
{periodKRs.length > 0 && (
|
||
<div className="pt-4 border-t">
|
||
<p className="text-sm text-muted-foreground mb-2">完成率</p>
|
||
<p className="text-3xl font-bold text-slate-800">
|
||
{Math.round((completedKRs.length / periodKRs.length) * 100)}%
|
||
</p>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">历史会议</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{meetings.filter(m => m.project_id === selectedProjectId).length === 0 ? (
|
||
<p className="text-center text-muted-foreground py-4">暂无历史记录</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{meetings
|
||
.filter(m => m.project_id === selectedProjectId)
|
||
.slice(0, 5)
|
||
.map(meeting => (
|
||
<div key={meeting.id} className="p-3 bg-slate-50 rounded-lg">
|
||
<p className="text-sm font-medium">
|
||
{formatDateRange(meeting.period_start, meeting.period_end)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
|
||
{meeting.summary.split('\n')[0]}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|