This commit is contained in:
parent
516a38777c
commit
5943ad452d
6 changed files with 810 additions and 113 deletions
|
|
@ -770,7 +770,9 @@ fn create_api_router() -> Router<AppState> {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WsQuery {
|
||||
#[allow(dead_code)]
|
||||
session_id: String,
|
||||
#[allow(dead_code)]
|
||||
user_id: String,
|
||||
bot_name: Option<String>,
|
||||
}
|
||||
|
|
@ -1092,69 +1094,6 @@ async fn handle_task_progress_ws_proxy(
|
|||
}
|
||||
}
|
||||
|
||||
async fn forward_client_to_backend(
|
||||
client_rx: &mut futures_util::stream::SplitStream<WebSocket>,
|
||||
backend_tx: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, TungsteniteMessage>,
|
||||
) {
|
||||
while let Some(msg) = client_rx.next().await {
|
||||
match msg {
|
||||
Ok(AxumMessage::Text(text)) => {
|
||||
if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(AxumMessage::Binary(data)) => {
|
||||
if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(AxumMessage::Ping(data)) => {
|
||||
if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(AxumMessage::Pong(data)) => {
|
||||
if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(AxumMessage::Close(_)) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_backend_to_client(
|
||||
backend_rx: &mut futures_util::stream::SplitStream<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>,
|
||||
client_tx: &mut futures_util::stream::SplitSink<WebSocket, AxumMessage>,
|
||||
) {
|
||||
while let Some(msg) = backend_rx.next().await {
|
||||
match msg {
|
||||
Ok(TungsteniteMessage::Text(text)) => {
|
||||
if client_tx.send(AxumMessage::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(TungsteniteMessage::Binary(data)) => {
|
||||
if client_tx.send(AxumMessage::Binary(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(TungsteniteMessage::Ping(data)) => {
|
||||
if client_tx.send(AxumMessage::Ping(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(TungsteniteMessage::Pong(data)) => {
|
||||
if client_tx.send(AxumMessage::Pong(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(TungsteniteMessage::Close(_)) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_ws_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/task-progress", get(ws_task_progress_proxy))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
<h1 data-i18n="campaigns-title">Campaigns</h1>
|
||||
<nav class="crm-tabs">
|
||||
<button class="crm-tab active" data-view="all" data-i18n="campaigns-all">All Campaigns</button>
|
||||
<button class="crm-tab" data-view="lists" data-i18n="campaigns-lists">Lists</button>
|
||||
<button class="crm-tab" data-view="templates" data-i18n="campaigns-templates">Templates</button>
|
||||
<button class="crm-tab" data-view="email" data-i18n="campaigns-email">Email</button>
|
||||
<button class="crm-tab" data-view="whatsapp" data-i18n="campaigns-whatsapp">WhatsApp</button>
|
||||
<button class="crm-tab" data-view="social" data-i18n="campaigns-social">Social</button>
|
||||
|
|
@ -40,6 +42,44 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lists Grid -->
|
||||
<div id="lists-view" class="crm-view">
|
||||
<div class="crm-view-header" style="padding: 16px 24px; display: flex; justify-content: flex-end;">
|
||||
<button class="btn-primary" onclick="showListModal()">
|
||||
<span>New List</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="campaigns-grid" id="listsList"
|
||||
hx-get="/api/crm/lists"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="pipeline-column" style="grid-column: 1 / -1;">
|
||||
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
|
||||
Loading lists...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates Grid -->
|
||||
<div id="templates-view" class="crm-view">
|
||||
<div class="crm-view-header" style="padding: 16px 24px; display: flex; justify-content: flex-end;">
|
||||
<button class="btn-primary" onclick="showTemplateModal()">
|
||||
<span>New Template</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="campaigns-grid" id="templatesList"
|
||||
hx-get="/api/crm/templates"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="pipeline-column" style="grid-column: 1 / -1;">
|
||||
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
|
||||
Loading templates...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Campaign Modal -->
|
||||
|
|
@ -256,6 +296,14 @@ function hideCampaignModal() {
|
|||
document.getElementById('campaign-form').reset();
|
||||
}
|
||||
|
||||
function showListModal() {
|
||||
alert('List creation coming soon!');
|
||||
}
|
||||
|
||||
function showTemplateModal() {
|
||||
alert('Template creation coming soon!');
|
||||
}
|
||||
|
||||
document.getElementById('campaign-form').addEventListener('htmx:afterRequest', function(e) {
|
||||
if (e.detail.successful) {
|
||||
hideCampaignModal();
|
||||
|
|
@ -267,10 +315,21 @@ document.getElementById('campaign-form').addEventListener('htmx:afterRequest', f
|
|||
document.querySelectorAll('.crm-tab[data-view]').forEach(tab => {
|
||||
tab.addEventListener('click', function() {
|
||||
document.querySelectorAll('.crm-tab[data-view]').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.crm-view').forEach(v => v.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
const view = this.dataset.view;
|
||||
filterCampaigns(view);
|
||||
|
||||
// Show the appropriate view
|
||||
const viewElement = document.getElementById(`${view}-view`);
|
||||
if (viewElement) {
|
||||
viewElement.classList.add('active');
|
||||
}
|
||||
|
||||
// For channel filters, use the existing filter function
|
||||
if (['all', 'email', 'whatsapp', 'social'].includes(view)) {
|
||||
filterCampaigns(view);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@
|
|||
<h1 data-i18n="crm-title">CRM</h1>
|
||||
<nav class="crm-tabs">
|
||||
<button class="crm-tab active" data-view="pipeline" data-i18n="crm-pipeline">Pipeline</button>
|
||||
<button class="crm-tab" data-view="leads" data-i18n="crm-leads">Leads</button>
|
||||
<button class="crm-tab" data-view="opportunities" data-i18n="crm-opportunities">Opportunities</button>
|
||||
<button class="crm-tab" data-view="deals" data-i18n="crm-deals">Deals</button>
|
||||
<button class="crm-tab" data-view="accounts" data-i18n="crm-accounts">Accounts</button>
|
||||
<button class="crm-tab" data-view="contacts" data-i18n="crm-contacts">Contacts</button>
|
||||
<button class="crm-tab" data-view="campaigns" data-i18n="crm-campaigns">Campaigns</button>
|
||||
|
|
@ -26,8 +25,8 @@
|
|||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text"
|
||||
placeholder="Search leads, opportunities, accounts..."
|
||||
data-i18n-placeholder="crm-search-placeholder"
|
||||
placeholder="Search deals, accounts..."
|
||||
data-i18n-placeholder="crm-search-placeholder-deals"
|
||||
hx-get="/api/ui/crm/search"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#crm-search-results">
|
||||
|
|
@ -154,53 +153,37 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leads List View -->
|
||||
<div id="crm-leads-view" class="crm-view">
|
||||
<!-- Deals List View -->
|
||||
<div id="crm-deals-view" class="crm-view">
|
||||
<div class="crm-list-header">
|
||||
<div class="list-filters">
|
||||
<select hx-get="/api/crm/leads" hx-trigger="change" hx-target="#leads-table-body" hx-include="this">
|
||||
<option value="all" data-i18n="crm-filter-all">All Leads</option>
|
||||
<select hx-get="/api/crm/deals" hx-trigger="change" hx-target="#deals-table-body" hx-include="this">
|
||||
<option value="all" data-i18n="crm-filter-all">All Deals</option>
|
||||
<option value="new" data-i18n="crm-filter-new">New</option>
|
||||
<option value="contacted" data-i18n="crm-filter-contacted">Contacted</option>
|
||||
<option value="qualified" data-i18n="crm-filter-qualified">Qualified</option>
|
||||
<option value="proposal" data-i18n="crm-filter-proposal">Proposal</option>
|
||||
<option value="negotiation" data-i18n="crm-filter-negotiation">Negotiation</option>
|
||||
<option value="won" data-i18n="crm-filter-won">Won</option>
|
||||
<option value="lost" data-i18n="crm-filter-lost">Lost</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="crm-col-name">Name</th>
|
||||
<th data-i18n="crm-col-company">Company</th>
|
||||
<th data-i18n="crm-col-email">Email</th>
|
||||
<th data-i18n="crm-col-phone">Phone</th>
|
||||
<th data-i18n="crm-col-source">Source</th>
|
||||
<th data-i18n="crm-col-status">Status</th>
|
||||
<th data-i18n="crm-col-created">Created</th>
|
||||
<th data-i18n="crm-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="leads-table-body" hx-get="/api/ui/crm/leads" hx-trigger="load">
|
||||
<!-- Leads loaded via HTMX -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Opportunities List View -->
|
||||
<div id="crm-opportunities-view" class="crm-view">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="crm-col-opportunity">Opportunity</th>
|
||||
<th data-i18n="crm-col-account">Account</th>
|
||||
<th data-i18n="crm-col-title">Title</th>
|
||||
<th data-i18n="crm-col-value">Value</th>
|
||||
<th data-i18n="crm-col-stage">Stage</th>
|
||||
<th data-i18n="crm-col-contact">Contact</th>
|
||||
<th data-i18n="crm-col-account">Account</th>
|
||||
<th data-i18n="crm-col-probability">Probability</th>
|
||||
<th data-i18n="crm-col-close-date">Expected Close</th>
|
||||
<th data-i18n="crm-col-owner">Owner</th>
|
||||
<th data-i18n="crm-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opportunities-table-body" hx-get="/api/ui/crm/opportunities" hx-trigger="load">
|
||||
<tbody id="deals-table-body" hx-get="/api/ui/crm/deals" hx-trigger="load">
|
||||
<!-- Deals loaded via HTMX -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -507,6 +507,25 @@
|
|||
grid-auto-rows: 24px;
|
||||
}
|
||||
|
||||
.virtual-viewport {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.virtual-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.virtual-viewport .cell {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 100px;
|
||||
width: 100px;
|
||||
|
|
|
|||
|
|
@ -207,6 +207,31 @@
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="toolbar-divider"></span>
|
||||
<div class="toolbar-group">
|
||||
<button class="btn-icon" id="importXlsxBtn" title="Import xlsx/csv">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="exportXlsxBtn" title="Export xlsx">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="exportCsvBtn" title="Export CSV">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class="collaborators" id="collaborators"></div>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,261 @@
|
|||
MAX_HISTORY: 50,
|
||||
AUTOSAVE_DELAY: 3000,
|
||||
WS_RECONNECT_DELAY: 3000,
|
||||
VIRTUAL_SCROLL_THRESHOLD: 500,
|
||||
BUFFER_SIZE: 10,
|
||||
};
|
||||
|
||||
let virtualGrid = null;
|
||||
let useVirtualScroll = false;
|
||||
|
||||
class VirtualGrid {
|
||||
constructor(container, options = {}) {
|
||||
this.options = {
|
||||
colCount: options.colCount || CONFIG.COLS,
|
||||
rowCount: options.rowCount || CONFIG.ROWS,
|
||||
colWidth: options.colWidth || CONFIG.COL_WIDTH,
|
||||
rowHeight: options.rowHeight || CONFIG.ROW_HEIGHT,
|
||||
bufferSize: options.bufferSize || CONFIG.BUFFER_SIZE,
|
||||
...options
|
||||
};
|
||||
|
||||
this.container = container;
|
||||
this.cellCache = new Map();
|
||||
this.renderedCells = new Map();
|
||||
this.visibleStartRow = 0;
|
||||
this.visibleEndRow = 0;
|
||||
this.visibleStartCol = 0;
|
||||
this.visibleEndCol = 0;
|
||||
this.scrollLeft = 0;
|
||||
this.scrollTop = 0;
|
||||
this.isRendering = false;
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.viewport = document.createElement('div');
|
||||
this.viewport.className = 'virtual-viewport';
|
||||
this.viewport.style.cssText = 'position:relative; overflow:auto; width:100%; height:100%;';
|
||||
|
||||
this.content = document.createElement('div');
|
||||
this.content.className = 'virtual-content';
|
||||
this.content.style.cssText = `position:absolute; top:0; left:0; width:${this.options.colCount * this.options.colWidth}px; height:${this.options.rowCount * this.options.rowHeight}px;`;
|
||||
|
||||
this.viewport.appendChild(this.content);
|
||||
this.container.appendChild(this.viewport);
|
||||
|
||||
this.viewport.addEventListener('scroll', () => this.onScroll(), { passive: true });
|
||||
|
||||
this.rowHeaders = document.createElement('div');
|
||||
this.rowHeaders.className = 'virtual-row-headers';
|
||||
this.rowHeaders.style.cssText = 'position:sticky; left:0; z-index:10; display:flex; flex-direction:column;';
|
||||
|
||||
this.updateDimensions();
|
||||
this.onScroll();
|
||||
}
|
||||
|
||||
updateDimensions() {
|
||||
this.content.style.width = `${this.options.colCount * this.options.colWidth}px`;
|
||||
this.content.style.height = `${this.options.rowCount * this.options.rowHeight}px`;
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
if (this.isRendering) return;
|
||||
|
||||
const lastScrollTop = this.scrollTop;
|
||||
const lastScrollLeft = this.scrollLeft;
|
||||
|
||||
this.scrollTop = this.viewport.scrollTop;
|
||||
this.scrollLeft = this.viewport.scrollLeft;
|
||||
|
||||
if (this.scrollTop === lastScrollTop && this.scrollLeft === lastScrollLeft) return;
|
||||
|
||||
requestAnimationFrame(() => this.renderVisibleCells());
|
||||
}
|
||||
|
||||
renderVisibleCells() {
|
||||
this.isRendering = true;
|
||||
|
||||
const viewHeight = this.viewport.clientHeight;
|
||||
const viewWidth = this.viewport.clientWidth;
|
||||
const buffer = this.options.bufferSize;
|
||||
|
||||
const newStartRow = Math.max(0, Math.floor(this.scrollTop / this.options.rowHeight) - buffer);
|
||||
const newEndRow = Math.min(this.options.rowCount - 1, Math.ceil((this.scrollTop + viewHeight) / this.options.rowHeight) + buffer);
|
||||
const newStartCol = Math.max(0, Math.floor(this.scrollLeft / this.options.colWidth) - buffer);
|
||||
const newEndCol = Math.min(this.options.colCount - 1, Math.ceil((this.scrollLeft + viewWidth) / this.options.colWidth) + buffer);
|
||||
|
||||
if (newStartRow === this.visibleStartRow && newEndRow === this.visibleEndRow &&
|
||||
newStartCol === this.visibleStartCol && newEndCol === this.visibleEndCol) {
|
||||
this.isRendering = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.visibleStartRow = newStartRow;
|
||||
this.visibleEndRow = newEndRow;
|
||||
this.visibleStartCol = newStartCol;
|
||||
this.visibleEndCol = newEndCol;
|
||||
|
||||
for (const [key, el] of this.renderedCells) {
|
||||
const [r, c] = key.split(',').map(Number);
|
||||
if (r < this.visibleStartRow || r > this.visibleEndRow ||
|
||||
c < this.visibleStartCol || c > this.visibleEndCol) {
|
||||
el.remove();
|
||||
this.renderedCells.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = this.visibleStartRow; row <= this.visibleEndRow; row++) {
|
||||
for (let col = this.visibleStartCol; col <= this.visibleEndCol; col++) {
|
||||
const key = `${row},${col}`;
|
||||
const cellData = this.cellCache.get(key);
|
||||
|
||||
if (!this.renderedCells.has(key)) {
|
||||
const cell = this.createCellElement(row, col, cellData);
|
||||
cell.style.position = 'absolute';
|
||||
cell.style.top = `${row * this.options.rowHeight}px`;
|
||||
cell.style.left = `${col * this.options.colWidth}px`;
|
||||
cell.style.width = `${this.options.colWidth}px`;
|
||||
cell.style.height = `${this.options.rowHeight}px`;
|
||||
cell.dataset.row = row;
|
||||
cell.dataset.col = col;
|
||||
this.content.appendChild(cell);
|
||||
this.renderedCells.set(key, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.isRendering = false;
|
||||
}
|
||||
|
||||
createCellElement(row, col, cellData) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cell';
|
||||
|
||||
if (cellData) {
|
||||
if (cellData.formula) {
|
||||
cell.textContent = evaluateFormula(cellData.formula, row, col);
|
||||
} else if (cellData.value !== undefined) {
|
||||
cell.textContent = cellData.value;
|
||||
}
|
||||
if (cellData.style) {
|
||||
this.applyStyle(cell, cellData.style);
|
||||
}
|
||||
if (cellData.merged) {
|
||||
const { rowSpan, colSpan } = cellData.merged;
|
||||
if (rowSpan > 1) cell.style.gridRow = `span ${rowSpan}`;
|
||||
if (colSpan > 1) cell.style.gridColumn = `span ${colSpan}`;
|
||||
}
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
applyStyle(cell, style) {
|
||||
if (!style) return;
|
||||
if (style.fontFamily) cell.style.fontFamily = style.fontFamily;
|
||||
if (style.fontSize) cell.style.fontSize = style.fontSize + 'px';
|
||||
if (style.fontWeight) cell.style.fontWeight = style.fontWeight;
|
||||
if (style.fontStyle) cell.style.fontStyle = style.fontStyle;
|
||||
if (style.textDecoration) cell.style.textDecoration = style.textDecoration;
|
||||
if (style.color) cell.style.color = style.color;
|
||||
if (style.background) cell.style.backgroundColor = style.background;
|
||||
if (style.textAlign) cell.style.textAlign = style.textAlign;
|
||||
}
|
||||
|
||||
setCellValue(row, col, value) {
|
||||
const key = `${row},${col}`;
|
||||
|
||||
if (!value || (typeof value === 'object' && !value.value && !value.formula)) {
|
||||
this.cellCache.delete(key);
|
||||
} else {
|
||||
if (typeof value === 'object') {
|
||||
this.cellCache.set(key, value);
|
||||
} else {
|
||||
this.cellCache.set(key, { value: String(value) });
|
||||
}
|
||||
}
|
||||
|
||||
if (row >= this.visibleStartRow && row <= this.visibleEndRow &&
|
||||
col >= this.visibleStartCol && col <= this.visibleEndCol) {
|
||||
const existing = this.renderedCells.get(key);
|
||||
|
||||
if (!value || (typeof value === 'object' && !value.value && !value.formula)) {
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
this.renderedCells.delete(key);
|
||||
}
|
||||
} else if (existing) {
|
||||
const cell = this.createCellElement(row, col, typeof value === 'object' ? value : { value });
|
||||
existing.textContent = cell.textContent;
|
||||
existing.style.cssText = cell.style.cssText;
|
||||
} else {
|
||||
const cell = this.createCellElement(row, col, typeof value === 'object' ? value : { value });
|
||||
cell.style.position = 'absolute';
|
||||
cell.style.top = `${row * this.options.rowHeight}px`;
|
||||
cell.style.left = `${col * this.options.colWidth}px`;
|
||||
cell.style.width = `${this.options.colWidth}px`;
|
||||
cell.style.height = `${this.options.rowHeight}px`;
|
||||
cell.dataset.row = row;
|
||||
cell.dataset.col = col;
|
||||
this.content.appendChild(cell);
|
||||
this.renderedCells.set(key, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCellValue(row, col) {
|
||||
return this.cellCache.get(`${row},${col}`);
|
||||
}
|
||||
|
||||
scrollToCell(row, col) {
|
||||
const targetTop = row * this.options.rowHeight;
|
||||
const targetLeft = col * this.options.colWidth;
|
||||
const viewHeight = this.viewport.clientHeight;
|
||||
const viewWidth = this.viewport.clientWidth;
|
||||
|
||||
this.viewport.scrollTo({
|
||||
left: targetLeft - viewWidth / 2,
|
||||
top: targetTop - viewHeight / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
getVisibleRange() {
|
||||
return {
|
||||
startRow: this.visibleStartRow,
|
||||
endRow: this.visibleEndRow,
|
||||
startCol: this.visibleStartCol,
|
||||
endCol: this.visibleEndCol
|
||||
};
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.renderVisibleCells();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.viewport.remove();
|
||||
this.cellCache.clear();
|
||||
this.renderedCells.clear();
|
||||
}
|
||||
|
||||
loadData(data) {
|
||||
this.cellCache.clear();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value && (value.value || value.formula || value.style)) {
|
||||
this.cellCache.set(key, value);
|
||||
}
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
getViewportScroll() {
|
||||
return { top: this.scrollTop, left: this.scrollLeft };
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
sheetId: null,
|
||||
sheetName: "Untitled Spreadsheet",
|
||||
|
|
@ -44,6 +297,184 @@
|
|||
|
||||
const elements = {};
|
||||
|
||||
class AuditLog {
|
||||
constructor() {
|
||||
this.entries = [];
|
||||
this.maxEntries = 1000;
|
||||
}
|
||||
|
||||
log(action, details = {}) {
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
details,
|
||||
sheetId: state.sheetId
|
||||
};
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > this.maxEntries) {
|
||||
this.entries.shift();
|
||||
}
|
||||
this.persistEntry(entry);
|
||||
}
|
||||
|
||||
async persistEntry(entry) {
|
||||
if (!state.sheetId) return;
|
||||
try {
|
||||
await fetch('/api/sheet/audit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Audit log persist failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
getHistory(filter = {}) {
|
||||
let filtered = this.entries;
|
||||
if (filter.action) {
|
||||
filtered = filtered.filter(e => e.action === filter.action);
|
||||
}
|
||||
if (filter.startTime) {
|
||||
filtered = filtered.filter(e => new Date(e.timestamp) >= new Date(filter.startTime));
|
||||
}
|
||||
if (filter.endTime) {
|
||||
filtered = filtered.filter(e => new Date(e.timestamp) <= new Date(filter.endTime));
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
class VersionManager {
|
||||
constructor() {
|
||||
this.versions = [];
|
||||
this.currentVersion = -1;
|
||||
this.maxVersions = 100;
|
||||
this.autoSaveInterval = null;
|
||||
this.lastSavedState = null;
|
||||
}
|
||||
|
||||
createSnapshot(reason = 'manual') {
|
||||
const snapshot = {
|
||||
timestamp: new Date().toISOString(),
|
||||
reason,
|
||||
worksheets: JSON.parse(JSON.stringify(state.worksheets)),
|
||||
sheetName: state.sheetName
|
||||
};
|
||||
|
||||
if (this.lastSavedState && JSON.stringify(this.lastSavedState) === JSON.stringify(snapshot.worksheets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.versions.push(snapshot);
|
||||
this.currentVersion = this.versions.length - 1;
|
||||
this.lastSavedState = JSON.parse(JSON.stringify(snapshot.worksheets));
|
||||
|
||||
if (this.versions.length > this.maxVersions) {
|
||||
this.versions.shift();
|
||||
this.currentVersion--;
|
||||
}
|
||||
|
||||
this.persistVersion(snapshot);
|
||||
return this.versions.length - 1;
|
||||
}
|
||||
|
||||
async persistVersion(snapshot) {
|
||||
if (!state.sheetId) return;
|
||||
try {
|
||||
await fetch('/api/sheet/version', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sheetId: state.sheetId,
|
||||
snapshot
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Version persist failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
restoreVersion(versionIndex) {
|
||||
if (versionIndex < 0 || versionIndex >= this.versions.length) return false;
|
||||
|
||||
const version = this.versions[versionIndex];
|
||||
state.worksheets = JSON.parse(JSON.stringify(version.worksheets));
|
||||
state.sheetName = version.sheetName;
|
||||
|
||||
if (useVirtualScroll && virtualGrid) {
|
||||
const ws = state.worksheets[state.activeWorksheet];
|
||||
virtualGrid.loadData(ws?.data || {});
|
||||
} else {
|
||||
renderAllCells();
|
||||
}
|
||||
renderWorksheetTabs();
|
||||
|
||||
auditLog.log('version_restore', { versionIndex, timestamp: version.timestamp });
|
||||
return true;
|
||||
}
|
||||
|
||||
getVersionList() {
|
||||
return this.versions.map((v, i) => ({
|
||||
index: i,
|
||||
timestamp: v.timestamp,
|
||||
reason: v.reason,
|
||||
sheetName: v.sheetName
|
||||
})).reverse();
|
||||
}
|
||||
|
||||
startAutoSave() {
|
||||
if (this.autoSaveInterval) return;
|
||||
this.autoSaveInterval = setInterval(() => {
|
||||
if (state.isDirty) {
|
||||
this.createSnapshot('auto');
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
stopAutoSave() {
|
||||
if (this.autoSaveInterval) {
|
||||
clearInterval(this.autoSaveInterval);
|
||||
this.autoSaveInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionManager {
|
||||
constructor() {
|
||||
this.permissions = new Map();
|
||||
this.currentUserLevel = 'edit';
|
||||
}
|
||||
|
||||
setPermission(userId, level) {
|
||||
this.permissions.set(userId, level);
|
||||
}
|
||||
|
||||
setCurrentUserLevel(level) {
|
||||
this.currentUserLevel = level;
|
||||
}
|
||||
|
||||
canEdit() {
|
||||
return this.currentUserLevel === 'edit' || this.currentUserLevel === 'admin';
|
||||
}
|
||||
|
||||
canDelete() {
|
||||
return this.currentUserLevel === 'admin';
|
||||
}
|
||||
|
||||
canShare() {
|
||||
return this.currentUserLevel === 'admin';
|
||||
}
|
||||
|
||||
canExport() {
|
||||
return this.currentUserLevel === 'view' || this.currentUserLevel === 'edit' || this.currentUserLevel === 'admin';
|
||||
}
|
||||
}
|
||||
|
||||
const auditLog = new AuditLog();
|
||||
const versionManager = new VersionManager();
|
||||
const permissions = new PermissionManager();
|
||||
|
||||
function init() {
|
||||
cacheElements();
|
||||
renderGrid();
|
||||
|
|
@ -92,27 +523,85 @@
|
|||
elements.insertImageModal = document.getElementById("insertImageModal");
|
||||
}
|
||||
|
||||
function initVirtualGrid() {
|
||||
const container = document.getElementById('cellsContainer');
|
||||
if (!container || virtualGrid) return;
|
||||
|
||||
virtualGrid = new VirtualGrid(container, {
|
||||
colCount: CONFIG.COLS,
|
||||
rowCount: CONFIG.ROWS,
|
||||
colWidth: CONFIG.COL_WIDTH,
|
||||
rowHeight: CONFIG.ROW_HEIGHT
|
||||
});
|
||||
|
||||
const ws = state.worksheets[state.activeWorksheet];
|
||||
if (ws && ws.data) {
|
||||
virtualGrid.loadData(ws.data);
|
||||
}
|
||||
}
|
||||
|
||||
function destroyVirtualGrid() {
|
||||
if (virtualGrid) {
|
||||
virtualGrid.destroy();
|
||||
virtualGrid = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
renderColumnHeaders();
|
||||
renderRowHeaders();
|
||||
|
||||
useVirtualScroll = CONFIG.ROWS > CONFIG.VIRTUAL_SCROLL_THRESHOLD;
|
||||
|
||||
if (useVirtualScroll) {
|
||||
elements.cells.style.display = 'none';
|
||||
if (!virtualGrid) {
|
||||
initVirtualGrid();
|
||||
} else {
|
||||
virtualGrid.refresh();
|
||||
}
|
||||
} else {
|
||||
if (virtualGrid) {
|
||||
destroyVirtualGrid();
|
||||
}
|
||||
elements.cells.style.display = '';
|
||||
renderAllCellsLegacy();
|
||||
}
|
||||
}
|
||||
|
||||
function renderColumnHeaders() {
|
||||
elements.columnHeaders.innerHTML = "";
|
||||
for (let col = 0; col < CONFIG.COLS; col++) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "column-header";
|
||||
header.textContent = getColName(col);
|
||||
header.dataset.col = col;
|
||||
header.addEventListener('click', handleColumnHeaderClick);
|
||||
elements.columnHeaders.appendChild(header);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRowHeaders() {
|
||||
elements.rowHeaders.innerHTML = "";
|
||||
for (let row = 0; row < CONFIG.ROWS; row++) {
|
||||
const maxRows = useVirtualScroll ? Math.min(100, CONFIG.ROWS) : CONFIG.ROWS;
|
||||
for (let row = 0; row < maxRows; row++) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "row-header";
|
||||
header.textContent = row + 1;
|
||||
header.dataset.row = row;
|
||||
header.addEventListener('click', handleRowHeaderClick);
|
||||
elements.rowHeaders.appendChild(header);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAllCellsLegacy() {
|
||||
const ws = state.worksheets[state.activeWorksheet];
|
||||
if (!ws) return;
|
||||
|
||||
elements.cells.innerHTML = "";
|
||||
elements.cells.style.gridTemplateColumns = `repeat(${CONFIG.COLS}, ${CONFIG.COL_WIDTH}px)`;
|
||||
elements.cells.style.gridTemplateRows = `repeat(${CONFIG.ROWS}, ${CONFIG.ROW_HEIGHT}px)`;
|
||||
|
||||
for (let row = 0; row < CONFIG.ROWS; row++) {
|
||||
for (let col = 0; col < CONFIG.COLS; col++) {
|
||||
const cell = document.createElement("div");
|
||||
|
|
@ -122,23 +611,37 @@
|
|||
elements.cells.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
renderAllCells();
|
||||
}
|
||||
|
||||
function renderAllCells() {
|
||||
const ws = state.worksheets[state.activeWorksheet];
|
||||
if (!ws) return;
|
||||
|
||||
|
||||
const cells = elements.cells.querySelectorAll(".cell");
|
||||
cells.forEach((cell) => {
|
||||
const row = parseInt(cell.dataset.row);
|
||||
const col = parseInt(cell.dataset.col);
|
||||
renderCell(row, col);
|
||||
renderCellLegacy(row, col);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAllCells() {
|
||||
if (useVirtualScroll && virtualGrid) {
|
||||
const ws = state.worksheets[state.activeWorksheet];
|
||||
if (ws && ws.data) {
|
||||
virtualGrid.loadData(ws.data);
|
||||
}
|
||||
} else {
|
||||
renderAllCellsLegacy();
|
||||
}
|
||||
}
|
||||
|
||||
function renderCell(row, col) {
|
||||
if (useVirtualScroll && virtualGrid) {
|
||||
const ws = state.worksheets[state.activeWorksheet];
|
||||
const data = ws?.data?.[`${row},${col}`];
|
||||
virtualGrid.setCellValue(row, col, data);
|
||||
} else {
|
||||
renderCellLegacy(row, col);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCellLegacy(row, col) {
|
||||
const cell = elements.cells.querySelector(
|
||||
`[data-row="${row}"][data-col="${col}"]`,
|
||||
);
|
||||
|
|
@ -299,6 +802,22 @@
|
|||
document.getElementById("zoomInBtn")?.addEventListener("click", zoomIn);
|
||||
document.getElementById("zoomOutBtn")?.addEventListener("click", zoomOut);
|
||||
|
||||
document
|
||||
.getElementById("importXlsxBtn")
|
||||
?.addEventListener("click", () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.xlsx,.xls,.csv,.ods';
|
||||
input.onchange = async (e) => {
|
||||
if (e.target.files[0]) {
|
||||
await importXlsx(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
document.getElementById("exportXlsxBtn")?.addEventListener("click", exportXlsx);
|
||||
document.getElementById("exportCsvBtn")?.addEventListener("click", exportCsv);
|
||||
|
||||
document
|
||||
.getElementById("findReplaceBtn")
|
||||
?.addEventListener("click", showFindReplaceModal);
|
||||
|
|
@ -520,12 +1039,17 @@
|
|||
end: { row, col },
|
||||
};
|
||||
|
||||
const cell = elements.cells.querySelector(
|
||||
`[data-row="${row}"][data-col="${col}"]`,
|
||||
);
|
||||
if (cell) {
|
||||
cell.classList.add("selected");
|
||||
cell.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
if (useVirtualScroll && virtualGrid) {
|
||||
virtualGrid.scrollToCell(row, col);
|
||||
setTimeout(() => highlightVirtualCell(row, col), 50);
|
||||
} else {
|
||||
const cell = elements.cells.querySelector(
|
||||
`[data-row="${row}"][data-col="${col}"]`,
|
||||
);
|
||||
if (cell) {
|
||||
cell.classList.add("selected");
|
||||
cell.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
updateCellAddress();
|
||||
|
|
@ -533,6 +1057,14 @@
|
|||
updateSelectionInfo();
|
||||
}
|
||||
|
||||
function highlightVirtualCell(row, col) {
|
||||
const cell = elements.cells.querySelector(`[data-row="${row}"][data-col="${col}"]`);
|
||||
if (cell && !cell.classList.contains('selected')) {
|
||||
clearSelection();
|
||||
cell.classList.add('selected');
|
||||
}
|
||||
}
|
||||
|
||||
function extendSelection(row, col) {
|
||||
clearSelection();
|
||||
|
||||
|
|
@ -695,8 +1227,14 @@
|
|||
}
|
||||
|
||||
function setCellValue(row, col, value) {
|
||||
if (!permissions.canEdit()) {
|
||||
addChatMessage("system", "You don't have permission to edit this sheet");
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = state.worksheets[state.activeWorksheet];
|
||||
const key = `${row},${col}`;
|
||||
const oldValue = ws.data[key]?.value || ws.data[key]?.formula || '';
|
||||
|
||||
saveToHistory();
|
||||
|
||||
|
|
@ -708,6 +1246,12 @@
|
|||
ws.data[key] = { value };
|
||||
}
|
||||
|
||||
if (useVirtualScroll && virtualGrid) {
|
||||
virtualGrid.setCellValue(row, col, ws.data[key]);
|
||||
}
|
||||
|
||||
auditLog.log('cell_change', { row, col, oldValue, newValue: value, cellRef: getCellRef(row, col) });
|
||||
|
||||
state.isDirty = true;
|
||||
scheduleAutoSave();
|
||||
broadcastChange("cell", { row, col, value });
|
||||
|
|
@ -725,6 +1269,13 @@
|
|||
return data.value || "";
|
||||
}
|
||||
|
||||
function getCellValue(row, col) {
|
||||
const data = getCellData(row, col);
|
||||
if (!data) return "";
|
||||
if (data.formula) return evaluateFormula(data.formula, row, col);
|
||||
return data.value || "";
|
||||
}
|
||||
|
||||
function evaluateFormula(formula, sourceRow, sourceCol) {
|
||||
if (!formula.startsWith("=")) return formula;
|
||||
|
||||
|
|
@ -1539,6 +2090,127 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function importXlsx(file) {
|
||||
elements.saveStatus.textContent = "Importing...";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sheet/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
state.sheetId = data.id;
|
||||
state.sheetName = data.name || file.name.replace(/\.[^/.]+$/, '');
|
||||
state.worksheets = data.worksheets || [{ name: "Sheet1", data: {} }];
|
||||
|
||||
if (elements.sheetName) elements.sheetName.value = state.sheetName;
|
||||
|
||||
CONFIG.ROWS = Math.max(CONFIG.ROWS, state.worksheets.reduce((max, ws) => {
|
||||
const maxRow = Object.keys(ws.data || {}).reduce((m, key) => {
|
||||
const [r] = key.split(',').map(Number);
|
||||
return Math.max(m, r);
|
||||
}, 0);
|
||||
return Math.max(max, maxRow + 1);
|
||||
}, CONFIG.ROWS));
|
||||
|
||||
window.history.replaceState({}, "", `#id=${state.sheetId}`);
|
||||
|
||||
renderWorksheetTabs();
|
||||
renderGrid();
|
||||
|
||||
elements.saveStatus.textContent = "Imported";
|
||||
addChatMessage("system", `Successfully imported ${file.name}`);
|
||||
} else {
|
||||
const err = await response.json();
|
||||
elements.saveStatus.textContent = "Import failed";
|
||||
addChatMessage("error", `Import failed: ${err.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (e) {
|
||||
elements.saveStatus.textContent = "Import failed";
|
||||
addChatMessage("error", `Import failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportXlsx() {
|
||||
elements.saveStatus.textContent = "Exporting...";
|
||||
|
||||
try {
|
||||
if (!state.sheetId) {
|
||||
const response = await saveSheet();
|
||||
if (!response.ok) throw new Error('Failed to save first');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/sheet/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: state.sheetId,
|
||||
format: 'xlsx'
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${state.sheetName || 'spreadsheet'}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
elements.saveStatus.textContent = "Exported";
|
||||
addChatMessage("system", "Spreadsheet exported successfully");
|
||||
} else {
|
||||
const err = await response.json();
|
||||
elements.saveStatus.textContent = "Export failed";
|
||||
addChatMessage("error", `Export failed: ${err.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (e) {
|
||||
elements.saveStatus.textContent = "Export failed";
|
||||
addChatMessage("error", `Export failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
elements.saveStatus.textContent = "Exporting...";
|
||||
|
||||
try {
|
||||
if (!state.sheetId) {
|
||||
await saveSheet();
|
||||
}
|
||||
|
||||
const response = await fetch('/api/sheet/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: state.sheetId,
|
||||
format: 'csv'
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${state.sheetName || 'spreadsheet'}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
elements.saveStatus.textContent = "Exported";
|
||||
} else {
|
||||
elements.saveStatus.textContent = "Export failed";
|
||||
}
|
||||
} catch (e) {
|
||||
elements.saveStatus.textContent = "Export failed";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromUrlParams() {
|
||||
const hash = window.location.hash;
|
||||
if (!hash) return;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue