fix: force dynamic for meeting page
This commit is contained in:
550
app/meeting/page.tsx
Normal file
550
app/meeting/page.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user