
Watch this space.
The new site drops soon.
Exciting things are going on behind the scenes. Come back soon!
Get the latest news:
Nine
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Ajanta Report</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script
<!-- <style>
body {
font-family: sans-serif;
max-width: 1100px;
margin: 20px auto;
padding: 0 16px;
}
h2 {
margin-top: 32px;
border-bottom: 2px solid #333;
padding-bottom: 4px;
}
h3 {
margin-top: 20px;
}
label {
display: block;
margin-top: 8px;
font-weight: bold;
font-size: 13px;
}
input[type=text],
input[type=number],
input[type=date] {
width: 100%;
padding: 6px;
margin-top: 2px;
box-sizing: border-box;
}
.row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.row3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
textarea {
width: 100%;
height: 180px;
margin-top: 4px;
font-size: 12px;
box-sizing: border-box;
}
button {
margin-top: 8px;
padding: 8px 16px;
cursor: pointer;
}
button.primary {
background: #0066cc;
color: white;
border: none;
font-size: 14px;
padding: 10px 20px;
}
button.primary:disabled {
background: #999;
cursor: not-allowed;
}
.dropzone {
border: 2px dashed #999;
padding: 24px;
text-align: center;
margin-top: 4px;
cursor: pointer;
background: #fafafa;
}
.dropzone.over {
border-color: #0066cc;
background: #eef;
}
.status {
font-size: 12px;
color: #444;
margin-top: 4px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 8px;
font-size: 12px;
}
th {
background: #eee;
border: 1px solid #ccc;
padding: 5px 8px;
text-align: left;
}
td {
border: 1px solid #ddd;
padding: 4px 8px;
}
.chart-box {
background: white;
border: 1px solid #ccc;
padding: 8px;
margin-top: 12px;
}
.section-block {
border: 1px solid #ccc;
padding: 12px;
margin-top: 12px;
}
.saved-badge {
color: green;
font-size: 12px;
margin-left: 8px;
display: none;
}
.saved-badge.v {
display: inline;
}
#pdf-error {
color: red;
font-size: 13px;
margin-top: 8px;
display: none;
}
#pdf-error.v {
display: block;
}
.period-hint {
font-size: 12px;
color: #555;
margin-top: 6px;
}
hr {
margin: 32px 0;
}
</style> -->
</head>
<body>
<h1>Ajanta Report</h1>
<!-- ══════════════════════════════════════ -->
<h2>1. Report Configuration</h2>
<div class="row2">
<div><label>Report Number</label><input id="c-rno" value="DHJ/QC/ANTR/25-26/01"></div>
<div><label>SOP Reference</label><input id="c-sop" value="SOP/DHJ/QC/262"></div>
<div><label>Current Period Label</label><input id="c-per" value="Apr 2025 – Jun 2025"></div>
<div><label>Previous Period Label</label><input id="c-pper" value="Jan 2025 – Mar 2025"></div>
<div><label>Prepared By</label><input id="c-prep" placeholder="Analyst name"></div>
<div><label>Location</label><input id="c-loc"
value="Ajanta Pharma Limited, Z/103/A, Dahej SEZ II, Bharuch, Gujarat – 392130, INDIA"></div>
</div>
<!-- ══════════════════════════════════════ -->
<h2>2. Upload Data</h2>
<p>
<label><input type="radio" name="mode" value="split" checked onchange="setMode('split')"> Split single CSV by
date</label>
<label><input type="radio" name="mode" value="two" onchange="setMode('two')"> Two separate CSV files</label>
</p>
<div id="mode-split">
<div class="dropzone" id="uz1"><input type="file" id="fi1" accept=".csv" style="display:none">
Drop full EWS export CSV here, or click to browse
</div>
<div class="status" id="fst1"></div>
<label>Split Date — rows BEFORE this = Previous Period, rows ON/AFTER = Current Period</label>
<input type="date" id="c-split" value="2025-04-01" style="width:200px" oninput="updateHint()">
<div class="period-hint" id="phint"></div>
</div>
<div id="mode-two" style="display:none">
<div class="row2">
<div>
<label>🔵 Previous Period CSV</label>
<div class="dropzone" id="uz-prev"><input type="file" id="fi-prev" accept=".csv" style="display:none">Drop CSV
here</div>
<div class="status" id="fst-prev"></div>
</div>
<div>
<label>🟠 Current Period CSV</label>
<div class="dropzone" id="uz-curr"><input type="file" id="fi-curr" accept=".csv" style="display:none">Drop CSV
here</div>
<div class="status" id="fst-curr"></div>
</div>
</div>
</div>
<br>
<button class="primary" id="btn-proc" disabled onclick="processData()">Process Data</button>
<!-- ══════════════════════════════════════ -->
<div id="results" style="display:none">
<hr>
<h2>3. Aggregation</h2>
<h3>Summary</h3>
<div id="summary-stats"></div>
<h3>Software Breakdown</h3>
<table id="sw-tbl"></table>
<div class="chart-box"><canvas id="ch-sw" height="120"></canvas></div>
<h3>Open Lab 2.5 — Anomaly Types</h3>
<table id="ol-tbl"></table>
<div class="chart-box"><canvas id="ch-ol" height="140"></canvas></div>
<h3>Top-3 Detail (Current Period)</h3>
<div id="t3c"></div>
<!-- ══════════════════════════════════════ -->
<hr>
<h2>4. LLM Narrative Prompts</h2>
<p style="font-size:13px;color:#555">Copy each prompt → paste into Claude web → paste response back → Save. PDF will
use whatever is saved.</p>
<div id="sec-panels"></div>
<!-- ══════════════════════════════════════ -->
<hr>
<h2>5. Generate PDF</h2>
<div id="completion-list"></div>
<br>
<button class="primary" id="btn-pdf" onclick="genPDF()">⬇ Download PDF Report</button>
<div id="pdf-error"></div>
</div><!-- end #results -->
<script>
// ═══════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════
const SW_MAP = { OLAB: 'Open Lab 2.5', ERHT: 'Hardness Tester', SICP: 'Inductively Coupled Plasma Mass Spectrometry', TAKF: 'Auto Karl Fisher Titrator', LABX: 'Lab X 2019', ATLC: 'HP-TLC', PLMR: 'Polarimeter' };
const COL = { param: ['Parameter', 'parameter'], seqName: ['Sequence/Analysis Name', 'Sequence_Analysis_Name'], product: ['Product', 'Product_Short', 'product'], testCode: ['Test Code', 'Test_Code'], reason: ['Reason', 'reason'], insertDate: ['Insertion Date', 'Insertion_Date', 'InsertionDate', 'Date', 'date'], status: ['Status', 'status'], overdueDays: ['Overdue Days', 'Overdue_Days'] };
const SECS = [
{ id: 'abort', lbl: '4.2 Abort Sequences', num: '4.2', match: t => t.toLowerCase().includes('abort') },
{ id: 'repeat', lbl: '4.3 Repeat Analysis', num: '4.3', match: t => t.toLowerCase().includes('repeat') },
{ id: 'unlocked', lbl: '4.4 Seq Not Locked', num: '4.4', match: t => t.toLowerCase().includes('lock') },
{ id: 'errors', lbl: '4.5 Error Messages', num: '4.5', match: t => t.toLowerCase().includes('error') },
{ id: 'nomenclature', lbl: '4.6 Nomenclature', num: '4.6', match: t => t.toLowerCase().includes('nomenclature') },
{ id: 'conclusion', lbl: '6. Conclusion', num: '6', match: null }
];
const SEC_TITLES = { abort: 'Brief details of "Abort sequences for Open Lab software" anomaly', repeat: 'Brief details of "Repeat analysis for Open Lab software" anomaly', unlocked: 'Brief details of "Sequence is not locked for Open Lab software" anomaly', errors: 'Brief details of "Error messages in activity log for Open Lab software" anomaly', nomenclature: 'Brief details of "Nomenclature violation for Open Lab software" anomaly' };
const PREV_COLOR = '#4472C4';
const CURR_COLOR = '#ED7D31';
// ═══════════════════════════════════════
// STATE
// ═══════════════════════════════════════
const S = { allRows: [], prevRows: [], currRows: [], agg: null, cfg: {}, llm: {}, charts: {}, mode: 'split' };
// ═══════════════════════════════════════
// COLUMN RESOLVER
// ═══════════════════════════════════════
function resolveCols(rows) {
if (!rows || !rows.length) return {};
const keys = Object.keys(rows[0]), r = {};
for (const [f, c] of Object.entries(COL)) r[f] = c.find(x => keys.includes(x)) || null;
return r;
}
function gc(row, field, cols) { return cols[field] ? (row[cols[field]] || '') : '' }
function parseRowDate(str) {
if (!str) return null;
const m = str.trim().match(/^(\d{1,2})\s+(\w{3,})\s+(\d{4})/);
if (m) return new Date(`${m[2]} ${m[1]} ${m[3]}`);
const d = new Date(str.trim().split(' ')[0]);
return isNaN(d) ? null : d;
}
// ═══════════════════════════════════════
// AGGREGATION
// ═══════════════════════════════════════
function enrich(rows, cols) {
rows.forEach(r => {
const p = (gc(r, 'param', cols) || '').trim();
const pfx = p.split(' - ')[0].trim().split('/')[0];
r._sw = SW_MAP[pfx] || (p || 'Unknown');
r._typ = p.includes(' - ') ? p.split(' - ').slice(1).join(' - ') : (p || 'Unknown');
const seq = (gc(r, 'seqName', cols) || '').trim();
const m = seq.match(/^(\d+)_/);
r._inst = m ? `QCD/${m[1]}` : 'Unknown';
});
}
function aggregate(prevRows, currRows) {
const cols = resolveCols([...prevRows, ...currRows]);
enrich(prevRows, cols); enrich(currRows, cols);
const pT = prevRows.length, cT = currRows.length;
const pBySW = .groupBy(prevRows, 'sw'), cBySW = .groupBy(currRows, 'sw');
const allSW = [...new Set([...Object.keys(pBySW), ...Object.keys(cBySW)])];
const swTbl = allSW.map(n => ({ n, pCnt: (pBySW[n] || []).length, pPct: pT > 0 ? ((pBySW[n] || []).length / pT 100).toFixed(2) : '0.00', cCnt: (cBySW[n] || []).length, cPct: cT > 0 ? ((cBySW[n] || []).length / cT 100).toFixed(2) : '0.00' })).sort((a, b) => b.cCnt - a.cCnt);
const pOL = pBySW['Open Lab 2.5'] || [], cOL = cBySW['Open Lab 2.5'] || [];
const pOLT = pOL.length, cOLT = cOL.length;
const pOLTyp = .groupBy(pOL, 'typ'), cOLTyp = .groupBy(cOL, 'typ');
const allTyp = [...new Set([...Object.keys(pOLTyp), ...Object.keys(cOLTyp)])];
const olTypTbl = allTyp.map(n => ({ n, pCnt: (pOLTyp[n] || []).length, pPct: pOLT > 0 ? ((pOLTyp[n] || []).length / pOLT 100).toFixed(2) : '0.00', cCnt: (cOLTyp[n] || []).length, cPct: cOLT > 0 ? ((cOLTyp[n] || []).length / cOLT 100).toFixed(2) : '0.00' })).sort((a, b) => b.cCnt - a.cCnt);
const top3 = (obj, tot) => Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n, c]) => ({ n, c, p: tot > 0 ? (c / tot * 100).toFixed(2) : '0.00' }));
const det = {};
olTypTbl.forEach(t => {
const tr = cOLTyp[t.n] || [];
det[t.n] = {
cCnt: t.cCnt, cPct: t.cPct, pCnt: t.pCnt, pPct: t.pPct,
prods: top3(_.countBy(tr, r => gc(r, 'product', cols) || 'Unknown'), t.cCnt),
insts: top3(_.countBy(tr, '_inst'), t.cCnt).map(x => ({ n: x.n, c: x.c })),
tests: top3(_.countBy(tr, r => gc(r, 'testCode', cols) || 'Unknown'), t.cCnt).map(x => ({ n: x.n, c: x.c })),
reasons: Object.entries(_.countBy(tr, r => gc(r, 'reason', cols) || 'Unknown')).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([r, c]) => ({ r, c }))
};
});
const statusF = cols.status || 'Status', odF = cols.overdueDays || 'Overdue Days';
const pending = currRows.filter(r => (r[statusF] || '').includes('Pending')).length;
return { pT, cT, swTbl, pOLT, cOLT, olTypTbl, det, pending, cols };
}
// ═══════════════════════════════════════
// PROMPT BUILDER
// ═══════════════════════════════════════
function buildPrompt(sec, agg, cfg) {
const curr = cfg.period || 'current quarter', prev = cfg.pperiod || 'previous quarter', loc = cfg.loc || 'Ajanta Pharma Limited, Dahej';
if (sec.id === 'conclusion') {
return `You are a regulatory technical writer for ${loc}.\nWrite Section 6 (CONCLUSION) of a GxP QC Anomaly Trending Report.\n\nRULES: Formal regulatory English, third person. Number subsections 6.1–6.6. Reference all numbers exactly. Each subsection 2–4 sentences. Start directly with "6. CONCLUSION" — no preamble.\n\nDATA:\n${JSON.stringify({ reviewPeriod: curr, previousPeriod: prev, currentTotal: agg.cT, previousTotal: agg.pT, softwareBreakdown: agg.swTbl.map(r => ({ sw: r.n, prev: r.pCnt + '(' + r.pPct + '%)', curr: r.cCnt + '(' + r.cPct + '%)' })), olabTopTypes: agg.olTypTbl.slice(0, 5).map(r => ({ type: r.n, prev: r.pCnt + '(' + r.pPct + '%)', curr: r.cCnt + '(' + r.cPct + '%)' })), pendingAnomalies: agg.pending }, null, 2)}`;
}
let mName = null, mDet = null;
for (const [tn, td] of Object.entries(agg.det)) { if (sec.match && sec.match(tn)) { mName = tn; mDet = td; break } }
return `You are a regulatory technical writer for ${loc}.\nWrite Section ${sec.num} of a GxP QC Anomaly Trending Report.\nTitle: "${SEC_TITLES[sec.id]}"\n\nRULES: Formal regulatory English, third person. Number subsections ${sec.num}.1, ${sec.num}.2, ${sec.num}.3. ~200 words. Start directly with "${sec.num} Brief details of…" — no preamble.\n\nDATA:\n${JSON.stringify({ reviewPeriod: curr, previousPeriod: prev, anomalyType: mName || 'N/A', currentCount: mDet ? mDet.cCnt : 0, currentPct: mDet ? mDet.cPct + '%' : '0%', previousCount: mDet ? mDet.pCnt : 0, previousPct: mDet ? mDet.pPct + '%' : '0%', trend: mDet ? (mDet.cCnt > mDet.pCnt ? 'increasing' : mDet.cCnt < mDet.pCnt ? 'decreasing' : 'stable') : 'N/A', top3Products: mDet ? mDet.prods : [], top3Instruments: mDet ? mDet.insts : [], top3Tests: mDet ? mDet.tests : [], topReasons: mDet ? mDet.reasons : [] }, null, 2)}`;
}
// ═══════════════════════════════════════
// RENDER
// ═══════════════════════════════════════
function mkChart(id, labels, prevData, currData, prevLbl, currLbl) {
const el = document.getElementById(id); if (!el) return;
const ctx = el.getContext('2d');
if (S.charts[id]) S.charts[id].destroy();
S.charts[id] = new Chart(ctx, {
type: 'bar',
data: {
labels, datasets: [
{ label: prevLbl, data: prevData, backgroundColor: PREV_COLOR, borderWidth: 0 },
{ label: currLbl, data: currData, backgroundColor: CURR_COLOR, borderWidth: 0 }
]
},
options: {
responsive: true,
plugins: {
legend: { position: 'top', labels: { boxWidth: 12, font: { size: 10 } } },
datalabels: { anchor: 'end', align: 'top', formatter: v => v > 0 ? parseFloat(v).toFixed(2) + '%' : '', font: { size: 8, weight: 'bold' }, color: '#333' }
},
scales: {
x: { ticks: { font: { size: 8 }, maxRotation: 40 } },
y: { beginAtZero: true, ticks: { callback: v => v + '%', font: { size: 9 } } }
}
},
plugins: [ChartDataLabels]
});
}
function renderAgg(agg) {
const cfg = S.cfg;
const prevLbl = `% (${cfg.pperiod || 'Prev'}) (3 months)`;
const currLbl = `% (${cfg.period || 'Curr'}) (3 months)`;
// Summary
document.getElementById('summary-stats').innerHTML = `
<table><tr><th>Metric</th><th>Value</th></tr>
<tr><td>Previous period total</td><td>${agg.pT.toLocaleString()}</td></tr>
<tr><td>Current period total</td><td>${agg.cT.toLocaleString()}</td></tr>
<tr><td>OpenLab 2.5 (current)</td><td>${agg.cOLT.toLocaleString()} (${agg.cT > 0 ? ((agg.cOLT / agg.cT) * 100).toFixed(2) : '0'}%)</td></tr>
<tr><td>Pending closure</td><td>${agg.pending}</td></tr>
<tr><td>Software systems</td><td>${agg.swTbl.length}</td></tr>
</table>`;
// SW table
document.getElementById('sw-tbl').innerHTML =
`<tr><th>Sr.</th><th>Software</th><th>Prev Count</th><th>Prev %</th><th>Curr Count</th><th>Curr %</th></tr>` +
agg.swTbl.map((r, i) => `<tr><td>${i + 1}</td><td>${r.n}</td><td>${r.pCnt}</td><td>${r.pPct}%</td><td>${r.cCnt}</td><td>${r.cPct}%</td></tr>`).join('') +
`<tr><td></td><td><b>Total</b></td><td>${agg.pT}</td><td>100%</td><td>${agg.cT}</td><td>100%</td></tr>`;
// OLab table
document.getElementById('ol-tbl').innerHTML =
`<tr><th>Sr.</th><th>Anomaly Type</th><th>Prev Count</th><th>Prev %</th><th>Curr Count</th><th>Curr %</th></tr>` +
(agg.olTypTbl.length ? agg.olTypTbl.map((r, i) => `<tr><td>${i + 1}</td><td>${r.n}</td><td>${r.pCnt}</td><td>${r.pPct}%</td><td>${r.cCnt}</td><td>${r.cPct}%</td></tr>`).join('')
: '<tr><td colspan="6">No OpenLab rows found — check Parameter column has OLAB prefix</td></tr>') +
`<tr><td></td><td><b>Total</b></td><td>${agg.pOLT}</td><td>100%</td><td>${agg.cOLT}</td><td>100%</td></tr>`;
mkChart('ch-sw',
agg.swTbl.map(r => r.n.replace('Inductively Coupled Plasma Mass Spectrometry', 'ICP-MS')),
agg.swTbl.map(r => parseFloat(r.pPct)), agg.swTbl.map(r => parseFloat(r.cPct)), prevLbl, currLbl);
mkChart('ch-ol',
agg.olTypTbl.map(r => r.n),
agg.olTypTbl.map(r => parseFloat(r.pPct)), agg.olTypTbl.map(r => parseFloat(r.cPct)), prevLbl, currLbl);
// Top-3
const entries = Object.entries(agg.det).slice(0, 3);
document.getElementById('t3c').innerHTML = entries.length === 0 ? '<p>No OpenLab data.</p>' : entries.map(([tn, d]) => `
<div class="section-block">
<b>${tn}</b> — Prev: ${d.pCnt} (${d.pPct}%) → Curr: ${d.cCnt} (${d.cPct}%)
<div class="row2">
<div><b>Top Products</b>
<table><tr><th>Product</th><th>Count</th><th>%</th></tr>
${d.prods.map(p => `<tr><td>${p.n}</td><td>${p.c}</td><td>${p.p}%</td></tr>`).join('')}
</table></div>
<div><b>Top Tests</b>
<table><tr><th>Test</th><th>Count</th></tr>
${d.tests.map(t => `<tr><td>${t.n}</td><td>${t.c}</td></tr>`).join('')}
</table></div>
</div>
</div>`).join('');
// Prompt sections
document.getElementById('sec-panels').innerHTML = SECS.map(s => `
<div class="section-block">
<b>${s.lbl}</b> <span class="saved-badge ${S.llm[s.id] ? 'v' : ''}" id="bd-${s.id}">✓ Saved</span>
<br><br>
<b>Generated Prompt</b>
<button onclick="copyP('${s.id}')" style="margin-left:8px">Copy</button><br>
<textarea id="pt-${s.id}" readonly>${buildPrompt(s, agg, cfg)}</textarea>
<b>Paste LLM Response</b><br>
<textarea id="rt-${s.id}" placeholder="Paste Claude's response here…">${S.llm[s.id] || ''}</textarea>
<butto n onclick="saveR('${s.id}')">Save Response</button>
<button onclick="clearR('${s.id}')">Clear</button>
</div>`).join('');
// Completion list
document.getElementById('completion-list').innerHTML = SECS.map(s => {
const ok = !!(S.llm[s.id] && S.llm[s.id].trim());
return `<span style="color:${ok ? 'green' : '#888'};margin-right:16px">${ok ? '✓' : '○'} ${s.lbl}</span>`;
}).join('');
document.getElementById('results').style.display = 'block';
document.getElementById('results').scrollIntoView({ behavior: 'smooth' });
}
function copyP(id) { navigator.clipboard.writeText(document.getElementById(`pt-${id}`).value).then(() => alert('Prompt copied')) }
function saveR(id) {
const v = document.getElementById(`rt-${id}`).value.trim();
S.llm[id] = v;
const b = document.getElementById(`bd-${id}`);
if (v) b.classList.add('v'); else b.classList.remove('v');
// refresh completion list
document.getElementById('completion-list').innerHTML = SECS.map(s => {
const ok = !!(S.llm[s.id] && S.llm[s.id].trim());
return `<span style="color:${ok ? 'green' : '#888'};margin-right:16px">${ok ? '✓' : '○'} ${s.lbl}</span>`;
}).join('');
}
function clearR(id) { document.getElementById(`rt-${id}`).value = ''; saveR(id) }
// ═══════════════════════════════════════
// UPLOAD MODE
// ═══════════════════════════════════════
function setMode(m) {
S.mode = m;
document.getElementById('mode-split').style.display = m === 'split' ? '' : 'none';
document.getElementById('mode-two').style.display = m === 'two' ? '' : 'none';
checkReady();
}
function checkReady() {
const ok = S.mode === 'split' ? S.allRows.length > 0 : (S.prevRows.length > 0 && S.currRows.length > 0);
document.getElementById('btn-proc').disabled = !ok;
}
function updateHint() {
if (!S.allRows.length) return;
const cols = resolveCols(S.allRows);
const df = cols.insertDate;
if (!df) { document.getElementById('phint').textContent = '(date column not detected)'; return }
const cutoff = new Date(document.getElementById('c-split').value);
let p = 0, c = 0;
S.allRows.forEach(r => { const d = parseRowDate(r[df] || ''); if (!d) return; if (d < cutoff) p++; else c++ });
document.getElementById('phint').textContent = `🔵 Previous: ${p.toLocaleString()} rows 🟠 Current: ${c.toLocaleString()} rows`;
}
function setupUZ(uzId, fiId, fstId, cb) {
const uz = document.getElementById(uzId), fi = document.getElementById(fiId);
if (!uz || !fi) return;
uz.addEventListener('click', () => fi.click());
uz.addEventListener('dragover', e => { e.preventDefault(); uz.classList.add('over') });
uz.addEventListener('dragleave', () => uz.classList.remove('over'));
uz.addEventListener('drop', e => { e.preventDefault(); uz.classList.remove('over'); if (e.dataTransfer.files[0]) loadCSV(e.dataTransfer.files[0], fstId, cb) });
fi.addEventListener('change', e => { if (e.target.files[0]) loadCSV(e.target.files[0], fstId, cb) });
}
function loadCSV(f, fstId, cb) {
document.getElementById(fstId).textContent = `Parsing ${f.name}…`;
Papa.parse(f, {
header: true, skipEmptyLines: true, complete: r => {
cb(r.data);
const cols = resolveCols(r.data);
const det = Object.entries(cols).filter(([, v]) => v).map(([k, v]) => `${k}:"${v}"`).join(', ');
document.getElementById(fstId).textContent = `✓ ${r.data.length.toLocaleString()} rows | Detected: ${det || 'none'}`;
checkReady(); updateHint();
}, error: e => { document.getElementById(fstId).textContent = `Error: ${e.message}` }
});
}
setupUZ('uz1', 'fi1', 'fst1', rows => { S.allRows = rows });
setupUZ('uz-prev', 'fi-prev', 'fst-prev', rows => { S.prevRows = rows });
setupUZ('uz-curr', 'fi-curr', 'fst-curr', rows => { S.currRows = rows });
function processData() {
S.cfg = { rno: document.getElementById('c-rno').value.trim(), sop: document.getElementById('c-sop').value.trim(), period: document.getElementById('c-per').value.trim(), pperiod: document.getElementById('c-pper').value.trim(), loc: document.getElementById('c-loc').value.trim(), prep: document.getElementById('c-prep').value.trim() };
let prev, curr;
if (S.mode === 'split') {
const cols = resolveCols(S.allRows);
const cutoff = new Date(document.getElementById('c-split').value);
const df = cols.insertDate;
if (df) { prev = S.allRows.filter(r => { const d = parseRowDate(r[df] || ''); return d && d < cutoff }); curr = S.allRows.filter(r => { const d = parseRowDate(r[df] || ''); return !d || d >= cutoff }) }
else { prev = []; curr = S.allRows }
} else { prev = S.prevRows; curr = S.currRows }
S.agg = aggregate(prev, curr);
renderAgg(S.agg);
}
// ═══════════════════════════════════════
// PDF GENERATION
// ═══════════════════════════════════════
async function genPDF() {
const errEl = document.getElementById('pdf-error');
errEl.style.display = 'none';
if (!window.jspdf) { errEl.textContent = 'jsPDF not loaded'; errEl.style.display = 'block'; return }
const { jsPDF } = window.jspdf;
const doc = new jsPDF('p', 'mm', 'a4');
if (!doc.autoTable) { errEl.textContent = 'AutoTable not loaded'; errEl.style.display = 'block'; return }
const agg = S.agg, cfg = S.cfg;
const W = 210, H = 297, M = 14, CW = 182;
// NO color constants — plain black/gray only
const BLACK = [0, 0, 0], DARK = [30, 30, 30], GRAY = [100, 100, 100], LIGHTGRAY = [230, 230, 230];
let y = M, pn = 1;
function sy(t) { return (t && typeof t.finalY === 'number' && isFinite(t.finalY)) ? t.finalY : y + 8 }
function np() { doc.addPage(); pn++; y = M; phdr() }
// Only break page when truly needed — no forced np() between sections
function chk(n) { if (y + n > H - M - 8) np() }
// Page header: plain grid, bold labels, no fill
function phdr() {
doc.autoTable({
startY: 5, margin: { left: M, right: M }, head: [],
body: [[
{ content: 'Title', styles: { fontStyle: 'bold' } }, 'Anomaly Trending Report',
{ content: 'Ref. SOP:', styles: { fontStyle: 'bold' } }, cfg.sop || 'SOP/DHJ/QC/262',
{ content: 'Page:', styles: { fontStyle: 'bold' } }, String(pn)
]],
theme: 'grid',
styles: { fontSize: 7, cellPadding: 1.5, textColor: BLACK },
columnStyles: { 0: { cellWidth: 16 }, 1: { cellWidth: 66 }, 2: { cellWidth: 18 }, 3: { cellWidth: 46 }, 4: { cellWidth: 12 }, 5: { cellWidth: 24 } }
});
y = sy(doc.lastAutoTable) + 10;
}
// Section header: bold text, padded top and bottom, with thin underline rule
function shdr(txt) {
chk(16);
y += 5; // top padding before heading
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(...BLACK);
doc.text(txt, M, y);
y += 3;
doc.setDrawColor(...BLACK); doc.setLineWidth(0.3);
doc.line(M, y, M + CW, y);
y += 6; // bottom padding after underline
doc.setFont('helvetica', 'normal'); doc.setTextColor(...DARK);
}
// Sub-header: bold text, smaller
function sbhdr(txt) {
chk(8);
doc.setFontSize(9); doc.setFont('helvetica', 'bold'); doc.setTextColor(...DARK);
doc.text(txt, M, y); y += 5;
doc.setFont('helvetica', 'normal');
}
// Body text with auto page-break
function btext(txt) {
doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(...DARK);
const l = doc.splitTextToSize(txt, CW);
chk(l.length * 5 + 2);
doc.text(l, M, y); y += l.length * 5 + 2;
}
// Table: plain grid, light gray header (no blue)
function tbl(head, body, cs) {
chk(20);
doc.autoTable({
startY: y, margin: { left: M, right: M },
head: [head], body,
theme: 'grid',
styles: { fontSize: 8, cellPadding: 2, textColor: DARK, lineColor: [180, 180, 180], lineWidth: 0.2 },
headStyles: { fillColor: LIGHTGRAY, textColor: BLACK, fontStyle: 'bold', fontSize: 8 },
columnStyles: cs || {}
});
y = sy(doc.lastAutoTable) + 3;
}
// Chart — always renders; shows blank axes when no data
async function addChart(labels, prevData, currData, prevLbl, currLbl, title) {
try {
// Guard: if no labels at all, show a single placeholder so axes still render
const safeLabels = labels.length ? labels : ['(no data)'];
const safePrev = prevData.length ? prevData : [0];
const safeCurr = currData.length ? currData : [0];
const cv = document.createElement('canvas'); cv.width = 750; cv.height = 280;
const ctx2 = cv.getContext('2d');
ctx2.fillStyle = '#fff'; ctx2.fillRect(0, 0, 750, 280);
const ch = new Chart(ctx2, {
type: 'bar',
data: {
labels: safeLabels, datasets: [
{ label: prevLbl, data: safePrev, backgroundColor: PREV_COLOR, borderWidth: 0 },
{ label: currLbl, data: safeCurr, backgroundColor: CURR_COLOR, borderWidth: 0 }
]
},
options: {
responsive: false, animation: false,
plugins: {
legend: { position: 'top', labels: { boxWidth: 10, font: { size: 9 }, color: '#111' } },
title: { display: true, text: title, font: { size: 10, weight: 'bold' }, color: '#111' },
datalabels: {
anchor: 'end', align: 'top',
// only show label when value > 0, otherwise blank
formatter: v => (v && v > 0) ? parseFloat(v).toFixed(2) + '%' : '',
font: { size: 7, weight: 'bold' }, color: '#222'
}
},
scales: {
x: { ticks: { color: '#333', font: { size: 8 }, maxRotation: 40 }, grid: { color: '#eee' } },
y: {
beginAtZero: true,
// always show at least 0–100% range so blank charts look intentional
suggestedMax: 100,
ticks: { callback: v => v + '%', color: '#333', font: { size: 8 } },
grid: { color: '#eee' }
}
}
},
plugins: [ChartDataLabels]
});
await new Promise(r => setTimeout(r, 600));
const img = cv.toDataURL('image/png'); ch.destroy();
chk(52); doc.addImage(img, 'PNG', M, y, CW, 48); y += 50;
} catch (e) {
console.error(e);
// fallback: draw a blank bordered box with the title so space is reserved
chk(52);
doc.setDrawColor(180, 180, 180); doc.setLineWidth(0.3);
doc.rect(M, y, CW, 48, 'S');
doc.setFontSize(9); doc.setFont('helvetica', 'italic'); doc.setTextColor(150, 150, 150);
doc.text(title + ' (chart unavailable)', M + CW / 2, y + 24, { align: 'center' });
doc.setFont('helvetica', 'normal'); doc.setTextColor(...DARK);
y += 50;
}
}
// Narrative — flows continuously, no forced page break before it
function narrative(txt, ph) {
const content = (txt && txt.trim()) ? txt : `[${ph} — paste LLM response above]`;
const isPH = !(txt && txt.trim());
doc.setFontSize(8.5);
doc.setFont('helvetica', isPH ? 'italic' : 'normal');
doc.setTextColor(...(isPH ? GRAY : DARK));
const lines = doc.splitTextToSize(content, CW);
let i = 0;
while (i < lines.length) {
const avail = Math.floor((H - M - 8 - y) / 5);
const chunk = lines.slice(i, i + Math.max(avail - 1, 4));
if (!chunk.length) { np(); continue }
doc.text(chunk, M, y); y += chunk.length * 5;
i += chunk.length;
if (i < lines.length) np();
}
y += 3;
doc.setFont('helvetica', 'normal'); doc.setTextColor(...DARK);
}
const prevLbl = `Percentage (${cfg.pperiod || 'Jan 2025 – Mar 2025'}) (3 months)`;
const currLbl = `Percentage (${cfg.period || 'Apr 2025 – Jun 2025'}) (3 months)`;
// ── COVER ──────────────────────────────────
phdr();
// Title box: plain bordered rectangle, no fill
chk(22);
doc.setDrawColor(...BLACK); doc.setLineWidth(0.5);
doc.rect(M, y, CW, 20, 'S');
doc.setFontSize(16); doc.setFont('helvetica', 'bold'); doc.setTextColor(...BLACK);
doc.text('ANOMALY TRENDING REPORT', W / 2, y + 10, { align: 'center' });
doc.setFontSize(9); doc.setFont('helvetica', 'normal'); doc.setTextColor(...DARK);
doc.text(cfg.rno || 'DHJ/QC/ANTR/25-26/01', W / 2, y + 17, { align: 'center' });
y += 24;
// Period / location table — no fills
tbl(['Field', 'Details'],
[['Period', cfg.period || ''], ['Manufacturing Location', cfg.loc || ''], ['Reference SOP No.', cfg.sop || '']],
{ 0: { cellWidth: 42, fontStyle: 'bold' }, 1: { cellWidth: CW - 42 } });
// Confidentiality notice
chk(14);
doc.setFontSize(8); doc.setFont('helvetica', 'italic'); doc.setTextColor(...GRAY);
const cl = doc.splitTextToSize('This document contains confidential, privileged and proprietary information. No part of this document can be disclosed, reproduced, transmitted or transferred in any form without the written permission of Ajanta Pharma Limited.', CW);
doc.text(cl, M, y); y += cl.length * 4.5 + 6;
doc.setFont('helvetica', 'normal'); doc.setTextColor(...DARK);
// Approval table
shdr('REPORT PREPARATION AND APPROVAL:');
tbl(
['', 'Prepared By', 'Checked By (Head QC)', 'Checked By (Head IT)', 'Checked By (QA)', 'Approved By (Head QA)'],
[['Signature', '', '', '', '', ''], ['Date', '', '', '', '', ''], ['Name', cfg.prep || '', '', '', '', ''], ['Department', 'QC', 'Head QC', 'Head IT', 'QA', 'Head QA']],
{ 0: { cellWidth: 22, fontStyle: 'bold' } }
);
// ── SECTION 2 — OBJECTIVE ──────────────────
chk(50);
shdr('2. OBJECTIVE');
btext('2.1 Early Warning System (EWS) is an automated tool which continuously analyses and monitors electronic data generated by instruments/equipment installed with software without impacting the original data in QC to identify potential anomalies, if any.');
btext(`2.2 Last trending was performed for the period of ${cfg.pperiod || 'previous quarter'}. The next trending report is prepared for the period of ${cfg.period || 'current quarter'}.`);
btext('2.3 To understand the anomaly observed maximum with respect to product, test and instrument.');
btext('2.4 To identify irregularities generated during analysis and take adequate corrective and preventive actions for control and prevention of anomalies wherever possible.');
// ── SECTION 3 — ANOMALY REPORTED ──────────
chk(40);
shdr('3. ANOMALY REPORTED DURING THE REVIEW PERIOD');
sbhdr(`3.1 Total ${agg.cT.toLocaleString()} (${numberToWords(agg.cT)}) anomalies generated for the period of ${cfg.period || ''} among ${agg.swTbl.length} (${numberToWords(agg.swTbl.length)}) softwares as follows:`);
tbl(
['Sr. No.', 'Name of Software', `No. of anomalies\n(${cfg.pperiod || 'Prev'})\n(3 months)`, `Percentage\n(${cfg.pperiod || 'Prev'})\n(3 months)`, `No. of anomalies\n(${cfg.period || 'Curr'})\n(3 months)`, `Percentage\n(${cfg.period || 'Curr'})\n(3 months)`],
[...agg.swTbl.map((r, i) => [i + 1, r.n, r.pCnt, r.pPct + '%', r.cCnt, r.cPct + '%']),
['', 'Total', agg.pT, '100%', agg.cT, '100%']],
{ 0: { cellWidth: 12 }, 1: { cellWidth: 58 } }
);
// Software chart — always render (blank axes shown when no data)
await addChart(
agg.swTbl.map(r => r.n.replace('Inductively Coupled Plasma Mass Spectrometry', 'ICP-MS')),
agg.swTbl.map(r => parseFloat(r.pPct) || 0),
agg.swTbl.map(r => parseFloat(r.cPct) || 0),
prevLbl, currLbl, 'Software-wise Anomaly Distribution'
);
// 3.4 OpenLab — always render table + chart
chk(30);
shdr('3.4 Open Lab Software\'s anomalies details are mentioned as below,');
tbl(
['Sr. No.', 'Name of anomalies', `No. of anomalies\n(${cfg.pperiod || 'Prev'})\n(3 months)`, `Percentage\n(Prev)\n(3 months)`, `No. of anomalies\n(${cfg.period || 'Curr'})\n(3 months)`, `Percentage\n(Curr)\n(3 months)`],
agg.olTypTbl.length
? [...agg.olTypTbl.map((r, i) => [i + 1, r.n, r.pCnt, r.pPct + '%', r.cCnt, r.cPct + '%']),
['', 'Total', agg.pOLT, '100%', agg.cOLT, '100%']]
: [['—', 'No Open Lab anomalies in current period', '—', '—', '0', '0.00%']],
{ 0: { cellWidth: 12 }, 1: { cellWidth: 68 } }
);
// Always render OpenLab chart — blank if no data
await addChart(
agg.olTypTbl.length ? agg.olTypTbl.map(r => r.n) : ['(no data)'],
agg.olTypTbl.length ? agg.olTypTbl.map(r => parseFloat(r.pPct) || 0) : [0],
agg.olTypTbl.length ? agg.olTypTbl.map(r => parseFloat(r.cPct) || 0) : [0],
prevLbl, currLbl, "Open Lab Software's anomalies"
);
// ── SECTION 4 — BRIEF DETAILS ─────────────
// No separate blank page — just continue flowing
chk(14);
shdr('4. Brief details of anomaly raised during the review period:');
for (const sec of SECS.filter(s => s.id !== 'conclusion')) {
chk(16); // only break page if not enough room — never force np()
shdr(`${sec.num} ${SEC_TITLES[sec.id]}`);
let mName = null, mDet = null;
for (const [tn, td] of Object.entries(agg.det)) {
if (sec.match && sec.match(tn)) { mName = tn; mDet = td; break }
}
if (mDet) {
// Brief data summary before narrative
sbhdr(`${sec.num}.1 Trend: Previous ${mDet.pCnt} (${mDet.pPct}%) → Current ${mDet.cCnt} (${mDet.cPct}%)`);
if (mDet.prods.length) {
sbhdr('Major three products:');
tbl(['Sr. No.', 'Name of products', 'No. of anomaly', '% of anomaly'],
mDet.prods.map((p, i) => [i + 1, p.n, p.c, p.p + '%']),
{ 0: { cellWidth: 12 }, 1: { cellWidth: 88 } });
}
if (mDet.insts.length) {
sbhdr('Major three instruments:');
tbl(['Sr. No.', 'Name of Instrument', 'No. of anomaly'],
mDet.insts.map((t, i) => [i + 1, t.n, t.c]),
{ 0: { cellWidth: 12 }, 1: { cellWidth: 60 } });
}
if (mDet.tests.length) {
sbhdr('Major three tests:');
tbl(['Sr. No.', 'Name of Tests', 'No. of anomaly'],
mDet.tests.map((t, i) => [i + 1, t.n, t.c]),
{ 0: { cellWidth: 12 }, 1: { cellWidth: 60 } });
}
} else {
btext('No anomalies recorded for this category in the current review period.');
}
// Narrative flows directly — no extra gap, no forced page break
narrative(S.llm[sec.id], `Narrative pending — paste LLM response in Section 4 above`);
}
// ── SECTION 5 — COMPARISON ────────────────
chk(20);
shdr('5. COMPARISON OF ANOMALY OF PREVIOUS REVIEW PERIOD WITH CURRENT REVIEW PERIOD');
btext(`5.1 On comparison with previous trend for Open Lab software (Major anomalies), anomalies are found in increasing or decreasing order as compared to previous trend and also all anomalies close with adequate justification. Refer Point No.: 3.4.`);
btext(`5.2 Total anomalies (${cfg.pperiod || ''}): ${agg.pT.toLocaleString()}. Total anomalies (${cfg.period || ''}): ${agg.cT.toLocaleString()}.`);
if (agg.swTbl.some(r => r.n !== 'Open Lab 2.5' && r.pCnt === 0 && r.cCnt > 0)) {
btext('5.3 Following instruments with software have been integrated in EWS during this review period. Hence previous comparison is not available.');
}
// ── SECTION 6 — CONCLUSION ────────────────
chk(14);
shdr('6. CONCLUSION');
narrative(S.llm['conclusion'], 'Conclusion narrative pending — paste LLM response above');
// ── SECTION 7 — CAPA ──────────────────────
chk(20);
shdr('7. CAPA');
btext('7.1 On comparison of previous trend for Open Lab 2.5 software, major anomalies and adequate CAPA actions have been raised against identified incidents.');
btext('7.2 Awareness training has been imparted to the concerned persons for observed anomalies and further action.');
btext('7.3 Further to minimize the non-value-added anomalies in OpenLab software which have no impact on analysis, hence logic to be re-evaluated on these anomalies.');
// ── SECTION 8 — ATTACHMENTS ───────────────
chk(16);
shdr('8. Attachments:');
btext('Annex 1: List of anomalies');
btext('Annex 2: Awareness training record');
doc.save(`${(cfg.rno || 'ANTR').replace(/\//g, '_')}.pdf`);
}
// Helper: number to words (for totals like "Four Thousand One Hundred Eighty")
function numberToWords(n) {
if (n === 0) return 'Zero';
const ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen'];
const tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety'];
function h(n) {
if (n < 20) return ones[n];
if (n < 100) return tens[Math.floor(n / 10)] + (n % 10 ? ' ' + ones[n % 10] : '');
return ones[Math.floor(n / 100)] + ' Hundred' + (n % 100 ? ' ' + h(n % 100) : '');
}
if (n < 1000) return h(n);
if (n < 10000) return ones[Math.floor(n / 1000)] + ' Thousand' + (n % 1000 ? ' ' + h(n % 1000) : '');
return n.toString();
}
</script>
</body>
</html>