feat(workspace): 增加工作活动日报引擎
This commit is contained in:
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2,
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { CategoryChip } from './CategoryChip';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
@@ -29,6 +30,7 @@ interface Props {
|
||||
|
||||
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) {
|
||||
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
|
||||
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
|
||||
const { categories } = useTaskCategoryStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
const { members } = useMemberStore();
|
||||
@@ -36,6 +38,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [showDelayInput, setShowDelayInput] = useState(false);
|
||||
const [delayReason, setDelayReason] = useState('');
|
||||
const [progressNote, setProgressNote] = useState('');
|
||||
const [progressBlocker, setProgressBlocker] = useState('');
|
||||
const [progressHelperId, setProgressHelperId] = useState('');
|
||||
const [progressDelayRisk, setProgressDelayRisk] = useState('');
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) return null;
|
||||
@@ -86,6 +92,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
setBlockReason('');
|
||||
};
|
||||
|
||||
const handleProgressNote = () => {
|
||||
const note = progressNote.trim();
|
||||
const blocker = progressBlocker.trim();
|
||||
const delayRisk = progressDelayRisk.trim();
|
||||
if (!note && !blocker && !delayRisk) return;
|
||||
|
||||
addProgressNote({
|
||||
actorId: task.assigneeId,
|
||||
sourceType: 'dev_task',
|
||||
sourceId: task.id,
|
||||
title: task.title,
|
||||
note: note || '今日进展已更新',
|
||||
blocker: blocker || undefined,
|
||||
helperId: progressHelperId || undefined,
|
||||
delayRisk: delayRisk || undefined,
|
||||
});
|
||||
setProgressNote('');
|
||||
setProgressBlocker('');
|
||||
setProgressHelperId('');
|
||||
setProgressDelayRisk('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -199,6 +227,50 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.status !== 'todo' && task.status !== 'submitted' && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">今日进展</div>
|
||||
<textarea
|
||||
value={progressNote}
|
||||
onChange={(e) => setProgressNote(e.target.value)}
|
||||
placeholder="今日完成内容、剩余内容"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[12px] leading-5 text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
value={progressBlocker}
|
||||
onChange={(e) => setProgressBlocker(e.target.value)}
|
||||
placeholder="阻塞原因"
|
||||
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={progressHelperId}
|
||||
onChange={(e) => setProgressHelperId(e.target.value)}
|
||||
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||
>
|
||||
<option value="">协助人</option>
|
||||
{members.filter((m) => m.name !== task.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
value={progressDelayRisk}
|
||||
onChange={(e) => setProgressDelayRisk(e.target.value)}
|
||||
placeholder="延期风险"
|
||||
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleProgressNote}
|
||||
disabled={!progressNote.trim() && !progressBlocker.trim() && !progressDelayRisk.trim()}
|
||||
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
>
|
||||
记录进展
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3 flex items-center gap-1.5"><CalendarRange className="h-3 w-3" />时间信息</div>
|
||||
<div className="grid grid-cols-2 gap-y-2.5 gap-x-4 text-[12px]">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarDays, ClipboardList, Clock3 } from 'lucide-react';
|
||||
import { AlertTriangle, CalendarDays, ClipboardList, Clock3 } from 'lucide-react';
|
||||
import { WORK_ACTIVITY_CATEGORY_LABEL, type WorkActivityCategory } from '@/lib/work-activity';
|
||||
import type { WorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
||||
import { formatDateTimeShort } from '@/lib/format';
|
||||
import { formatWorkHours, formatWorkHoursShort } from '@/lib/work-hours';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +11,11 @@ interface Props {
|
||||
}
|
||||
|
||||
export function DailyReportPanel({ report }: Props) {
|
||||
const groupOrder: WorkActivityCategory[] = ['delivery', 'progress', 'creation', 'risk', 'note'];
|
||||
const hasActivities = groupOrder.some((key) => report.groups[key].length > 0);
|
||||
const hasLegacyWorklogs = report.items.length > 0;
|
||||
const hasNeedsProgress = report.needsProgressItems.length > 0;
|
||||
|
||||
return (
|
||||
<aside className="w-80 shrink-0 border-l border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
|
||||
<div className="flex h-14 items-center justify-between border-b border-[var(--line)] px-4">
|
||||
@@ -25,47 +32,100 @@ export function DailyReportPanel({ report }: Props) {
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
<span>今日合计</span>
|
||||
<span>工时合计</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{formatWorkHours(report.totalHours)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
<span>已登记</span>
|
||||
<span>日报记录</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{report.totalCount} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{report.items.length === 0 ? (
|
||||
{!hasActivities && !hasLegacyWorklogs && !hasNeedsProgress ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg)] p-4 text-center">
|
||||
<p className="text-[13px] font-medium text-[var(--ink)]">今日暂无日报记录</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[var(--ink-muted)]">从任务详情里的工时记录登记今日工作内容。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{report.items.map((item) => {
|
||||
const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
<div className="space-y-4">
|
||||
{groupOrder.map((key) => {
|
||||
const items = report.groups[key];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.workContent}</p>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent)]">
|
||||
{formatWorkHoursShort(item.hours)}
|
||||
</span>
|
||||
<section key={key}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold text-[var(--ink-soft)]">{WORK_ACTIVITY_CATEGORY_LABEL[key]}</h3>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">{items.length} 条</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-[11px] text-[var(--ink-soft)]" title={item.taskTitle}>{item.taskTitle}</p>
|
||||
{contextLabel && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={contextLabel}>
|
||||
{contextLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.summary}</p>
|
||||
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTimeShort(item.occurredAt)}</span>
|
||||
</div>
|
||||
{item.context && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={item.context}>{item.context}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasLegacyWorklogs && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold text-[var(--ink-soft)]">工时记录</h3>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">{report.items.length} 条</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{report.items.map((item) => {
|
||||
const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.workContent}</p>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent)]">
|
||||
{formatWorkHoursShort(item.hours)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-[11px] text-[var(--ink-soft)]" title={item.taskTitle}>{item.taskTitle}</p>
|
||||
{contextLabel && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={contextLabel}>
|
||||
{contextLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasNeedsProgress && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-500" />
|
||||
<h3 className="text-[11px] font-semibold text-orange-700">需补进展</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{report.needsProgressItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<p className="break-words text-[12px] font-medium leading-5 text-orange-800">{item.title}</p>
|
||||
{item.context && <p className="mt-1 truncate text-[10px] text-orange-700/70" title={item.context}>{item.context}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user