feat: 加班/版本/项目调整 + 角色权限设计
加班记录: - 加班人默认回显当前用户且不可改 - 列表加创建日期列 - 移除编辑功能(创建即提交,仅可删除) - 提交时校验结束>开始 版本列表: - 移除顶部 9 个状态 tab + 表格状态列 - 仅保留搜索、项目、优先级筛选 项目列表: - 行内显示进度未达 100% 的版本(与版本页同源算法) - 新增 lib/version-progress.ts 抽出公共进度计算 角色权限: - RoleItem 加 permissions 字段 - 新建 lib/permissions.ts: 14 组 37 个权限点 + 默认 5 角色映射 + hasPermission - 角色表单内嵌权限矩阵(主模块 4 件套 + Tab 二档 + Bug Tab 4 档)+ 全选/反选/仅查看快捷 - 新建 components/auth/Guard.tsx: RouteGuard + PermissionGuard + useHasPermission + AccessDenied - Sidebar 菜单按 view 权限过滤 - 7 个主路由(products/projects/versions/requirements/overtime/admin/members/roles)包 RouteGuard - 版本详情 Tab 按 view 权限过滤,自动跳转到第一个有权限的 Tab - 默认未登录/无角色按只读最严格 - 超管 role-admin: ['*'] 通配符,permissions 不可改 通用: - 新建 components/FieldError.tsx 统一表单错误文案 - PlanTab 提交时校验结束>开始 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Plus, ChevronDown, Clock, Pencil, Trash2, X, Download } from 'lucide-react';
|
||||
import { Search, Plus, X, Download } from 'lucide-react';
|
||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||
import { calcDuration } from '@/lib/overtime';
|
||||
import type { OvertimeRecord } from '@/lib/overtime';
|
||||
@@ -12,11 +13,22 @@ import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { DictDrawer } from '@/components/requirement/DictDrawer';
|
||||
import { MonthPicker } from '@/components/MonthPicker';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { FieldError } from '@/components/FieldError';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
|
||||
export default function OvertimePage() {
|
||||
const { records, fetchRecords, createRecord, updateRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
|
||||
return (
|
||||
<RouteGuard permission="overtime:view">
|
||||
<OvertimePageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function OvertimePageContent() {
|
||||
const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
|
||||
@@ -25,7 +37,6 @@ export default function OvertimePage() {
|
||||
const [reasonFilter, setReasonFilter] = useState('all');
|
||||
const [monthFilter, setMonthFilter] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<OvertimeRecord | null>(null);
|
||||
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
@@ -48,8 +59,7 @@ export default function OvertimePage() {
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||
|
||||
const handleCreate = () => { setEditing(null); setShowModal(true); };
|
||||
const handleEdit = (r: OvertimeRecord) => { setEditing(r); setShowModal(true); };
|
||||
const handleCreate = () => { setShowModal(true); };
|
||||
|
||||
const handleExport = () => {
|
||||
const header = ['项目', '版本', '加班人', '开始时间', '结束时间', '时长(h)', '加班原因', '备注'];
|
||||
@@ -139,6 +149,7 @@ export default function OvertimePage() {
|
||||
<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>
|
||||
@@ -161,9 +172,9 @@ export default function OvertimePage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-muted)] max-w-[120px] truncate">{r.remark || '-'}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-muted)]">{r.createdAt}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => handleEdit(r)} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">编辑</button>
|
||||
<button onClick={() => deleteRecord(r.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -180,7 +191,7 @@ export default function OvertimePage() {
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<OvertimeModal
|
||||
initial={editing}
|
||||
defaultPerson={user?.name ?? ''}
|
||||
products={overview.map((p) => ({ id: p.id, name: p.name }))}
|
||||
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
|
||||
versions={allVersions}
|
||||
@@ -188,8 +199,7 @@ export default function OvertimePage() {
|
||||
requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={(data) => {
|
||||
if (editing) updateRecord(editing.id, data);
|
||||
else createRecord(data as any);
|
||||
createRecord(data as any);
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
@@ -203,8 +213,8 @@ export default function OvertimePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function OvertimeModal({ initial, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
|
||||
initial: OvertimeRecord | null;
|
||||
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
|
||||
defaultPerson: string;
|
||||
products: { id: string; name: string }[];
|
||||
projects: { id: string; name: string; productId: string }[];
|
||||
versions: { id: string; name: string; projectId?: string }[];
|
||||
@@ -214,22 +224,14 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
const [productId, setProductId] = useState('');
|
||||
const [projectId, setProjectId] = useState(initial?.projectId ?? '');
|
||||
const [versionId, setVersionId] = useState(initial?.versionId ?? '');
|
||||
const [person, setPerson] = useState(initial?.person ?? '');
|
||||
const [startTime, setStartTime] = useState(initial?.startTime ?? '');
|
||||
const [endTime, setEndTime] = useState(initial?.endTime ?? '');
|
||||
const [reasonId, setReasonId] = useState(initial?.reasonId ?? '');
|
||||
const [remark, setRemark] = useState(initial?.remark ?? '');
|
||||
const [requirementId, setRequirementId] = useState(initial?.requirementId ?? '');
|
||||
|
||||
// 初始编辑时反推 productId
|
||||
useEffect(() => {
|
||||
if (initial?.projectId) {
|
||||
const proj = projects.find((p) => p.id === initial.projectId);
|
||||
if (proj) setProductId(proj.productId);
|
||||
}
|
||||
}, [initial, projects]);
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [versionId, setVersionId] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [reasonId, setReasonId] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const [requirementId, setRequirementId] = useState('');
|
||||
const [endTimeError, setEndTimeError] = useState('');
|
||||
|
||||
const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0;
|
||||
const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects;
|
||||
@@ -238,15 +240,20 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!projectId || !person.trim() || !startTime || !endTime || !reasonId) return;
|
||||
onSubmit({ projectId, versionId: versionId || undefined, requirementId: requirementId || undefined, person: person.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined });
|
||||
if (!projectId || !defaultPerson.trim() || !startTime || !endTime || !reasonId) return;
|
||||
if (new Date(endTime).getTime() <= new Date(startTime).getTime()) {
|
||||
setEndTimeError('结束时间必须晚于开始时间');
|
||||
return;
|
||||
}
|
||||
setEndTimeError('');
|
||||
onSubmit({ projectId, versionId: versionId || undefined, requirementId: requirementId || undefined, person: defaultPerson.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑加班记录' : '新建加班记录'}</h3>
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">新建加班记录</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
@@ -274,17 +281,18 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">加班人 *</label>
|
||||
<input value={person} onChange={(e) => setPerson(e.target.value)} required placeholder="姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">加班人</label>
|
||||
<input value={defaultPerson} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">开始时间 *</label>
|
||||
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<input type="datetime-local" value={startTime} onChange={(e) => { setStartTime(e.target.value); setEndTimeError(''); }} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">结束时间 *</label>
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => setEndTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => { setEndTime(e.target.value); setEndTimeError(''); }} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<FieldError message={endTimeError} />
|
||||
</div>
|
||||
</div>
|
||||
{duration > 0 && (
|
||||
@@ -314,7 +322,7 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
|
||||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user