641 lines
24 KiB
JavaScript
641 lines
24 KiB
JavaScript
const $ = (id) => document.getElementById(id);
|
|
const STORAGE_KEY = 'ssa_last_session_id';
|
|
const state = {
|
|
sessionId: null,
|
|
current: null,
|
|
drafts: [],
|
|
};
|
|
|
|
const stageLabels = {
|
|
intro: 'تمهيد',
|
|
exploration: 'استكشاف',
|
|
usecase: 'حالات الاستخدام',
|
|
evaluation: 'تقييم',
|
|
done: 'مكتمل',
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
function init() {
|
|
$('start-btn').addEventListener('click', startSession);
|
|
$('project-title').addEventListener('keydown', (e) => { if (e.key === 'Enter') startSession(); });
|
|
$('send-btn').addEventListener('click', sendMessage);
|
|
$('input').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
$('input').addEventListener('input', autoGrow);
|
|
$('srs-text').addEventListener('input', autoGrow);
|
|
$('actors-editor').addEventListener('input', autoGrow);
|
|
$('usecases-editor').addEventListener('input', autoGrow);
|
|
$('io-editor').addEventListener('input', autoGrow);
|
|
$('scope-editor').addEventListener('input', autoGrow);
|
|
$('analyze-srs-btn').addEventListener('click', analyzeSrs);
|
|
$('confirm-srs-btn').addEventListener('click', confirmStructure);
|
|
$('save-planning-btn').addEventListener('click', savePlanning);
|
|
$('export-btn').addEventListener('click', () => {
|
|
if (state.sessionId) window.open(`/report/${state.sessionId}`, '_blank');
|
|
});
|
|
$('srs-file').addEventListener('change', () => {
|
|
const file = $('srs-file').files?.[0];
|
|
$('srs-file-name').textContent = file ? `الملف المختار: ${file.name}` : 'لم يتم اختيار ملف بعد.';
|
|
});
|
|
|
|
loadDrafts();
|
|
}
|
|
|
|
function autoGrow(event) {
|
|
const target = event.target;
|
|
if (!target || target.tagName !== 'TEXTAREA') return;
|
|
target.style.height = 'auto';
|
|
target.style.height = Math.min(target.scrollHeight, target.classList.contains('editor-textarea') ? 220 : 160) + 'px';
|
|
}
|
|
|
|
async function loadDrafts() {
|
|
try {
|
|
const res = await fetch('/api/sessions');
|
|
const data = await res.json();
|
|
state.drafts = Array.isArray(data.sessions) ? data.sessions : [];
|
|
renderDraftLists();
|
|
} catch (error) {
|
|
console.error('loadDrafts error', error);
|
|
}
|
|
}
|
|
|
|
function renderDraftLists() {
|
|
renderDraftList('welcome-drafts', true);
|
|
renderDraftList('sidebar-drafts', false);
|
|
}
|
|
|
|
function renderDraftList(containerId, compact = false) {
|
|
const container = $(containerId);
|
|
const drafts = state.drafts || [];
|
|
const lastId = localStorage.getItem(STORAGE_KEY);
|
|
|
|
if (!drafts.length) {
|
|
container.innerHTML = '<div class="draft-empty">لا توجد مسودات محفوظة بعد.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = drafts.map((draft) => {
|
|
const active = draft.id === state.sessionId;
|
|
const last = draft.id === lastId;
|
|
const counts = draft.counts || {};
|
|
return `
|
|
<button class="draft-card${active ? ' active' : ''}${last ? ' last-opened' : ''}" data-session-id="${esc(draft.id)}">
|
|
<div class="draft-card-top">
|
|
<div class="draft-title">${esc(draft.title || 'مسودة بدون عنوان')}</div>
|
|
${last && !active ? '<span class="tiny-badge">آخر جلسة</span>' : ''}
|
|
</div>
|
|
<div class="draft-meta">${esc(draft.checkpointLabel || draft.stage || '—')}</div>
|
|
<div class="draft-stats">
|
|
<span>${counts.useCases || 0} UC</span>
|
|
<span>${counts.functionPoints || 0} FP</span>
|
|
<span>${counts.inputOutputs || 0} IO</span>
|
|
</div>
|
|
<div class="draft-date">${formatDate(draft.updatedAt, compact)}</div>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
|
|
container.querySelectorAll('.draft-card').forEach((btn) => {
|
|
btn.addEventListener('click', () => resumeSession(btn.dataset.sessionId));
|
|
});
|
|
}
|
|
|
|
function formatDate(value, compact = false) {
|
|
if (!value) return 'بدون تاريخ';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return 'بدون تاريخ';
|
|
const formatter = new Intl.DateTimeFormat('ar-EG', {
|
|
year: 'numeric',
|
|
month: compact ? 'short' : 'long',
|
|
day: 'numeric',
|
|
hour: compact ? undefined : 'numeric',
|
|
minute: compact ? undefined : '2-digit',
|
|
});
|
|
return formatter.format(date);
|
|
}
|
|
|
|
function showApp(title) {
|
|
$('welcome').classList.add('hidden');
|
|
$('app').classList.remove('hidden');
|
|
$('project-name').textContent = title || '—';
|
|
}
|
|
|
|
function showResumeBanner(text) {
|
|
const banner = $('resume-banner');
|
|
banner.textContent = text;
|
|
banner.classList.remove('hidden');
|
|
}
|
|
|
|
function hideResumeBanner() {
|
|
$('resume-banner').classList.add('hidden');
|
|
$('resume-banner').textContent = '';
|
|
}
|
|
|
|
async function startSession() {
|
|
const title = $('project-title').value.trim();
|
|
if (!title) {
|
|
$('project-title').focus();
|
|
return;
|
|
}
|
|
|
|
$('start-btn').disabled = true;
|
|
try {
|
|
const res = await fetch('/api/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'فشل بدء الجلسة');
|
|
|
|
state.sessionId = data.sessionId;
|
|
localStorage.setItem(STORAGE_KEY, data.sessionId);
|
|
state.current = data;
|
|
clearMessages();
|
|
hideResumeBanner();
|
|
showApp(title);
|
|
addAgentMessage(data.reply || 'تم بدء الجلسة.', true);
|
|
updateState(data);
|
|
await loadDrafts();
|
|
$('input').focus();
|
|
} catch (error) {
|
|
alert(`خطأ: ${error.message}`);
|
|
} finally {
|
|
$('start-btn').disabled = false;
|
|
}
|
|
}
|
|
|
|
async function resumeSession(sessionId) {
|
|
try {
|
|
const res = await fetch(`/api/session/${encodeURIComponent(sessionId)}`);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'تعذر استئناف الجلسة');
|
|
|
|
state.sessionId = data.sessionId;
|
|
state.current = data;
|
|
localStorage.setItem(STORAGE_KEY, data.sessionId);
|
|
showApp(data.title);
|
|
renderHistory(data.history || []);
|
|
updateState(data);
|
|
showResumeBanner(data.resumeMessage || 'أهلاً بك مجدداً، يمكننا متابعة العمل من آخر نقطة توقفنا عندها.');
|
|
$('input').focus();
|
|
await loadDrafts();
|
|
} catch (error) {
|
|
alert(`خطأ: ${error.message}`);
|
|
if (String(error.message).includes('غير موجودة')) {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const input = $('input');
|
|
const text = input.value.trim();
|
|
if (!text || !state.sessionId) return;
|
|
|
|
hideResumeBanner();
|
|
addUserMessage(text);
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
$('send-btn').disabled = true;
|
|
const thinkingEl = addAgentMessage('يفكّر...', false, true);
|
|
|
|
try {
|
|
const res = await fetch('/api/message', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionId: state.sessionId, message: text }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'فشل الإرسال');
|
|
thinkingEl.remove();
|
|
addAgentMessage(data.reply || 'تم تحديث التحليل.', true);
|
|
updateState(data);
|
|
await loadDrafts();
|
|
} catch (error) {
|
|
thinkingEl.querySelector('.bubble').textContent = `⚠️ ${error.message}`;
|
|
thinkingEl.classList.remove('thinking');
|
|
} finally {
|
|
$('send-btn').disabled = false;
|
|
$('input').focus();
|
|
}
|
|
}
|
|
|
|
async function analyzeSrs() {
|
|
if (!state.sessionId) {
|
|
alert('ابدأ جلسة أولاً ثم ارفع ملف SRS أو الصق النص.');
|
|
return;
|
|
}
|
|
|
|
const file = $('srs-file').files?.[0];
|
|
let content = $('srs-text').value.trim();
|
|
let filename = '';
|
|
|
|
if (!content && file) {
|
|
filename = file.name;
|
|
content = await file.text();
|
|
} else if (file) {
|
|
filename = file.name;
|
|
}
|
|
|
|
if (!content.trim()) {
|
|
alert('أرفق ملفاً نصياً بسيطاً أو الصق نص المتطلبات أولاً.');
|
|
return;
|
|
}
|
|
|
|
if (looksBinary(content)) {
|
|
alert('الملف يبدو غير نصي. في هذه النسخة يُفضَّل رفع ملف نصي بسيط أو لصق محتوى SRS يدوياً.');
|
|
return;
|
|
}
|
|
|
|
addUserMessage(filename ? `📄 تم إرسال ملف SRS للتحليل: ${filename}` : '📄 تم إرسال نص SRS للتحليل');
|
|
const thinkingEl = addAgentMessage('أحلّل المستند وأستخرج Actors وUse Cases وInputs/Outputs...', false, true);
|
|
|
|
try {
|
|
const res = await fetch('/api/analyze-srs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionId: state.sessionId, content, filename }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'فشل تحليل المستند');
|
|
thinkingEl.remove();
|
|
addAgentMessage(data.reply || 'تم تحليل المستند.', true);
|
|
updateState(data);
|
|
await loadDrafts();
|
|
} catch (error) {
|
|
thinkingEl.querySelector('.bubble').textContent = `⚠️ ${error.message}`;
|
|
thinkingEl.classList.remove('thinking');
|
|
}
|
|
}
|
|
|
|
async function confirmStructure() {
|
|
if (!state.sessionId) return;
|
|
|
|
const actors = parseSimpleList($('actors-editor').value);
|
|
const useCases = parseUseCases($('usecases-editor').value, state.current?.useCases || []);
|
|
const inputOutputs = parseInputOutputs($('io-editor').value, state.current?.inputOutputs || []);
|
|
const scope = $('scope-editor').value.trim();
|
|
|
|
if (!actors.length && !useCases.length && !inputOutputs.length && !scope) {
|
|
alert('لا توجد بيانات لاعتمادها بعد. حلّل SRS أولاً أو أدخل القوائم يدوياً.');
|
|
return;
|
|
}
|
|
|
|
addUserMessage('✅ تم اعتماد/تعديل الهيكل الأولي للمشروع.');
|
|
const thinkingEl = addAgentMessage('أثبّت القوائم وأعيد حساب التقدير...', false, true);
|
|
|
|
try {
|
|
const res = await fetch('/api/confirm-structure', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionId: state.sessionId, scope, actors, useCases, inputOutputs }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'فشل اعتماد الاقتراح');
|
|
thinkingEl.remove();
|
|
addAgentMessage(data.reply || 'تم اعتماد الهيكل الأولي.', true);
|
|
updateState(data);
|
|
await loadDrafts();
|
|
} catch (error) {
|
|
thinkingEl.querySelector('.bubble').textContent = `⚠️ ${error.message}`;
|
|
thinkingEl.classList.remove('thinking');
|
|
}
|
|
}
|
|
|
|
async function savePlanning() {
|
|
if (!state.sessionId) return;
|
|
|
|
$('planning-status').textContent = 'جارٍ تحديث خطة التنفيذ...';
|
|
try {
|
|
const res = await fetch(`/api/session/${encodeURIComponent(state.sessionId)}/planning`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
developers: Number($('team-size').value || 3),
|
|
sprintWeeks: Number($('sprint-weeks').value || 2),
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'فشل تحديث التخطيط');
|
|
updateState(data);
|
|
$('planning-status').textContent = 'تم تحديث الخطة والمدة المتوقعة بنجاح.';
|
|
await loadDrafts();
|
|
} catch (error) {
|
|
$('planning-status').textContent = `⚠️ ${error.message}`;
|
|
}
|
|
}
|
|
|
|
function clearMessages() {
|
|
$('messages').innerHTML = '';
|
|
}
|
|
|
|
function renderHistory(history) {
|
|
clearMessages();
|
|
history.forEach((entry) => {
|
|
if (entry.role === 'user') addUserMessage(entry.text || '', false);
|
|
else if (entry.role === 'assistant') addAgentMessage(entry.payload?.reply || '', false, false, false);
|
|
});
|
|
scrollMsgs();
|
|
}
|
|
|
|
function addUserMessage(text, scroll = true) {
|
|
const el = document.createElement('div');
|
|
el.className = 'msg user';
|
|
el.innerHTML = `<div class="avatar">أنا</div><div class="bubble"></div>`;
|
|
el.querySelector('.bubble').textContent = text;
|
|
$('messages').appendChild(el);
|
|
if (scroll) scrollMsgs();
|
|
return el;
|
|
}
|
|
|
|
function addAgentMessage(text, typewriter = false, thinking = false, scroll = true) {
|
|
document.querySelectorAll('.msg.agent.fresh').forEach((node) => node.classList.remove('fresh'));
|
|
const el = document.createElement('div');
|
|
el.className = `msg agent fresh${thinking ? ' thinking' : ''}`;
|
|
el.innerHTML = `<div class="avatar">AI</div><div class="bubble${thinking ? ' typing' : ''}"></div>`;
|
|
$('messages').appendChild(el);
|
|
const bubble = el.querySelector('.bubble');
|
|
if (typewriter) typeText(bubble, text);
|
|
else bubble.textContent = text;
|
|
if (scroll) scrollMsgs();
|
|
return el;
|
|
}
|
|
|
|
function typeText(el, text) {
|
|
el.textContent = '';
|
|
const cursor = document.createElement('span');
|
|
cursor.className = 'cursor';
|
|
el.appendChild(cursor);
|
|
let i = 0;
|
|
const speed = Math.max(8, Math.min(28, 1200 / Math.max(text.length, 12)));
|
|
|
|
const tick = () => {
|
|
if (i < text.length) {
|
|
cursor.insertAdjacentText('beforebegin', text[i]);
|
|
i += 1;
|
|
scrollMsgs();
|
|
setTimeout(tick, speed);
|
|
} else {
|
|
cursor.remove();
|
|
}
|
|
};
|
|
tick();
|
|
}
|
|
|
|
function scrollMsgs() {
|
|
const messages = $('messages');
|
|
messages.scrollTop = messages.scrollHeight;
|
|
}
|
|
|
|
function updateState(data) {
|
|
state.current = data;
|
|
state.sessionId = data.sessionId || data.id || state.sessionId;
|
|
localStorage.setItem(STORAGE_KEY, state.sessionId);
|
|
|
|
const fps = data.functionPoints || [];
|
|
const ucs = data.useCases || [];
|
|
const ios = data.inputOutputs || [];
|
|
const actors = data.actors || [];
|
|
const analytics = data.analytics || {};
|
|
const planning = data.planning || {};
|
|
|
|
$('project-name').textContent = data.title || $('project-name').textContent;
|
|
$('fp-count').textContent = fps.length;
|
|
$('uc-count').textContent = ucs.length;
|
|
$('io-count').textContent = ios.length;
|
|
$('fp-total').textContent = analytics.fp?.totalFP ?? fps.reduce((sum, item) => sum + (Number(item.fpScore) || 0), 0);
|
|
$('stage-pill').textContent = stageLabels[data.stage] || data.stage || '—';
|
|
|
|
$('team-size').value = planning.developers || 3;
|
|
$('sprint-weeks').value = planning.sprintWeeks || 2;
|
|
|
|
fillProposalEditors(data);
|
|
renderTree(actors, fps, ios);
|
|
renderPreview(data);
|
|
|
|
if (data.isComplete || ucs.length >= 1 || fps.length >= 2) $('export-btn').classList.remove('hidden');
|
|
}
|
|
|
|
function fillProposalEditors(data) {
|
|
const source = data.srsDraft || data;
|
|
$('scope-editor').value = source.summary || source.scope || data.scope || '';
|
|
$('actors-editor').value = (source.actors || []).join('\n');
|
|
$('usecases-editor').value = (source.useCases || []).map((uc) => `${uc.title || ''} | ${uc.actor || 'المستخدم'}`).join('\n');
|
|
$('io-editor').value = (source.inputOutputs || []).map((item) => [
|
|
item.type === 'output' ? 'Output' : 'Input',
|
|
item.name || '',
|
|
item.source || '',
|
|
item.destination || '',
|
|
item.description || '',
|
|
].join(' | ')).join('\n');
|
|
|
|
const counts = source.counts || {
|
|
actors: (source.actors || []).length,
|
|
useCases: (source.useCases || []).length,
|
|
inputs: (source.inputOutputs || []).filter((item) => item.type === 'input').length,
|
|
outputs: (source.inputOutputs || []).filter((item) => item.type === 'output').length,
|
|
};
|
|
|
|
if (counts.actors || counts.useCases || counts.inputs || counts.outputs) {
|
|
$('proposal-summary').textContent = `Actors: ${counts.actors || 0} · Use Cases: ${counts.useCases || 0} · Inputs: ${counts.inputs || 0} · Outputs: ${counts.outputs || 0}`;
|
|
} else {
|
|
$('proposal-summary').textContent = 'ستظهر هنا أعداد Actors وUse Cases وInputs/Outputs بعد تحليل المستند.';
|
|
}
|
|
|
|
const status = $('proposal-status');
|
|
if (data.needsStructureConfirmation) {
|
|
status.textContent = 'بانتظار الاعتماد';
|
|
status.classList.remove('dim');
|
|
} else if ((source.actors || []).length || (source.useCases || []).length) {
|
|
status.textContent = 'قابل للتعديل';
|
|
status.classList.remove('dim');
|
|
} else {
|
|
status.textContent = 'بانتظار تحليل';
|
|
status.classList.add('dim');
|
|
}
|
|
}
|
|
|
|
function renderTree(actors, fps, ios) {
|
|
const tree = $('tree');
|
|
if (!actors.length && !fps.length && !ios.length) {
|
|
tree.innerHTML = '<div class="tree-empty">ستظهر الـ Actors والوظائف وهياكل الـ IO هنا تلقائياً.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
actors.forEach((actor) => {
|
|
html += `<div class="tree-actor">${esc(actor)}</div>`;
|
|
});
|
|
fps.forEach((fp) => {
|
|
html += `<div class="tree-fp">${esc(fp.id || '')} · ${esc(fp.name || '')}</div>`;
|
|
});
|
|
ios.slice(0, 8).forEach((item) => {
|
|
html += `<div class="tree-io ${item.type === 'output' ? 'out' : 'in'}">${item.type === 'output' ? 'Output' : 'Input'} · ${esc(item.name || '')}</div>`;
|
|
});
|
|
tree.innerHTML = html;
|
|
}
|
|
|
|
function renderPreview(data) {
|
|
const prev = $('preview-content');
|
|
const actors = data.actors || [];
|
|
const fps = data.functionPoints || [];
|
|
const ucs = data.useCases || [];
|
|
const ios = data.inputOutputs || [];
|
|
const analytics = data.analytics || {};
|
|
const fp = analytics.fp || {};
|
|
const ucp = analytics.ucp || {};
|
|
const discrepancy = analytics.discrepancy || {};
|
|
const plan = analytics.plan || {};
|
|
|
|
let html = '';
|
|
html += `<div class="summary-strip">
|
|
${previewMetric(fp.totalFP ?? 0, 'Total FP', `~ ${fp.effortHours ?? 0} ساعة`) }
|
|
${previewMetric(ucp.UCP ?? 0, 'UCP', `~ ${ucp.effortHours ?? 0} ساعة`) }
|
|
${previewMetric(discrepancy.deltaPercent ?? 0, 'Gap', `${discrepancy.dominantMethod || '—'} الأعلى`) }
|
|
${previewMetric(plan.totalSprints ?? 0, 'Sprints', `${plan.calendarWeeks ?? 0} أسبوع`) }
|
|
</div>`;
|
|
|
|
if (data.scope) {
|
|
html += `<h3>نطاق النظام</h3><div class="scope">${esc(data.scope)}</div>`;
|
|
}
|
|
|
|
if (actors.length) {
|
|
html += `<h3>Actors (${actors.length})</h3><div class="tag-row">${actors.map((actor) => `<span class="tag-chip">${esc(actor)}</span>`).join('')}</div>`;
|
|
}
|
|
|
|
if (ios.length) {
|
|
html += `<h3>Inputs / Outputs (${ios.length})</h3>`;
|
|
ios.forEach((item) => {
|
|
html += `<div class="io-prev ${item.type === 'output' ? 'out' : 'in'}"><b>${item.type === 'output' ? 'Output' : 'Input'}</b> ${esc(item.name || '')}<br><span>${esc(item.source || '')} → ${esc(item.destination || '')}</span></div>`;
|
|
});
|
|
}
|
|
|
|
if (fps.length) {
|
|
html += `<h3>المتطلبات الوظيفية (${fps.length})</h3>`;
|
|
fps.forEach((fpItem) => {
|
|
html += `<div class="uc-prev"><b>${esc(fpItem.id || '')}</b> ${esc(fpItem.name || '')} <span style="color:#888">— ${esc(fpItem.complexity || '')} (FP ${esc(String(fpItem.fpScore || ''))})</span></div>`;
|
|
});
|
|
}
|
|
|
|
if (ucs.length) {
|
|
html += `<h3>حالات الاستخدام (${ucs.length})</h3>`;
|
|
ucs.forEach((uc) => {
|
|
html += `<div class="uc-prev"><b>${esc(uc.id || '')}</b> ${esc(uc.title || '')}<br><span style="color:#888">الفاعل: ${esc(uc.actor || '')}</span></div>`;
|
|
});
|
|
}
|
|
|
|
if (discrepancy.narrative) {
|
|
html += `<h3>تحليل الفرق بين FP وUCP</h3>`;
|
|
html += `<div class="analysis-card">
|
|
<div class="analysis-line"><strong>الفرق:</strong> ${esc(String(discrepancy.deltaPercent ?? 0))}%</div>
|
|
<div class="analysis-line"><strong>الطريقة الأعلى:</strong> ${esc(discrepancy.dominantMethod || '—')}</div>
|
|
<div class="analysis-line">${esc(discrepancy.narrative || '')}</div>
|
|
${Array.isArray(discrepancy.evidence) && discrepancy.evidence.length ? `<ul>${discrepancy.evidence.map((item) => `<li>${esc(item)}</li>`).join('')}</ul>` : ''}
|
|
<div class="analysis-line"><strong>التوصية:</strong> ${esc(discrepancy.recommendation || '')}</div>
|
|
</div>`;
|
|
}
|
|
|
|
if (plan.milestones?.length) {
|
|
html += `<h3>WBS / Milestones</h3>`;
|
|
plan.milestones.forEach((item) => {
|
|
html += `<div class="milestone-item"><b>${esc(item.name || '')}</b><span>${esc(String(item.percent || 0))}% · ${esc(String(item.hours || 0))} ساعة · ${esc(item.owner || '')}</span></div>`;
|
|
});
|
|
}
|
|
|
|
if (plan.sprintPlan?.length) {
|
|
html += `<h3>Sprint Breakdown</h3>`;
|
|
plan.sprintPlan.forEach((sprint) => {
|
|
html += `<div class="sprint-card"><b>${esc(sprint.name || '')}</b><span>${esc(sprint.goal || '')}</span><ul>${(sprint.focus || []).map((item) => `<li>${esc(item)}</li>`).join('')}</ul></div>`;
|
|
});
|
|
}
|
|
|
|
if (plan.staffing?.length) {
|
|
html += `<h3>توزيع الفريق</h3>`;
|
|
plan.staffing.forEach((member) => {
|
|
html += `<div class="milestone-item"><b>${esc(member.name || '')}</b><span>${esc(member.focus || '')} · ${esc(String(member.loadHours || 0))} ساعة</span></div>`;
|
|
});
|
|
}
|
|
|
|
prev.innerHTML = html || '<div class="preview-empty">سيتشكل هنا ملخص النطاق، Catalog الـ IO، تحليل الفجوة بين FP/UCP، وخطة التنفيذ الأولية.</div>';
|
|
}
|
|
|
|
function previewMetric(value, label, meta) {
|
|
return `<div class="preview-metric"><div class="metric-label">${esc(label)}</div><div class="metric-value">${esc(String(value))}</div><div class="metric-meta">${esc(meta || '')}</div></div>`;
|
|
}
|
|
|
|
function parseSimpleList(text) {
|
|
return text
|
|
.split(/\n+/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseUseCases(text, previous = []) {
|
|
return parseSimpleList(text).map((line, index) => {
|
|
const prev = previous[index] || {};
|
|
const parts = line.split('|').map((part) => part.trim());
|
|
const title = parts[0] || prev.title || `حالة استخدام ${index + 1}`;
|
|
const actor = parts[1] || prev.actor || 'المستخدم';
|
|
const mainFlow = parts[2]
|
|
? parts[2].split('>').map((step) => step.trim()).filter(Boolean)
|
|
: (Array.isArray(prev.mainFlow) && prev.mainFlow.length ? prev.mainFlow : defaultMainFlow(title, actor));
|
|
const alternateFlow = parts[3]
|
|
? parts[3].split('>').map((step) => step.trim()).filter(Boolean)
|
|
: (Array.isArray(prev.alternateFlow) ? prev.alternateFlow : []);
|
|
return {
|
|
id: prev.id || `UC-${String(index + 1).padStart(2, '0')}`,
|
|
title,
|
|
actor,
|
|
preconditions: prev.preconditions || `يملك ${actor} الصلاحية اللازمة.`,
|
|
mainFlow,
|
|
alternateFlow,
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseInputOutputs(text, previous = []) {
|
|
return parseSimpleList(text).map((line, index) => {
|
|
const prev = previous[index] || {};
|
|
const parts = line.split('|').map((part) => part.trim());
|
|
const typeRaw = (parts[0] || prev.type || 'input').toLowerCase();
|
|
const type = /output|out|مخرج|إخراج/.test(typeRaw) ? 'output' : 'input';
|
|
return {
|
|
id: prev.id || `IO-${String(index + 1).padStart(2, '0')}`,
|
|
type,
|
|
name: parts[1] || prev.name || `${type === 'output' ? 'مخرج' : 'مدخل'} ${index + 1}`,
|
|
source: parts[2] || prev.source || (type === 'input' ? 'المستخدم' : 'النظام'),
|
|
destination: parts[3] || prev.destination || (type === 'input' ? 'النظام' : 'المستخدم'),
|
|
description: parts[4] || prev.description || '',
|
|
};
|
|
});
|
|
}
|
|
|
|
function defaultMainFlow(title, actor) {
|
|
return [
|
|
`${actor} يبدأ مسار ${title}.`,
|
|
'يدخل البيانات أو يحدد الخيارات المطلوبة.',
|
|
'يعالج النظام الطلب ويعرض النتيجة أو التأكيد.',
|
|
];
|
|
}
|
|
|
|
function looksBinary(text) {
|
|
if (!text) return false;
|
|
let suspicious = 0;
|
|
const sample = text.slice(0, 600);
|
|
for (let i = 0; i < sample.length; i += 1) {
|
|
const code = sample.charCodeAt(i);
|
|
if (code === 0 || (code < 9) || (code > 13 && code < 32)) suspicious += 1;
|
|
}
|
|
return suspicious / Math.max(sample.length, 1) > 0.12;
|
|
}
|
|
|
|
function esc(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
}
|