feat: 实现需求管理、加班记录、成员/角色管理模块

- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择
- 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出
- 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置
- 角色管理:卡片列表、系统角色保护、CRUD
- 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页
- 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-09 18:16:18 +08:00
parent 24ba61f929
commit 9a0b16a8f1
36 changed files with 4355 additions and 272 deletions

View File

@@ -0,0 +1,71 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, X } from 'lucide-react';
interface SelectOption {
value: string;
label: string;
}
interface FilterSelectProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
allLabel?: string;
className?: string;
}
export function FilterSelect({ value, onChange, options, placeholder, allLabel = '全部', className }: FilterSelectProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selected = options.find((o) => o.value === value);
const displayText = value === 'all' || !value ? '' : selected?.label || '';
return (
<div ref={ref} className={`relative ${className || ''}`}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] hover:border-[var(--accent)] transition-colors whitespace-nowrap"
>
<span className={`truncate max-w-[80px] ${displayText ? 'text-[var(--ink)]' : 'text-[var(--ink-muted)]'}`}>
{displayText || placeholder || allLabel}
</span>
<ChevronDown className={`shrink-0 h-3 w-3 text-[var(--ink-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 min-w-full max-w-[200px] max-h-[240px] overflow-y-auto rounded-xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] py-1">
<button
type="button"
onClick={() => { onChange('all'); setOpen(false); }}
className={`w-full text-left px-3 py-1.5 text-[12px] truncate transition-colors ${value === 'all' || !value ? 'text-[var(--accent)] font-medium bg-[var(--accent-soft)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{allLabel}
</button>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { onChange(opt.value); setOpen(false); }}
className={`w-full text-left px-3 py-1.5 text-[12px] truncate transition-colors ${value === opt.value ? 'text-[var(--accent)] font-medium bg-[var(--accent-soft)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
interface MonthPickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
const MONTHS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
export function MonthPicker({ value, onChange, placeholder = '选择月份' }: MonthPickerProps) {
const [open, setOpen] = useState(false);
const [year, setYear] = useState(() => {
if (value) return parseInt(value.slice(0, 4));
return new Date().getFullYear();
});
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selectedMonth = value ? parseInt(value.slice(5, 7)) : null;
const selectedYear = value ? parseInt(value.slice(0, 4)) : null;
const handleSelect = (month: number) => {
const m = String(month).padStart(2, '0');
onChange(`${year}-${m}`);
setOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setOpen(false);
};
const displayText = value
? `${selectedYear}${selectedMonth}`
: '';
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-8 items-center gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] transition-colors min-w-[120px]"
>
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className={displayText ? 'text-[var(--ink)]' : 'text-[var(--ink-muted)]'}>
{displayText || placeholder}
</span>
{value && (
<span
onClick={handleClear}
className="ml-auto text-[var(--ink-muted)] hover:text-[var(--ink)] text-[10px] leading-none"
>
</span>
)}
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 w-[240px] rounded-xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] p-3">
{/* Year nav */}
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setYear(year - 1)}
className="p-1 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--ink)]"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-[13px] font-semibold text-[var(--ink)]">{year}</span>
<button
type="button"
onClick={() => setYear(year + 1)}
className="p-1 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--ink)]"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Month grid */}
<div className="grid grid-cols-3 gap-1.5">
{MONTHS.map((label, i) => {
const month = i + 1;
const isSelected = selectedYear === year && selectedMonth === month;
const isCurrent = new Date().getFullYear() === year && new Date().getMonth() === i;
return (
<button
key={month}
type="button"
onClick={() => handleSelect(month)}
className={`h-8 rounded-lg text-[12px] font-medium transition-colors ${
isSelected
? 'bg-[var(--accent)] text-white'
: isCurrent
? 'border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent-soft)]'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
}`}
>
{label}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
interface PaginationProps {
total: number;
page: number;
pageSize: number;
onChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
export function Pagination({ total, page, pageSize, onChange, onPageSizeChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (total === 0) return null;
const pages: (number | '...')[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push('...');
const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (page < totalPages - 2) pages.push('...');
pages.push(totalPages);
}
return (
<div className="flex items-center justify-between px-1 py-3">
<div className="flex items-center gap-2">
<span className="text-[12px] text-[var(--ink-muted)]"> {total} </span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[12px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size} /</option>
))}
</select>
</div>
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="h-7 w-7 flex items-center justify-center rounded-md border border-[var(--line)] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{pages.map((p, idx) =>
p === '...' ? (
<span key={`dots-${idx}`} className="w-7 text-center text-[12px] text-[var(--ink-muted)]">...</span>
) : (
<button
key={p}
onClick={() => onChange(p as number)}
className={`h-7 w-7 flex items-center justify-center rounded-md text-[12px] font-medium transition-colors ${page === p ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{p}
</button>
)
)}
<button
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="h-7 w-7 flex items-center justify-center rounded-md border border-[var(--line)] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export function usePagination<T>(items: T[], defaultPageSize: number = 20) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const paged = items.slice((safePage - 1) * pageSize, safePage * pageSize);
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPage(1);
};
return { paged, page: safePage, setPage, total, pageSize, setPageSize: handlePageSizeChange };
}

