253 lines
25 KiB
TypeScript
253 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import mockData from "./data/mock-data.json";
|
||
|
||
type View = "overview" | "components" | "content" | "board" | "schedule";
|
||
type Toast = { id: number; message: string };
|
||
type BoardColumn = (typeof mockData.board)[number];
|
||
|
||
const navGroups = [
|
||
{
|
||
label: "工作台",
|
||
items: [
|
||
{ id: "overview" as View, icon: "⌂", label: "概览" },
|
||
{ id: "components" as View, icon: "◈", label: "组件样式", badge: "18" },
|
||
],
|
||
},
|
||
{
|
||
label: "业务管理",
|
||
items: [
|
||
{ id: "content" as View, icon: "▤", label: "内容管理", children: ["图文列表", "卡片内容", "分类标签"] },
|
||
{ id: "board" as View, icon: "▦", label: "流程泳道", badge: "9" },
|
||
{ id: "schedule" as View, icon: "◷", label: "定时任务" },
|
||
],
|
||
},
|
||
{
|
||
label: "系统设置",
|
||
items: [
|
||
{ id: "components" as View, icon: "♙", label: "成员与权限" },
|
||
{ id: "components" as View, icon: "⚙", label: "基础设置" },
|
||
],
|
||
},
|
||
];
|
||
|
||
const pageMeta: Record<View, { title: string; subtitle: string }> = {
|
||
overview: { title: "下午好,林知夏", subtitle: "这里是团队今天的工作概览,祝你高效完成每一项计划。" },
|
||
components: { title: "组件样式", subtitle: "可直接复用的标签、按钮、菜单与交互状态。" },
|
||
content: { title: "内容管理", subtitle: "通过图片文字列表与卡片视图管理业务内容。" },
|
||
board: { title: "流程泳道", subtitle: "拖动任务卡片,快速同步团队当前进度。" },
|
||
schedule: { title: "定时任务", subtitle: "纯前端模拟任务配置、状态切换与执行记录。" },
|
||
};
|
||
|
||
export default function Home() {
|
||
const [activeView, setActiveView] = useState<View>("overview");
|
||
const [expandedMenus, setExpandedMenus] = useState<string[]>(["内容管理"]);
|
||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||
const [board, setBoard] = useState<BoardColumn[]>(mockData.board);
|
||
const [dragged, setDragged] = useState<{ cardId: string; columnId: string } | null>(null);
|
||
const [schedules, setSchedules] = useState(mockData.schedules);
|
||
const [scheduleModal, setScheduleModal] = useState(false);
|
||
const [profileOpen, setProfileOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const saved = window.localStorage.getItem("orbit-schedules");
|
||
if (saved) {
|
||
try { setSchedules(JSON.parse(saved)); }
|
||
catch { window.localStorage.removeItem("orbit-schedules"); }
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
window.localStorage.setItem("orbit-schedules", JSON.stringify(schedules));
|
||
}, [schedules]);
|
||
|
||
const notify = (message: string) => {
|
||
const id = Date.now();
|
||
setToasts((current) => [...current, { id, message }]);
|
||
window.setTimeout(() => setToasts((current) => current.filter((item) => item.id !== id)), 2600);
|
||
};
|
||
|
||
const navigate = (view: View) => {
|
||
setActiveView(view);
|
||
setSidebarOpen(false);
|
||
setSearch("");
|
||
};
|
||
|
||
const filteredProjects = useMemo(
|
||
() => mockData.projects.filter((project) => `${project.title}${project.description}${project.tag}`.toLowerCase().includes(search.toLowerCase())),
|
||
[search],
|
||
);
|
||
|
||
const moveCard = (targetColumnId: string) => {
|
||
if (!dragged || dragged.columnId === targetColumnId) return;
|
||
let movingCard: BoardColumn["cards"][number] | undefined;
|
||
const nextBoard = board.map((column) => {
|
||
if (column.id !== dragged.columnId) return column;
|
||
movingCard = column.cards.find((card) => card.id === dragged.cardId);
|
||
return { ...column, cards: column.cards.filter((card) => card.id !== dragged.cardId) };
|
||
});
|
||
if (!movingCard) return;
|
||
setBoard(nextBoard.map((column) => column.id === targetColumnId ? { ...column, cards: [...column.cards, movingCard!] } : column));
|
||
setDragged(null);
|
||
notify("任务状态已更新");
|
||
};
|
||
|
||
const toggleSchedule = (id: number) => {
|
||
setSchedules((current) => current.map((schedule) => schedule.id === id ? { ...schedule, enabled: !schedule.enabled } : schedule));
|
||
notify("任务状态已保存到本地");
|
||
};
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<aside className={`sidebar ${sidebarOpen ? "is-open" : ""}`}>
|
||
<button className="brand" onClick={() => navigate("overview")} aria-label="返回概览">
|
||
<span className="brand-mark"><i /><i /><i /></span>
|
||
<span>Orbit<span>Flow</span></span>
|
||
</button>
|
||
<nav className="main-nav" aria-label="主导航">
|
||
{navGroups.map((group) => (
|
||
<div className="nav-group" key={group.label}>
|
||
<p>{group.label}</p>
|
||
{group.items.map((item) => {
|
||
const expanded = expandedMenus.includes(item.label);
|
||
return (
|
||
<div key={`${group.label}-${item.label}`}>
|
||
<button className={`nav-item ${activeView === item.id ? "active" : ""}`} onClick={() => {
|
||
navigate(item.id);
|
||
if (item.children) setExpandedMenus((current) => current.includes(item.label) ? current.filter((label) => label !== item.label) : [...current, item.label]);
|
||
}}>
|
||
<span className="nav-icon">{item.icon}</span><span>{item.label}</span>
|
||
{item.badge && <span className="nav-badge">{item.badge}</span>}
|
||
{item.children && <span className={`chevron ${expanded ? "expanded" : ""}`}>⌄</span>}
|
||
</button>
|
||
{item.children && expanded && (
|
||
<div className="subnav">
|
||
{item.children.map((child, index) => <button className={index === 0 && activeView === "content" ? "active" : ""} key={child} onClick={() => navigate("content")}>{child}</button>)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</nav>
|
||
<div className="sidebar-help"><span>?</span><div><strong>需要帮助?</strong><small>查看使用指南</small></div><button onClick={() => notify("帮助中心即将开放")}>↗</button></div>
|
||
</aside>
|
||
{sidebarOpen && <button className="sidebar-backdrop" aria-label="关闭菜单" onClick={() => setSidebarOpen(false)} />}
|
||
|
||
<main className="main-area">
|
||
<header className="topbar">
|
||
<button className="mobile-menu" aria-label="打开菜单" onClick={() => setSidebarOpen(true)}>☰</button>
|
||
<label className="global-search"><span>⌕</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索项目、任务或成员..." /><kbd>⌘ K</kbd></label>
|
||
<div className="top-actions">
|
||
<button className="icon-button" aria-label="帮助" onClick={() => notify("你可以从左侧菜单浏览全部模板")}>?</button>
|
||
<button className="icon-button notification" aria-label="通知" onClick={() => notify("你有 3 条未读通知")}>♢<i /></button>
|
||
<span className="divider" />
|
||
<div className="profile-wrap">
|
||
<button className="profile" onClick={() => setProfileOpen(!profileOpen)}><span className="avatar">林</span><span><strong>林知夏</strong><small>产品设计师</small></span><b>⌄</b></button>
|
||
{profileOpen && <div className="profile-menu"><button onClick={() => notify("个人资料功能为模板占位")}>个人资料</button><button onClick={() => notify("偏好设置功能为模板占位")}>偏好设置</button><button onClick={() => setProfileOpen(false)}>关闭菜单</button></div>}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="page-content">
|
||
<div className="page-heading">
|
||
<div><p className="eyebrow">{activeView === "overview" ? "2026 年 8 月 7 日 · 星期五" : "ORBITFLOW TEMPLATE"}</p><h1>{pageMeta[activeView].title}</h1><p>{pageMeta[activeView].subtitle}</p></div>
|
||
<div className="heading-actions"><button className="button secondary" onClick={() => notify("示例数据已刷新")}>↻ 刷新数据</button><button className="button primary" onClick={() => activeView === "schedule" ? setScheduleModal(true) : notify("已创建一条示例记录")}>+ {activeView === "schedule" ? "新建任务" : "快速创建"}</button></div>
|
||
</div>
|
||
{activeView === "overview" && <Overview onNavigate={navigate} notify={notify} />}
|
||
{activeView === "components" && <ComponentsShowcase notify={notify} />}
|
||
{activeView === "content" && <ContentView projects={filteredProjects} notify={notify} />}
|
||
{activeView === "board" && <BoardView board={board} dragged={dragged} setDragged={setDragged} moveCard={moveCard} notify={notify} />}
|
||
{activeView === "schedule" && <ScheduleView schedules={schedules} toggleSchedule={toggleSchedule} notify={notify} onCreate={() => setScheduleModal(true)} />}
|
||
</section>
|
||
</main>
|
||
|
||
{scheduleModal && <ScheduleModal onClose={() => setScheduleModal(false)} onSave={(name, frequency) => {
|
||
setSchedules((current) => [...current, { id: Math.max(...current.map((item) => item.id), 0) + 1, name, type: "自定义任务", cron: frequency === "每天" ? "0 9 * * *" : "0 9 * * 1", frequency: frequency === "每天" ? "每天 09:00" : "每周一 09:00", lastRun: "尚未执行", nextRun: frequency === "每天" ? "明天 09:00" : "下周一 09:00", enabled: true, success: 100 }]);
|
||
setScheduleModal(false); notify("新任务已保存到本地");
|
||
}} />}
|
||
<div className="toast-stack" aria-live="polite">{toasts.map((toast) => <div className="toast" key={toast.id}><span>✓</span>{toast.message}</div>)}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Overview({ onNavigate, notify }: { onNavigate: (view: View) => void; notify: (message: string) => void }) {
|
||
return (
|
||
<>
|
||
<div className="stats-grid">
|
||
{mockData.stats.map((stat, index) => <article className="stat-card" key={stat.label}><div className={`stat-icon ${stat.tone}`}>{["▣", "✓", "♙", "↗"][index]}</div><div><p>{stat.label}</p><strong>{stat.value}</strong></div><span className={index === 1 ? "muted" : "positive"}>{stat.change}</span></article>)}
|
||
</div>
|
||
<div className="dashboard-grid">
|
||
<section className="panel project-panel">
|
||
<PanelHeader title="重点项目" subtitle="本周需要关注的项目进展" action="查看全部" onAction={() => onNavigate("content")} />
|
||
<div className="project-list compact">
|
||
{mockData.projects.map((project) => <article className="project-row" key={project.id}><img src={project.image} alt="" /><div className="project-info"><div className="project-title-line"><strong>{project.title}</strong><span className={`tag status-${project.status}`}>{project.status}</span></div><p>{project.description}</p><div className="progress-line"><span><i style={{ width: `${project.progress}%` }} /></span><b>{project.progress}%</b></div></div><div className="project-owner"><span className="mini-avatar">{project.owner.slice(-1)}</span><small>{project.owner}</small><b>{project.date}</b></div></article>)}
|
||
</div>
|
||
</section>
|
||
<section className="panel todo-panel">
|
||
<PanelHeader title="今日待办" subtitle="已完成 6 / 9" action="管理任务" onAction={() => onNavigate("board")} />
|
||
<div className="completion-bar"><i /></div>
|
||
<div className="todo-list">
|
||
{[["评审新版工作台原型", "10:00", true], ["与研发确认排期", "11:30", true], ["整理组件规范文档", "14:00", false], ["增长实验周会", "16:30", false]].map(([title, time, done]) => <label key={String(title)} className={done ? "done" : ""}><input type="checkbox" defaultChecked={Boolean(done)} onChange={() => notify("待办状态已更新")} /><span>{String(title)}</span><time>{String(time)}</time></label>)}
|
||
</div>
|
||
<button className="text-button add-todo" onClick={() => notify("已添加一条空白待办")}>+ 添加待办事项</button>
|
||
</section>
|
||
</div>
|
||
<section className="panel activity-panel">
|
||
<PanelHeader title="团队动态" subtitle="项目中的最新协作记录" action="全部动态" onAction={() => notify("暂无更多动态")} />
|
||
<div className="activity-list">
|
||
{[["周", "周屿", "完成了任务", "后台筛选器交互优化", "12 分钟前", "green"], ["许", "许言", "上传了文件", "八月增长计划-v3.pdf", "35 分钟前", "blue"], ["陈", "陈序", "评论了", "确认新用户引导文案", "1 小时前", "orange"], ["林", "林知夏", "创建了项目", "新版工作台视觉升级", "2 小时前", "purple"]].map(([avatar, name, verb, target, time, tone]) => <div className="activity-item" key={String(target)}><span className={`activity-avatar ${tone}`}>{avatar}</span><p><strong>{name}</strong> {verb} <b>{target}</b><small>{time}</small></p><button aria-label="更多" onClick={() => notify("更多操作")}>•••</button></div>)}
|
||
</div>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ComponentsShowcase({ notify }: { notify: (message: string) => void }) {
|
||
const [tab, setTab] = useState("基础组件");
|
||
return (
|
||
<div className="component-layout">
|
||
<div className="component-tabs">{["基础组件", "状态样式", "菜单示例"].map((item) => <button className={tab === item ? "active" : ""} key={item} onClick={() => setTab(item)}>{item}</button>)}</div>
|
||
{tab === "基础组件" && <>
|
||
<section className="panel specimen"><PanelHeader title="按钮 Buttons" subtitle="用于操作、提交和页面导航" /><div className="specimen-row"><button className="button primary" onClick={() => notify("主要按钮")}>主要按钮</button><button className="button secondary" onClick={() => notify("次要按钮")}>次要按钮</button><button className="button soft" onClick={() => notify("轻量按钮")}>轻量按钮</button><button className="button danger" onClick={() => notify("危险操作示例")}>危险操作</button><button className="button icon-combo" onClick={() => notify("下载开始")}>↓ 带图标按钮</button><button className="button secondary" disabled>禁用状态</button></div></section>
|
||
<section className="panel specimen"><PanelHeader title="标签 Tags" subtitle="用于分类、筛选与状态提示" /><div className="specimen-row tags-demo"><span className="tag blue">进行中</span><span className="tag green">已完成</span><span className="tag orange">待处理</span><span className="tag purple">评审中</span><span className="tag red">已逾期</span><span className="tag neutral">默认标签</span><span className="tag outlined">可关闭 ×</span></div></section>
|
||
<section className="panel specimen"><PanelHeader title="表单控件 Forms" subtitle="统一输入、选择与开关样式" /><div className="form-showcase"><label><span>项目名称</span><input defaultValue="新版工作台升级" /></label><label><span>所属团队</span><select defaultValue="design"><option value="design">产品设计组</option><option>研发团队</option></select></label><label><span>关键词</span><div className="input-with-icon"><i>⌕</i><input placeholder="搜索关键词" /></div></label><label className="switch-field"><span>公开项目<small>允许团队成员查看</small></span><input type="checkbox" defaultChecked /></label></div></section>
|
||
</>}
|
||
{tab === "状态样式" && <section className="panel specimen state-gallery"><PanelHeader title="反馈与状态" subtitle="覆盖成功、提示、警告和错误场景" /><div className="alert success"><b>✓</b><span><strong>保存成功</strong>你的修改已经同步到本地。</span><button>×</button></div><div className="alert info"><b>i</b><span><strong>版本提示</strong>这是一个纯前端 JSON 数据模板。</span><button>×</button></div><div className="alert warning"><b>!</b><span><strong>注意</strong>浏览器缓存清除后,本地修改将会重置。</span><button>×</button></div><div className="alert error"><b>×</b><span><strong>操作失败</strong>请检查必填字段后重试。</span><button>×</button></div></section>}
|
||
{tab === "菜单示例" && <section className="panel specimen menu-gallery"><PanelHeader title="菜单 Menu" subtitle="展示导航、操作菜单和分组层级" /><div className="menu-examples"><div className="demo-menu"><p>项目操作</p><button>▤ 查看详情 <kbd>⌘ V</kbd></button><button>✎ 编辑项目 <kbd>⌘ E</kbd></button><hr /><button>♙ 分享协作</button><button className="danger-text">♲ 删除项目</button></div><div className="demo-menu dark"><p>快速前往</p><button className="active">⌂ 工作台</button><button>▦ 流程泳道 <span>9</span></button><button>◷ 定时任务</button><small>最近访问</small><button>◈ 组件规范</button></div></div></section>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ContentView({ projects, notify }: { projects: typeof mockData.projects; notify: (message: string) => void }) {
|
||
const [display, setDisplay] = useState<"list" | "cards">("list");
|
||
return (
|
||
<section className="panel content-panel">
|
||
<div className="content-toolbar"><div className="filter-chips"><button className="active">全部内容 <b>24</b></button><button>进行中 <b>8</b></button><button>已归档 <b>16</b></button></div><div className="view-switch"><button className={display === "list" ? "active" : ""} onClick={() => setDisplay("list")}>☷</button><button className={display === "cards" ? "active" : ""} onClick={() => setDisplay("cards")}>▦</button></div></div>
|
||
{projects.length === 0 ? <div className="empty-state"><span>⌕</span><h3>没有找到匹配内容</h3><p>试试更换搜索关键词。</p></div> : display === "list" ? <div className="image-list">{projects.map((project) => <article key={project.id}><img src={project.image} alt={`${project.title}项目配图`} /><div><div className="content-title"><span className="tag blue">{project.tag}</span><span className={`tag status-${project.status}`}>{project.status}</span></div><h3>{project.title}</h3><p>{project.description}</p><small>负责人 {project.owner} · 截止 {project.date}</small></div><div className="content-progress"><strong>{project.progress}%</strong><span><i style={{ width: `${project.progress}%` }} /></span><button onClick={() => notify(`打开:${project.title}`)}>查看详情 →</button></div></article>)}</div> : <div className="content-cards">{projects.map((project) => <article key={project.id}><div className="card-image"><img src={project.image} alt="" /><span className="tag white">{project.tag}</span></div><div className="card-body"><span className={`tag status-${project.status}`}>{project.status}</span><h3>{project.title}</h3><p>{project.description}</p><div><span className="mini-avatar">{project.owner.slice(-1)}</span><small>{project.owner}</small><b>{project.progress}%</b></div></div></article>)}</div>}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function BoardView({ board, dragged, setDragged, moveCard, notify }: { board: BoardColumn[]; dragged: { cardId: string; columnId: string } | null; setDragged: (value: { cardId: string; columnId: string } | null) => void; moveCard: (columnId: string) => void; notify: (message: string) => void }) {
|
||
return <><div className="board-toolbar"><div className="avatar-stack"><span>林</span><span>周</span><span>许</span><span>唐</span><b>+8</b></div><div><button className="button secondary">☷ 筛选</button><button className="button secondary">↕ 排序</button></div></div><div className="kanban-board">{board.map((column) => <section className={`kanban-column ${dragged ? "dragging" : ""}`} key={column.id} onDragOver={(event) => event.preventDefault()} onDrop={() => moveCard(column.id)}><header><div><i style={{ background: column.color }} /><strong>{column.title}</strong><span>{column.cards.length}</span></div><button onClick={() => notify(`在“${column.title}”中添加任务`)}>+</button></header><div className="kanban-cards">{column.cards.map((card) => <article draggable key={card.id} onDragStart={() => setDragged({ cardId: card.id, columnId: column.id })} onDragEnd={() => setDragged(null)}><div className="kanban-card-top"><span>{card.id}</span><button onClick={() => notify("更多任务操作")}>•••</button></div><h3>{card.title}</h3><div className="kanban-tags"><span className="tag neutral">{card.tag}</span>{card.priority === "紧急" && <span className="tag red">紧急</span>}</div><footer><span className="mini-avatar">{card.owner.slice(-1)}</span><small>{card.owner}</small><time className={card.due === "今天" ? "today" : ""}>◷ {card.due}</time></footer></article>)}<button className="add-card" onClick={() => notify("添加卡片为模板示例")}>+ 添加任务</button></div></section>)}</div></>;
|
||
}
|
||
|
||
function ScheduleView({ schedules, toggleSchedule, notify, onCreate }: { schedules: typeof mockData.schedules; toggleSchedule: (id: number) => void; notify: (message: string) => void; onCreate: () => void }) {
|
||
const activeCount = schedules.filter((item) => item.enabled).length;
|
||
return <><div className="schedule-summary"><div><span className="summary-icon blue">◷</span><p>任务总数<strong>{schedules.length}</strong></p></div><div><span className="summary-icon green">▷</span><p>运行中<strong>{activeCount}</strong></p></div><div><span className="summary-icon purple">✓</span><p>本月执行<strong>128</strong></p></div><div><span className="summary-icon orange">↗</span><p>平均成功率<strong>98.5%</strong></p></div></div><section className="panel schedule-panel"><div className="schedule-toolbar"><div><button className="active">全部任务</button><button>运行中</button><button>已停用</button></div><button className="button primary" onClick={onCreate}>+ 新建定时任务</button></div><div className="table-wrap"><table><thead><tr><th>任务名称</th><th>执行规则</th><th>最近执行</th><th>下次执行</th><th>成功率</th><th>状态</th><th /></tr></thead><tbody>{schedules.map((schedule) => <tr key={schedule.id}><td><span className="task-type-icon">{schedule.type === "消息通知" ? "✧" : schedule.type === "文件归档" ? "▤" : "↻"}</span><div><strong>{schedule.name}</strong><small>{schedule.type}</small></div></td><td><strong>{schedule.frequency}</strong><code>{schedule.cron}</code></td><td>{schedule.lastRun}</td><td>{schedule.nextRun}</td><td><span className="success-rate"><i style={{ width: `${schedule.success}%` }} /></span>{schedule.success}%</td><td><button className={`toggle ${schedule.enabled ? "on" : ""}`} aria-label={`${schedule.enabled ? "停用" : "启用"}${schedule.name}`} onClick={() => toggleSchedule(schedule.id)}><i /></button><span className={schedule.enabled ? "running" : "disabled"}>{schedule.enabled ? "运行中" : "已停用"}</span></td><td><button className="row-action" onClick={() => notify(`编辑:${schedule.name}`)}>•••</button></td></tr>)}</tbody></table></div></section></>;
|
||
}
|
||
|
||
function ScheduleModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string, frequency: string) => void }) {
|
||
const [name, setName] = useState(""); const [frequency, setFrequency] = useState("每天");
|
||
return <div className="modal-backdrop" onMouseDown={onClose}><form className="modal" onMouseDown={(event) => event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (name.trim()) onSave(name.trim(), frequency); }}><header><div><h2>新建定时任务</h2><p>配置完成后会保存到当前浏览器。</p></div><button type="button" onClick={onClose}>×</button></header><label><span>任务名称</span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:每日数据汇总" required /></label><label><span>任务类型</span><select><option>数据同步</option><option>消息通知</option><option>文件归档</option><option>系统维护</option></select></label><fieldset><legend>执行频率</legend><label><input type="radio" name="frequency" checked={frequency === "每天"} onChange={() => setFrequency("每天")} />每天</label><label><input type="radio" name="frequency" checked={frequency === "每周"} onChange={() => setFrequency("每周")} />每周</label></fieldset><div className="time-row"><label><span>执行时间</span><input type="time" defaultValue="09:00" /></label><label><span>时区</span><select><option>Asia/Shanghai</option></select></label></div><footer><button type="button" className="button secondary" onClick={onClose}>取消</button><button className="button primary" type="submit">保存任务</button></footer></form></div>;
|
||
}
|
||
|
||
function PanelHeader({ title, subtitle, action, onAction }: { title: string; subtitle: string; action?: string; onAction?: () => void }) {
|
||
return <div className="panel-header"><div><h2>{title}</h2><p>{subtitle}</p></div>{action && <button onClick={onAction}>{action} →</button>}</div>;
|
||
}
|