287 lines
14 KiB
JavaScript
287 lines
14 KiB
JavaScript
// Students / CRM screen.
|
||
|
||
const STATUS_META = {
|
||
active: { fa: 'فعال', cls: 'ok' },
|
||
completed: { fa: 'فارغالتحصیل', cls: 'info' },
|
||
'at-risk': { fa: 'خطر ریزش', cls: 'warn' },
|
||
};
|
||
|
||
function StudentsScreen() {
|
||
const { STUDENTS, COURSES } = window.DATA;
|
||
const [tab, setTab] = React.useState('all');
|
||
const [selected, setSelected] = React.useState(new Set());
|
||
const [detail, setDetail] = React.useState(STUDENTS[0]);
|
||
const [search, setSearch] = React.useState('');
|
||
|
||
const filtered = STUDENTS.filter(s => {
|
||
if (tab !== 'all' && s.status !== tab) return false;
|
||
if (search && !s.name.includes(search) && !s.course.includes(search)) return false;
|
||
return true;
|
||
});
|
||
|
||
const toggleSel = (id) => {
|
||
const next = new Set(selected);
|
||
next.has(id) ? next.delete(id) : next.add(id);
|
||
setSelected(next);
|
||
};
|
||
|
||
const segmentCounts = {
|
||
all: STUDENTS.length,
|
||
active: STUDENTS.filter(s => s.status === 'active').length,
|
||
completed: STUDENTS.filter(s => s.status === 'completed').length,
|
||
'at-risk': STUDENTS.filter(s => s.status === 'at-risk').length,
|
||
};
|
||
|
||
return (
|
||
<div className="page-inner">
|
||
{/* header */}
|
||
<div className="page-h">
|
||
<div>
|
||
<h1>دانشجویان</h1>
|
||
<p className="lede">مدیریت ارتباط با {STUDENTS.length} دانشجو در ۲ دوره</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn"><Icon name="upload" className="ic-sm" /> ورود از فایل</button>
|
||
<button className="btn"><Icon name="send" className="ic-sm" /> پیام انبوه</button>
|
||
<button className="btn primary"><Icon name="plus" className="ic-sm" /> افزودن دانشجو</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* stat strip */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 'var(--gap-md)' }}>
|
||
{[
|
||
{ l: 'کل دانشجویان', v: STUDENTS.length, sub: '+۴۸ این ماه', good: true },
|
||
{ l: 'فعال این هفته', v: STUDENTS.filter(s => s.status === 'active').length, sub: '۸۲٪ نرخ فعالیت', good: true },
|
||
{ l: 'تکمیلکنندگان', v: STUDENTS.filter(s => s.status === 'completed').length, sub: 'نرخ ۴۲٪', good: true },
|
||
{ l: 'در معرض ریزش', v: STUDENTS.filter(s => s.status === 'at-risk').length, sub: 'نیاز به پیگیری', good: false },
|
||
].map((s, i) => (
|
||
<div key={i} className="card card-pad">
|
||
<div className="muted" style={{ fontSize: 12, marginBottom: 8 }}>{s.l}</div>
|
||
<div className="num" style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-0.01em' }}>
|
||
{String(s.v).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d])}
|
||
</div>
|
||
<div style={{ marginTop: 8, fontSize: 11.5 }}>
|
||
<span className={`pill ${s.good ? 'ok' : 'warn'}`}>{s.sub}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* main: list + detail */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: detail ? '1.6fr 1fr' : '1fr', gap: 'var(--gap-lg)', alignItems: 'flex-start' }}>
|
||
<div className="card">
|
||
{/* filters */}
|
||
<div style={{ padding: '14px 20px 0 20px' }}>
|
||
<div className="tabs" style={{ marginInline: -20, paddingInline: 20 }}>
|
||
{[
|
||
['all', 'همه'],
|
||
['active', 'فعال'],
|
||
['completed', 'تکمیلشده'],
|
||
['at-risk', 'در معرض ریزش'],
|
||
].map(([k, v]) => (
|
||
<button key={k} className="tab" aria-selected={tab === k} onClick={() => setTab(k)}>
|
||
{v}
|
||
<span className="num" style={{
|
||
marginInlineStart: 6, fontSize: 11,
|
||
background: tab === k ? 'var(--accent-soft)' : 'var(--line-soft)',
|
||
color: tab === k ? 'var(--accent-ink)' : 'var(--ink-3)',
|
||
padding: '1px 6px', borderRadius: 'var(--r-pill)'
|
||
}}>
|
||
{String(segmentCounts[k]).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d])}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 10, padding: '14px 0 14px 0', alignItems: 'center' }}>
|
||
<div className="search" style={{ flex: 1, width: 'auto', maxWidth: 360 }}>
|
||
<Icon name="search" className="ic-sm" />
|
||
<input placeholder="جستجوی نام یا دوره…" value={search} onChange={e => setSearch(e.target.value)} />
|
||
</div>
|
||
<button className="btn sm"><Icon name="filter" className="ic-sm" /> همه دورهها</button>
|
||
<button className="btn sm"><Icon name="sort" className="ic-sm" /> مرتبسازی</button>
|
||
<div style={{ flex: 1 }} />
|
||
{selected.size > 0 && (
|
||
<span className="pill accent num">
|
||
{String(selected.size).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d])} انتخاب شده
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* table */}
|
||
<div style={{ overflow: 'auto' }}>
|
||
<table className="tbl">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 36 }}>
|
||
<input type="checkbox" className="chk"
|
||
checked={selected.size === filtered.length && filtered.length > 0}
|
||
onChange={() => {
|
||
if (selected.size === filtered.length) setSelected(new Set());
|
||
else setSelected(new Set(filtered.map(s => s.id)));
|
||
}} />
|
||
</th>
|
||
<th>دانشجو</th>
|
||
<th>دوره</th>
|
||
<th>پیشرفت</th>
|
||
<th>وضعیت</th>
|
||
<th>آخرین فعالیت</th>
|
||
<th style={{ width: 40 }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map(s => (
|
||
<tr key={s.id} onClick={() => setDetail(s)}
|
||
style={{ background: detail?.id === s.id ? 'var(--accent-soft)' : undefined }}>
|
||
<td onClick={e => e.stopPropagation()}>
|
||
<input type="checkbox" className="chk"
|
||
checked={selected.has(s.id)}
|
||
onChange={() => toggleSel(s.id)} />
|
||
</td>
|
||
<td>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div className="av" style={{ background: s.color, fontSize: 11 }}>
|
||
{s.initials}
|
||
</div>
|
||
<div>
|
||
<div style={{ fontWeight: 600 }}>{s.name}</div>
|
||
<div className="muted" style={{ fontSize: 11.5 }}>{s.tag}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td><span className="muted" style={{ fontSize: 12.5 }}>{s.course}</span></td>
|
||
<td>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div className="prog" style={{ width: 80 }}>
|
||
<i style={{ width: s.progress + '%', background: s.progress < 30 ? 'oklch(0.78 0.14 75)' : (s.progress >= 100 ? 'oklch(0.62 0.14 155)' : 'var(--accent)') }} />
|
||
</div>
|
||
<span className="num" style={{ fontSize: 12, fontWeight: 600, minWidth: 32 }}>
|
||
{String(s.progress).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d])}٪
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td><span className={`pill ${STATUS_META[s.status].cls}`}>{STATUS_META[s.status].fa}</span></td>
|
||
<td><span className="muted" style={{ fontSize: 12.5 }}>{s.lastActive}</span></td>
|
||
<td onClick={e => e.stopPropagation()}>
|
||
<button className="btn ghost sm"><Icon name="more" className="ic-sm" /></button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* footer */}
|
||
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--line-soft)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span className="muted" style={{ fontSize: 12 }}>
|
||
نمایش {filtered.length} از {STUDENTS.length} دانشجو
|
||
</span>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<button className="btn sm"><Icon name="chevron" className="ic-sm" /></button>
|
||
<button className="btn sm" style={{ background: 'var(--accent-soft)', color: 'var(--accent-ink)', borderColor: 'transparent' }}>۱</button>
|
||
<button className="btn sm">۲</button>
|
||
<button className="btn sm">۳</button>
|
||
<button className="btn sm"><Icon name="chevron" className="ic-sm flipx" /></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* detail panel */}
|
||
{detail && (
|
||
<div className="col">
|
||
<div className="card card-pad">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||
<div className="av av-lg" style={{ background: detail.color, width: 56, height: 56, fontSize: 16 }}>
|
||
{detail.initials}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700 }}>{detail.name}</div>
|
||
<div className="muted num" style={{ fontSize: 12, marginTop: 2 }}>
|
||
عضو از {detail.joined}
|
||
</div>
|
||
</div>
|
||
<button className="btn ghost sm"><Icon name="more" className="ic-sm" /></button>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
|
||
<button className="btn sm" style={{ flex: 1 }}><Icon name="mail" className="ic-sm" /> پیام</button>
|
||
<button className="btn sm" style={{ flex: 1 }}><Icon name="phone" className="ic-sm" /> تماس</button>
|
||
<button className="btn sm"><Icon name="star" className="ic-sm" /></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-h">
|
||
<h3>پیشرفت در دوره</h3>
|
||
</div>
|
||
<div className="card-pad col">
|
||
<div>
|
||
<div className="between" style={{ marginBottom: 8 }}>
|
||
<span style={{ fontSize: 13, fontWeight: 500 }}>{detail.course}</span>
|
||
<span className="num" style={{ fontSize: 13, fontWeight: 600 }}>
|
||
{String(detail.progress).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d])}٪
|
||
</span>
|
||
</div>
|
||
<div className="prog">
|
||
<i style={{ width: detail.progress + '%' }} />
|
||
</div>
|
||
<div className="muted num" style={{ fontSize: 11.5, marginTop: 8, display: 'flex', justifyContent: 'space-between' }}>
|
||
<span>۲۴ از ۳۸ درس</span>
|
||
<span>۸ ساعت تماشا</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="divider" />
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
||
{[
|
||
{ l: 'مبلغ پرداختی', v: detail.spent + ' ت', cls: 'num' },
|
||
{ l: 'آزمونهای موفق', v: '۳ از ۴', cls: 'num' },
|
||
{ l: 'تمرین ارسالی', v: '۵', cls: 'num' },
|
||
{ l: 'امتیاز رضایت', v: '⭐ ۴٫۸', cls: '' },
|
||
].map((s, i) => (
|
||
<div key={i} style={{ padding: 10, background: 'var(--surface-2)', borderRadius: 'var(--r-sm)' }}>
|
||
<div className="muted" style={{ fontSize: 11 }}>{s.l}</div>
|
||
<div className={s.cls} style={{ fontSize: 14, fontWeight: 600, marginTop: 2 }}>{s.v}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-h">
|
||
<h3>تایملاین</h3>
|
||
</div>
|
||
<div style={{ padding: '6px 0 14px' }}>
|
||
{[
|
||
{ ic: 'check', t: 'درس «تئوری رنگ کاربردی» را تمام کرد', when: '۲ ساعت پیش', color: 'oklch(0.62 0.14 155)' },
|
||
{ ic: 'chat', t: 'پرسشی در گفتوگوی دوره مطرح کرد', when: 'دیروز', color: 'oklch(0.70 0.12 240)' },
|
||
{ ic: 'quiz', t: 'به آزمون فصل اول نمره ۹۲ گرفت', when: '۳ روز پیش', color: 'oklch(0.72 0.12 280)' },
|
||
{ ic: 'star', t: 'دوره را خریداری کرد', when: detail.joined, color: 'oklch(0.74 0.13 50)' },
|
||
].map((x, i) => (
|
||
<div key={i} style={{ display: 'flex', gap: 12, padding: '8px 20px', alignItems: 'flex-start' }}>
|
||
<div style={{
|
||
width: 24, height: 24, borderRadius: '50%',
|
||
background: 'color-mix(in oklch, ' + x.color + ' 22%, transparent)',
|
||
color: x.color,
|
||
display: 'grid', placeItems: 'center', flexShrink: 0,
|
||
}}>
|
||
<Icon name={x.ic} className="ic-sm" style={{ width: 12, height: 12 }} />
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 13 }}>{x.t}</div>
|
||
<div className="muted num" style={{ fontSize: 11, marginTop: 1 }}>{x.when}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
window.StudentsScreen = StudentsScreen;
|