View File

@@ -1,7 +1,7 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search } from 'lucide-react';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield } from 'lucide-react';
const NAV_GROUPS = [
{
@@ -11,12 +11,15 @@ const NAV_GROUPS = [
{ label: '产品', path: '/products', icon: Package },
{ label: '项目', path: '/projects', icon: FolderKanban },
{ label: '版本', path: '/versions', icon: Tag },
{ label: '需求', path: '/requirements', icon: Lightbulb },
{ label: '加班记录', path: '/overtime', icon: Clock },
],
},
{
label: '管理',
items: [
{ label: '成员', path: '/admin/members', icon: Users },
{ label: '角色', path: '/admin/roles', icon: Shield },
],
},
];
@@ -34,7 +37,7 @@ export function Sidebar() {
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--accent)]">
<LayoutGrid className="h-4 w-4 text-white" strokeWidth={2.25} />
</div>
<span className="text-[14px] font-semibold tracking-tight text-[var(--ink)]">FTB</span>
<span className="text-[13px] font-semibold tracking-tight text-[var(--ink)]">FTB</span>
</div>
<div className="px-3 pb-2">
@@ -42,7 +45,7 @@ export function Sidebar() {
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input
placeholder="搜索"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] pl-8 pr-2 text-[12.5px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:bg-[var(--bg-card)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] pl-8 pr-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:bg-[var(--bg-card)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
</div>

View File

@@ -29,8 +29,8 @@ export function ProductCard({ product, onClick }: Props) {
<Package className="h-[18px] w-[18px] text-[var(--accent-hover)]" strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-[14px] font-semibold text-[var(--ink)]">{product.name}</h3>
<p className="mt-1 line-clamp-2 text-[12.5px] leading-relaxed text-[var(--ink-muted)]">
<h3 className="truncate text-[13px] font-semibold text-[var(--ink)]">{product.name}</h3>
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-[var(--ink-muted)]">
{product.description || '暂无描述'}
</p>
</div>

View File

@@ -50,7 +50,7 @@ export function RequirementForm({ initialData, onSubmit, onCancel }: Props) {
key={value}
type="button"
onClick={() => setPriority(v)}
className={`h-7 rounded-md px-2.5 text-[12.5px] transition-colors ${
className={`h-7 rounded-md px-2.5 text-[12px] transition-colors ${
active
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--ink-muted)] hover:text-[var(--ink)]'

View File

@@ -1,26 +1,16 @@
'use client';
import { RequirementStatus } from '@ftb/shared';
import { REQUIREMENT_STATUS_LABEL } from '@/lib/constants';
import type { RequirementStatus } from '@/lib/requirement';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
interface Props {
status: RequirementStatus;
}
const STATUS_STYLES: Record<RequirementStatus, string> = {
[RequirementStatus.DRAFT]: 'bg-zinc-100 text-zinc-700 ring-zinc-200',
[RequirementStatus.REVIEWING]: 'bg-blue-50 text-blue-700 ring-blue-200',
[RequirementStatus.APPROVED]: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
[RequirementStatus.REJECTED]: 'bg-red-50 text-red-700 ring-red-200',
[RequirementStatus.DELIVERED]: 'bg-violet-50 text-violet-700 ring-violet-200',
};
export function RequirementStatusBadge({ status }: Props) {
return (
<span
className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset ${STATUS_STYLES[status]}`}
>
{REQUIREMENT_STATUS_LABEL[status]}
<span className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[status]}`}>
{REQ_STATUS_LABEL[status]}
</span>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import { RequirementStatus } from '@ftb/shared';
import type { RequirementStatus } from '@/lib/requirement';
import { REQ_STATUS_LABEL } from '@/lib/requirement';
import { Pencil, Trash2 } from 'lucide-react';
import { RequirementStatusBadge } from './RequirementStatusBadge';
import { PRIORITY_LABEL, REQUIREMENT_STATUS_LABEL } from '@/lib/constants';
import { PRIORITY_LABEL } from '@/lib/constants';
interface RequirementItem {
id: string;
@@ -25,7 +26,7 @@ interface Props {
onCreate: () => void;
}
const ALL_STATUSES = Object.values(RequirementStatus);
const ALL_STATUSES: RequirementStatus[] = ['pending_review', 'adopted', 'rejected', 'planned', 'developing', 'testing', 'released', 'closed'];
export function RequirementTable({
requirements,
@@ -44,7 +45,7 @@ export function RequirementTable({
</FilterChip>
{ALL_STATUSES.map((s) => (
<FilterChip key={s} active={statusFilter === s} onClick={() => onFilterChange(s)}>
{REQUIREMENT_STATUS_LABEL[s]}
{REQ_STATUS_LABEL[s]}
</FilterChip>
))}
</div>
@@ -58,19 +59,19 @@ export function RequirementTable({
{requirements.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-16 text-center">
<p className="text-[14px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12.5px] text-[var(--ink-muted)]">"新建需求"</p>
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">"新建需求"</p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead>
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-right text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</tr>
</thead>
<tbody>
@@ -124,7 +125,7 @@ function FilterChip({
return (
<button
onClick={onClick}
className={`h-7 rounded-md px-2.5 text-[12.5px] transition-colors ${
className={`h-7 rounded-md px-2.5 text-[12px] transition-colors ${
active
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--ink-muted)] hover:text-[var(--ink)]'

View File

@@ -0,0 +1,164 @@
'use client';
import { useState } from 'react';
import { X, Plus, Pencil, Trash2, Check } from 'lucide-react';
import type { DictItem, SourceTarget, SourceType } from '@/lib/requirement';
import { SOURCE_TYPE_LABEL } from '@/lib/requirement';
interface DictDrawerProps {
open: boolean;
title: string;
items: DictItem[];
onClose: () => void;
onAdd: (name: string) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
}
interface SourceDrawerProps {
open: boolean;
items: SourceTarget[];
onClose: () => void;
onAdd: (name: string, sourceType: SourceType) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
}
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
export function DictDrawer({ open, title, items, onClose, onAdd, onUpdate, onDelete }: DictDrawerProps) {
if (!open) return null;
return (
<DrawerShell title={title} onClose={onClose}>
<ItemList items={items} onAdd={onAdd} onUpdate={onUpdate} onDelete={onDelete} />
</DrawerShell>
);
}
export function SourceDrawer({ open, items, onClose, onAdd, onUpdate, onDelete }: SourceDrawerProps) {
const [activeTab, setActiveTab] = useState<SourceType>('customer');
if (!open) return null;
const filtered = items.filter((t) => t.sourceType === activeTab);
return (
<DrawerShell title="来源对象管理" onClose={onClose}>
{/* Tabs */}
<div className="flex flex-wrap gap-1 px-4 py-2 border-b border-[var(--line)]">
{SOURCE_TYPES.map((type) => (
<button
key={type}
onClick={() => setActiveTab(type)}
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition-colors ${activeTab === type ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] text-[var(--ink-soft)] hover:text-[var(--ink)]'}`}
>
{SOURCE_TYPE_LABEL[type]}
</button>
))}
</div>
<ItemList
items={filtered}
onAdd={(name) => onAdd(name, activeTab)}
onUpdate={onUpdate}
onDelete={onDelete}
placeholder={`添加${SOURCE_TYPE_LABEL[activeTab]}来源对象`}
/>
</DrawerShell>
);
}
function DrawerShell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
<div className="w-80 h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--line)]">
<span className="text-[13px] font-semibold text-[var(--ink)]">{title}</span>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
function ItemList({ items, onAdd, onUpdate, onDelete, placeholder }: {
items: { id: string; name: string }[];
onAdd: (name: string) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
placeholder?: string;
}) {
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const handleAdd = () => {
const trimmed = newName.trim();
if (!trimmed) return;
onAdd(trimmed);
setNewName('');
};
const confirmEdit = () => {
if (editingId && editingName.trim()) {
onUpdate(editingId, editingName.trim());
}
setEditingId(null);
setEditingName('');
};
return (
<>
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--line)]">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
placeholder={placeholder || '输入名称...'}
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAdd} className="h-8 w-8 flex items-center justify-center rounded-lg bg-[var(--accent)] text-white hover:opacity-90">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{items.map((item) => (
<div key={item.id} className="group flex items-center px-4 py-2 hover:bg-[var(--bg-subtle)]">
{editingId === item.id ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditingId(null); }}
autoFocus
className="flex-1 h-7 rounded-lg border border-[var(--accent)] bg-[var(--bg-card)] px-2 text-[13px] text-[var(--ink)] outline-none"
/>
<button onClick={confirmEdit} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--accent)]">
<Check className="h-3.5 w-3.5" />
</button>
</div>
) : (
<>
<span className="flex-1 text-[13px] text-[var(--ink)]">{item.name}</span>
<div className="hidden group-hover:flex items-center gap-1">
<button onClick={() => { setEditingId(item.id); setEditingName(item.name); }} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)]">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(item.id)} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)] hover:text-red-500">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
))}
{items.length === 0 && (
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { X } from 'lucide-react';
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, EFFORT_SHORT, EFFORT_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
interface RequirementDetailProps {
requirement: Requirement;
projects: { id: string; name: string }[];
types: DictItem[];
platforms: DictItem[];
sourceTargets: SourceTarget[];
resolveVersionName: (id?: string) => string;
onClose: () => void;
onEdit: () => void;
onAdopt?: () => void;
onReject?: () => void;
onCloseReq?: () => void;
onDelete?: () => void;
}
const PRIORITY_COLORS: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
export function RequirementDetail({
requirement: req,
projects,
types,
platforms,
sourceTargets,
resolveVersionName,
onClose,
onEdit,
onAdopt,
onReject,
onCloseReq,
onDelete,
}: RequirementDetailProps) {
const canEdit = ['pending_review', 'adopted', 'planned'].includes(req.status);
const projectName = projects.find((p) => p.id === req.projectId)?.name ?? '-';
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
const platformNames = req.platforms.map((pid) => platforms.find((p) => p.id === pid)?.name ?? pid).join('、') || '-';
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
<div className="w-[480px] h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col shadow-xl" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-semibold text-[var(--ink)]">{req.code}</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[req.status]}`}>
{REQ_STATUS_LABEL[req.status]}
</span>
</div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
<X className="h-4 w-4" />
</button>
</div>
{/* Actions */}
<div className="flex items-center gap-2 px-5 py-3 border-b border-[var(--line)]">
{canEdit && (
<button onClick={onEdit} className="h-7 px-3 rounded-md text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">
</button>
)}
{onAdopt && req.status === 'pending_review' && (
<button onClick={onAdopt} className="h-7 px-3 rounded-md text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 transition-colors">
</button>
)}
{onReject && req.status === 'pending_review' && (
<button onClick={onReject} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-600 border border-red-200 hover:bg-red-50 transition-colors">
</button>
)}
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && (
<button onClick={onCloseReq} className="h-7 px-3 rounded-md text-[12px] font-medium text-orange-600 border border-orange-200 hover:bg-orange-50 transition-colors">
</button>
)}
{onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && (
<button onClick={onDelete} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-500 border border-red-200 hover:bg-red-50 transition-colors">
</button>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* 需求概述 */}
<div>
<h2 className="text-[15px] font-semibold text-[var(--ink)] leading-snug">{req.title}</h2>
</div>
{/* 需求描述 */}
{req.description && (
<div>
<Label></Label>
<p className="text-[13px] text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">{req.description}</p>
</div>
)}
{/* 基本信息 */}
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
<FieldRow label="需求来源" value={`${SOURCE_TYPE_LABEL[req.sourceType]} · ${req.sourceTarget || '-'}`} />
<FieldRow label="所属项目" value={projectName} />
<FieldRow label="需求类型" value={typeName} />
<FieldRow label="支持端" value={platformNames} />
<FieldRow label="优先级">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? ''}`}>
{req.priority}
</span>
</FieldRow>
{req.effort && (
<FieldRow label="工作量">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${EFFORT_COLOR[req.effort]}`}>
{EFFORT_SHORT[req.effort]}
</span>
</FieldRow>
)}
<FieldRow label="所属版本" value={resolveVersionName(req.versionId)} />
</div>
{/* 人员 & 日期 */}
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
<FieldRow label="产品负责人" value={req.productOwner || '-'} />
<FieldRow label="录入人员" value={req.creator} />
<FieldRow label="录入日期" value={req.createdAt.slice(0, 10)} />
</div>
{/* 关联父需求 */}
{req.parentId && (
<div className="rounded-lg border border-[var(--line)]">
<FieldRow label="父需求" value={req.parentId} />
</div>
)}
</div>
</div>
</div>
);
}
function Label({ children }: { children: React.ReactNode }) {
return <div className="text-[11px] font-medium text-[var(--ink-muted)] mb-1">{children}</div>;
}
function FieldRow({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) {
return (
<div className="flex items-center justify-between px-4 py-2.5">
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
{children ?? <span className="text-[13px] text-[var(--ink)]">{value}</span>}
</div>
);
}

View File

@@ -0,0 +1,412 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-react';
import type { Requirement, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
import type { Priority } from '@/lib/derive';
import { EFFORT_LABEL, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
interface RequirementModalProps {
open: boolean;
initial?: Requirement | null;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
sourceTargets: SourceTarget[];
types: DictItem[];
platforms: DictItem[];
requirements: Requirement[];
onClose: () => void;
onSubmit: (data: any) => void;
onOpenDrawer: (type: 'source' | 'type' | 'platform') => void;
}
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
const EFFORTS: Effort[] = ['S', 'M', 'L', 'XL'];
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
const PRIORITY_COLORS: Record<Priority, string> = {
P0: 'bg-red-500 text-white',
P1: 'bg-orange-500 text-white',
P2: 'bg-blue-500 text-white',
P3: 'bg-zinc-500 text-white',
P4: 'bg-zinc-400 text-white',
};
export function RequirementModal({
open,
initial,
products,
projects,
sourceTargets,
types,
platforms,
requirements,
onClose,
onSubmit,
onOpenDrawer,
}: RequirementModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [sourceType, setSourceType] = useState<SourceType>('customer');
const [sourceTarget, setSourceTarget] = useState('');
const [productId, setProductId] = useState('');
const [projectId, setProjectId] = useState('');
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [typeId, setTypeId] = useState('');
const [priority, setPriority] = useState<Priority>('P2');
const [effort, setEffort] = useState<Effort>('M');
const [productOwner, setProductOwner] = useState('');
const [parentId, setParentId] = useState('');
const [platformDropOpen, setPlatformDropOpen] = useState(false);
const filteredProjects = useMemo(
() => productId ? projects.filter((p) => p.productId === productId) : projects,
[projects, productId],
);
useEffect(() => {
if (initial) {
setTitle(initial.title);
setDescription(initial.description || '');
setSourceType(initial.sourceType || 'customer');
setSourceTarget(initial.sourceTarget || '');
setProductId(initial.productId || '');
setProjectId(initial.projectId || '');
setSelectedPlatforms(initial.platforms || []);
setTypeId(initial.typeId || '');
setPriority(initial.priority || 'P2');
setEffort(initial.effort || 'M');
setProductOwner(initial.productOwner || '');
setParentId(initial.parentId || '');
} else {
setTitle('');
setDescription('');
setSourceType('customer');
setSourceTarget('');
setProductId('');
setProjectId('');
setSelectedPlatforms([]);
setTypeId('');
setPriority('P2');
setEffort('M');
setProductOwner('');
setParentId('');
}
}, [initial, open]);
if (!open) return null;
const filteredTargets = sourceTargets.filter(t => t.sourceType === sourceType);
const togglePlatform = (id: string) => {
setSelectedPlatforms((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
);
};
const handleSubmit = () => {
if (!title.trim()) return;
onSubmit({
title: title.trim(),
description: description.trim(),
sourceType,
sourceTarget: sourceTarget || undefined,
productId: productId || undefined,
projectId: projectId || undefined,
platforms: selectedPlatforms,
typeId: typeId || undefined,
priority,
productOwner: productOwner.trim() || undefined,
parentId: parentId || undefined,
});
};
const platformDisplay =
selectedPlatforms.length > 0
? platforms
.filter((p) => selectedPlatforms.includes(p.id))
.map((p) => p.name)
.join(', ')
: '请选择';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div
className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)] max-h-[85vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h2 className="text-[15px] font-semibold text-[var(--ink)]">
{initial ? '编辑需求' : '新建需求'}
</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
{/* 1. 需求概述 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="请输入需求标题"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
</div>
{/* 2. 需求描述 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="请输入需求描述"
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)] resize-none"
/>
</div>
{/* 3. 来源类型 + 来源对象 (2-column grid) */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={sourceType}
onChange={(e) => {
setSourceType(e.target.value as SourceType);
setSourceTarget('');
}}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
{SOURCE_TYPES.map((st) => (
<option key={st} value={st}>
{SOURCE_TYPE_LABEL[st]}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]">
{SOURCE_TARGET_LABEL[sourceType]}
</label>
<button
type="button"
onClick={() => onOpenDrawer('source')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<select
value={sourceTarget}
onChange={(e) => setSourceTarget(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{filteredTargets.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
</div>
{/* 4. 所属产品 → 项目 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={productId}
onChange={(e) => { setProductId(e.target.value); setProjectId(''); }}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{filteredProjects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
</div>
{/* 5. 支持端 (multi-select) */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('platform')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<div className="relative">
<button
type="button"
onClick={() => setPlatformDropOpen(!platformDropOpen)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate"
>
{platformDisplay}
</button>
{platformDropOpen && (
<div className="absolute top-10 left-0 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] z-10 max-h-40 overflow-y-auto py-1">
{platforms.map((p) => (
<label
key={p.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedPlatforms.includes(p.id)}
onChange={() => togglePlatform(p.id)}
className="h-3.5 w-3.5 rounded border-[var(--line)] accent-[var(--accent)]"
/>
<span className="text-[13px] text-[var(--ink)]">{p.name}</span>
</label>
))}
{platforms.length === 0 && (
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
)}
</div>
</div>
{/* 6. 需求类型 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('type')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<select
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
{/* 7. 优先级 (button group) */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<div className="flex gap-2">
{PRIORITIES.map((p) => (
<button
key={p}
type="button"
onClick={() => setPriority(p)}
className={`h-8 px-3 rounded-lg text-[12px] font-medium transition-colors ${
priority === p
? PRIORITY_COLORS[p]
: 'border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
}`}
>
{p}
</button>
))}
</div>
</div>
{/* 9. 产品负责人 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<input
type="text"
value={productOwner}
onChange={(e) => setProductOwner(e.target.value)}
placeholder="请输入负责人姓名"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
</div>
{/* 10. 关联父需求 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{requirements
.filter((r) => r.id !== initial?.id)
.map((r) => (
<option key={r.id} value={r.id}>
{r.code} - {r.title}
</option>
))}
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-[var(--line)]">
<button
type="button"
onClick={onClose}
className="h-9 px-4 rounded-lg border border-[var(--line)] text-[13px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
>
</button>
<button
type="button"
onClick={handleSubmit}
className="h-9 px-4 rounded-lg bg-[var(--accent)] text-white text-[13px] font-medium hover:opacity-90 transition-opacity"
>
{initial ? '保存' : '创建'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage';
import type { RoleProgress } from '@/lib/derive';
export function CapsuleStages({ currentStage, progress }: {
currentStage?: Stage;
progress?: RoleProgress[];
}) {
const currentIdx = currentStage !== undefined
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
: -1;
const progressMap = (progress ?? []).reduce<Record<Role, { percent: number; daysSpent: number }>>((acc, p) => {
acc[p.role] = { percent: p.percent, daysSpent: p.daysSpent };
return acc;
}, {} as Record<Role, { percent: number; daysSpent: number }>);
const stageRoleMap: Record<Stage, Role[]> = {
requirement: ['product'],
product_design: ['product'],
ui_design: ['ui'],
dev: ['frontend', 'backend'],
integration: ['frontend', 'backend'],
testing: ['testing'],
released: [],
};
function getStageInfo(stage: Stage) {
const roles = stageRoleMap[stage];
if (roles.length === 0) return { percent: 0, days: 0, hasData: false };
const items = roles.map((r) => progressMap[r]).filter(Boolean);
if (items.length === 0) return { percent: 0, days: 0, hasData: false };
const percent = Math.round(items.reduce((s, i) => s + i.percent, 0) / items.length);
const days = Math.max(...items.map((i) => i.daysSpent));
return { percent, days, hasData: true };
}
return (
<div className="flex rounded-lg border border-[var(--line)] overflow-hidden bg-[var(--bg-card)]">
{STAGES.map((stage, idx) => {
const isCompleted = idx < currentIdx;
const isCurrent = idx === currentIdx;
const info = getStageInfo(stage.key);
return (
<div
key={stage.key}
className={`flex-1 flex flex-col ${idx < STAGES.length - 1 ? 'border-r border-[var(--line-soft)]' : ''}`}
>
<div className="flex items-center justify-between px-2 py-1.5 min-h-[28px]">
<span className={`text-[10px] font-medium leading-tight ${isCurrent ? 'text-[var(--ink)]' : isCompleted ? 'text-[var(--ink-soft)]' : 'text-[var(--ink-muted)]'}`}>
{stage.label}
</span>
<span className={`text-[10px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
{isCompleted ? (info.hasData ? `${info.days}` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''}
</span>
</div>
<div className="h-[3px] w-full bg-zinc-50">
{isCompleted && <div className="h-full bg-zinc-700 w-full" />}
{isCurrent && info.hasData && (
<div className="h-full bg-blue-100 w-full">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${info.percent}%` }} />
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
interface TrendPoint {
date: string;
score: number;
}
interface HealthTrendProps {
data: TrendPoint[];
}
function getColor(score: number) {
if (score >= 90) return '#10b981';
if (score >= 75) return '#3b82f6';
if (score >= 60) return '#f59e0b';
if (score >= 35) return '#f97316';
return '#ef4444';
}
export function HealthTrend({ data }: HealthTrendProps) {
if (data.length < 2) return null;
const w = 160;
const h = 48;
const pad = { top: 4, right: 4, bottom: 12, left: 4 };
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
const scores = data.map((d) => d.score);
const min = Math.max(0, Math.min(...scores) - 5);
const max = Math.min(100, Math.max(...scores) + 5);
const range = max - min || 1;
const points = data.map((d, i) => ({
x: pad.left + (i / (data.length - 1)) * chartW,
y: pad.top + chartH - ((d.score - min) / range) * chartH,
...d,
}));
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
const lastPoint = points[points.length - 1];
const firstPoint = points[0];
const trend = lastPoint.score - firstPoint.score;
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-[var(--ink-muted)]">{data.length}</span>
<span className={`text-[10px] font-medium ${trend > 0 ? 'text-emerald-600' : trend < 0 ? 'text-red-600' : 'text-[var(--ink-muted)]'}`}>
{trend > 0 ? '↑' : trend < 0 ? '↓' : '→'} {trend > 0 ? '+' : ''}{trend}
</span>
</div>
<svg width={w} height={h} className="w-full">
<path d={pathD} fill="none" stroke={getColor(lastPoint.score)} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.8" />
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="2" fill={getColor(p.score)} />
))}
<text x={firstPoint.x} y={h - 1} textAnchor="start" fontSize="8" fill="var(--ink-muted)">{firstPoint.date.slice(5)}</text>
<text x={lastPoint.x} y={h - 1} textAnchor="end" fontSize="8" fill="var(--ink-muted)">{lastPoint.date.slice(5)}</text>
</svg>
</div>
);
}
export function generateMockTrend(currentScore: number, days: number = 7): TrendPoint[] {
const result: TrendPoint[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().slice(0, 10);
const drift = (days - 1 - i) * (currentScore < 60 ? -1.5 : 0.5);
const jitter = Math.round((Math.random() - 0.5) * 6);
const score = Math.max(0, Math.min(100, Math.round(currentScore - drift + jitter)));
result.push({ date: dateStr, score });
}
result[result.length - 1].score = currentScore;
return result;
}

View File

@@ -0,0 +1,23 @@
import { Role, ROLES } from '@/lib/stage';
export function MemberChips({ members }: { members: { role: Role; name: string }[] }) {
if (!members || members.length === 0) return null;
const grouped = ROLES.reduce<Record<Role, string[]>>((acc, r) => {
acc[r.key] = members.filter((m) => m.role === r.key).map((m) => m.name);
return acc;
}, {} as Record<Role, string[]>);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
{ROLES.map((r) => {
const names = grouped[r.key];
if (!names || names.length === 0) return null;
return (
<span key={r.key} className="inline-flex items-center gap-1 text-[var(--ink-soft)]">
<span className="font-medium text-[var(--ink-muted)]">{r.label}</span>
{names.join('/')}
</span>
);
})}
</div>
);
}