Files
clinicpro/.claude/skills/qa-clinicpro/guide-shots.mjs
T
hamed 231ce793bc Add user guides for clinic management roles in Persian
- Create README.md for user guides covering roles: independent doctor, clinic owner, clinic manager, secretary, and staff.
- Add detailed step-by-step guides for setting up and managing clinics for clinic managers and clinic owners.
- Include instructions for independent doctors on setting up their practice.
- Provide a comprehensive guide for secretaries on daily operations and patient management.
- Introduce staff guide for nurses and technicians on session management.
2026-08-10 14:10:26 +03:30

284 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* guide-shots.mjs — تصویربردار راهنمای کاربری.
*
* برای هر نقش لاگین می‌کند، فهرست صفحاتش را می‌پیماید و از هرکدام یک PNG می‌گیرد.
* خروجی: <out>/<role>/<NN-slug>.png به‌همراه shots.json که ترتیب و عنوان‌ها را
* برای متن راهنما نگه می‌دارد.
*
* node guide-shots.mjs --role clinic-owner --out /tmp/guide
* node guide-shots.mjs --all --out /tmp/guide
*
* وابستگی ندارد: Node 22 با WebSocket سراسری مستقیم با CDP حرف می‌زند.
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const CHROME = process.env.CHROME_BIN
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PASSWORD = process.env.GUIDE_PASSWORD ?? 'QaTest@1234';
const PORT = Number(process.env.CDP_PORT ?? 9500);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
/**
* پنج شخصیتِ راهنما. هر صفحه یک گام از داستانِ همان نقش است، پس ترتیب مهم است و
* الفبایی نیست: کاربر تازه‌وارد از داشبورد شروع می‌کند، نه از تنظیمات.
*/
const ROLES = {
'independent-doctor': {
title: 'پزشک مستقل',
mobile: '0912000101',
pages: [
['dashboard', 'داشبورد'],
['appointments', 'نوبت‌های امروز'],
['appointments/new', 'ثبت نوبت جدید'],
['patients', 'پرونده‌ها'],
['my-patients', 'بیماران من'],
['clinic-services', 'سرویس‌ها'],
['service-categories', 'دسته‌بندی سرویس‌ها'],
['insurance-pricing', 'بیمه و تعرفه'],
['my-payments', 'پرداخت‌ها'],
['claims', 'مطالبات بیمه'],
['treatment-cases', 'دوره‌های درمان'],
['resources', 'منابع'],
['inventory', 'انبارداری'],
['my-secretaries', 'منشی‌ها'],
['staff', 'پرسنل'],
['settings-menu', 'تنظیمات'],
['appointment-settings', 'تنظیمات نوبت‌دهی'],
['holidays', 'تعطیلات'],
['record-number-settings', 'شمارهٔ پرونده'],
['tags-settings', 'برچسب‌ها'],
['sms-wallet', 'کیف پول پیامک'],
['subscription', 'اشتراک'],
['discounts', 'تخفیف‌ها'],
['profile', 'پروفایل پزشک'],
['account-settings', 'حساب کاربری'],
],
},
'clinic-owner-doctor': {
title: 'پزشکی که کلینیک دارد',
mobile: '0912000201',
// این حساب هم مطب شخصی دارد هم کلینیک، پس اپ اول «انتخاب محیط» می‌پرسد.
context: 'کلینیک',
pages: [
['dashboard', 'داشبورد'],
['appointments', 'نوبت‌های کلینیک'],
['my-clinic', 'کلینیک من'],
['settings/clinic-doctors', 'پزشکان کلینیک'],
['settings/appointment-settings', 'نوبت‌دهی کلینیک'],
['settings/practice-domain', 'حوزهٔ فعالیت'],
['clinic-services', 'سرویس‌های کلینیک'],
['resources', 'منابع'],
['resources/types', 'نوع منابع'],
['resources/pools', 'استخر منابع'],
['resources/skills', 'مهارت‌ها'],
['staff', 'پرسنل'],
['my-secretaries', 'منشی‌ها'],
['patients', 'پرونده‌ها'],
['treatment-cases', 'دوره‌های درمان'],
['my-payments', 'پرداخت‌ها'],
['my-financial', 'مدیریت پرداخت'],
['claims', 'مطالبات بیمه'],
['inventory', 'انبارداری'],
['settings-menu', 'تنظیمات'],
['subscription', 'اشتراک'],
],
},
'clinic-manager': {
title: 'مدیر کلینیک',
mobile: '0912000301',
pages: [
['dashboard', 'داشبورد'],
['appointments', 'نوبت‌های درمانگاه'],
['my-clinic', 'کلینیک من'],
['settings/clinic-doctors', 'پزشکان'],
['settings/appointment-settings', 'نوبت‌دهی'],
['clinic-services', 'سرویس‌ها'],
['service-categories', 'دسته‌بندی‌ها'],
['resources', 'منابع'],
['staff', 'پرسنل'],
['my-secretaries', 'منشی‌ها'],
['patients', 'پرونده‌ها'],
['my-payments', 'پرداخت‌ها'],
['claims', 'مطالبات بیمه'],
['insurance-pricing', 'بیمه و تعرفه'],
['inventory', 'انبارداری'],
['treatment-cases', 'دوره‌های درمان'],
['settings-menu', 'تنظیمات'],
['sms-wallet', 'کیف پول پیامک'],
['subscription', 'اشتراک'],
],
},
secretary: {
title: 'منشی',
mobile: '0912000209',
pages: [
['appointments', 'نوبت‌ها'],
['appointments/new', 'ثبت نوبت'],
['appointments/reserve', 'نوبت رزرو'],
['patients', 'پرونده‌ها'],
['patients/new', 'تشکیل پرونده'],
['my-payments', 'پرداخت‌ها'],
['claims', 'مطالبات بیمه'],
['treatment-cases', 'دوره‌های درمان'],
['secretary-earnings', 'درآمد من'],
['secretary-settlement', 'تسویه حساب'],
['account-settings', 'حساب کاربری'],
],
},
staff: {
title: 'پرسنل',
mobile: '09120002105',
pages: [
['my-sessions', 'جلسات امروز من'],
['account-settings', 'حساب کاربری'],
],
},
};
const args = process.argv.slice(2);
const argOf = (name, fallback = null) => {
const i = args.indexOf(`--${name}`);
return i === -1 ? fallback : args[i + 1];
};
const OUT = argOf('out', '/tmp/guide');
const WIDTH = Number(argOf('w', 1440));
const HEIGHT = Number(argOf('h', 900));
const WAIT = Number(argOf('wait', 3500));
const wanted = args.includes('--all') ? Object.keys(ROLES) : [argOf('role')].filter(Boolean);
if (wanted.length === 0) {
console.error('استفاده: --role <name> یا --all');
console.error('نقش‌ها: ' + Object.keys(ROLES).join(', '));
process.exit(1);
}
async function login(mobile) {
const res = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: mobile, password: PASSWORD }),
});
const body = await res.json();
if (!body.access_token) {
throw new Error(`ورود ${mobile} ناموفق: ${JSON.stringify(body).slice(0, 160)}`);
}
return body;
}
async function cdpUrl() {
for (let i = 0; i < 50; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
return (await r.json()).webSocketDebuggerUrl;
} catch { await new Promise((r) => setTimeout(r, 300)); }
}
throw new Error('Chrome بالا نیامد');
}
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
'--ignore-certificate-errors', '--force-device-scale-factor=2',
`--remote-debugging-port=${PORT}`, `--user-data-dir=/tmp/guide-shots-${process.pid}`,
`--window-size=${WIDTH},${HEIGHT}`, 'about:blank',
], { stdio: 'ignore' });
const ws = new WebSocket(await cdpUrl());
await new Promise((r) => ws.addEventListener('open', r, { once: true }));
let msgId = 0;
const pending = new Map();
ws.addEventListener('message', (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
});
const rpc = (method, params, sessionId) => new Promise((resolve) => {
const id = ++msgId;
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params, sessionId }));
});
const { targetId } = await rpc('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await rpc('Target.attachToTarget', { targetId, flatten: true });
const S = (m, p) => rpc(m, p, sessionId);
await S('Page.enable');
await S('Runtime.enable');
const evalJs = async (expression) => {
const { result } = await S('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
return result?.value;
};
// اجرای تک‌نقشی نباید فهرست نقش‌های قبلی را پاک کند.
const manifestPath = join(OUT, 'shots.json');
const manifest = existsSync(manifestPath)
? JSON.parse(readFileSync(manifestPath, 'utf8'))
: {};
for (const roleKey of wanted) {
const role = ROLES[roleKey];
const dir = join(OUT, roleKey);
mkdirSync(dir, { recursive: true });
const { access_token, refresh_token } = await login(role.mobile);
// localStorage به origin وابسته است؛ اول باید روی همان دامنه باشیم.
await S('Page.navigate', { url: `${BASE}/admin/login` });
await new Promise((r) => setTimeout(r, 1500));
await evalJs(`localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify({
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true }, version: 0,
}))});
localStorage.setItem('pwa-dismissed','1');
// تور راهنما روی تصویرها می‌افتد و متنِ خودِ راهنما را می‌پوشاند.
localStorage.setItem('clinicpro-tours-suppress','1');`);
// حسابِ چندمحیطی تا محیطش را انتخاب نکند، هر مسیری به «انتخاب محیط» می‌رود.
if (role.context) {
await S('Page.navigate', { url: `${BASE}/admin/select-context` });
await new Promise((r) => setTimeout(r, 2500));
const picked = await evalJs(`(() => {
const btn = [...document.querySelectorAll('button')]
.find(b => b.textContent.includes(${JSON.stringify(role.context)}));
if (!btn) return false;
btn.click();
return btn.textContent.trim().slice(0, 60);
})()`);
await new Promise((r) => setTimeout(r, 2500));
console.log(` محیط انتخاب شد: ${picked || 'پیدا نشد ⚠'}`);
}
manifest[roleKey] = { title: role.title, mobile: role.mobile, shots: [] };
console.log(`\n### ${role.title} (${role.mobile})`);
for (const [index, [path, label]] of role.pages.entries()) {
const file = `${String(index + 1).padStart(2, '0')}-${path.replace(/\//g, '-')}.png`;
await S('Page.navigate', { url: `${BASE}/admin/${path}` });
await new Promise((r) => setTimeout(r, WAIT));
// تور خودکار روی صفحه می‌نشیند؛ بسته می‌شود تا تصویر خودِ صفحه را نشان دهد.
await evalJs(`(() => {
const close = document.querySelector('.driver-popover-close-btn');
if (close) close.click();
})()`);
await new Promise((r) => setTimeout(r, 400));
const landed = await evalJs('location.pathname');
const empty = await evalJs('(document.body.innerText || "").trim().length < 40');
const shot = await S('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(dir, file), Buffer.from(shot.data, 'base64'));
const note = landed.endsWith(path) ? '' : ` (منتقل شد به ${landed})`;
console.log(` ${empty ? '⚠' : '✓'} ${label}${file}${note}`);
manifest[roleKey].shots.push({ file, path, label, landed, empty });
}
}
mkdirSync(OUT, { recursive: true });
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`\nفهرست: ${manifestPath}`);
ws.close();
chrome.kill();