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:
164
apps/web/components/requirement/DictDrawer.tsx
Normal file
164
apps/web/components/requirement/DictDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user