crm-summit/app/Views/figma_dashboard.php
mahdahar 329e4e6725 feat(figma): add local sync dashboard, API, and CLI tooling
Add Figma persistence and sync flow for one file source.

- create figma_files, figma_file_versions, and figma_comments tables with supporting migrations
- add FigmaSyncService for full and incremental sync, API fetch, pagination, dedupe, and upserts
- add CLI commands and shell wrappers for full and incremental sync runs
- expose Figma dashboard plus API endpoints for summary, snapshots, comments, and admin sync trigger
- wire route and sidebar entry for dashboard access
- trim legacy file_url and thumbnail_url fields, add version label/description support
2026-04-27 16:55:43 +07:00

379 lines
12 KiB
PHP

<?= $this->extend('layouts/main.php') ?>
<?= $this->section('content') ?>
<div class="page-wrapper">
<div class="container-fluid">
<div class="row page-titles">
<div class="col-md-6 align-self-center">
<h4 class="text-themecolor">Figma Dashboard (DB)</h4>
</div>
<div class="col-md-6 text-end">
<?php if (!empty($isAdmin)): ?>
<button id="btnSync" class="btn btn-primary">Sync Now</button>
<?php endif; ?>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3">
<label class="form-label">Start Date</label>
<input type="date" id="filterStart" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label">End Date</label>
<input type="date" id="filterEnd" class="form-control">
</div>
<div class="col-md-2 d-flex align-items-end">
<button id="btnApplyFilter" class="btn btn-success w-100">Apply</button>
</div>
</div>
<div class="row mb-3 g-3">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="small text-muted">Current File</div>
<div id="currentFileName" class="fw-bold">-</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="small text-muted">Current Version</div>
<div id="currentFileVersion" class="fw-bold">-</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="small text-muted">Snapshots</div>
<div id="totalSnapshots" class="fw-bold">0</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="small text-muted">Comments</div>
<div id="totalComments" class="fw-bold">0</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Latest File Snapshots</h5>
<div><strong>Total Snapshots:</strong> <span id="totalVersionRows">0</span></div>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="tableVersions">
<thead>
<tr>
<th>Date</th>
<th>Label</th>
<th>Description</th>
<th>Version</th>
<th>Editor</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<button id="versionPrev" class="btn btn-outline-secondary btn-sm">Prev</button>
<div id="versionPageInfo" class="small text-muted">Page 0 of 0</div>
<button id="versionNext" class="btn btn-outline-secondary btn-sm">Next</button>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Latest Comments</h5>
<div><strong>Total Comments:</strong> <span id="totalCommentRows">0</span></div>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="tableComments">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Comment</th>
<th>Status</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<button id="commentPrev" class="btn btn-outline-secondary btn-sm">Prev</button>
<div id="commentPageInfo" class="small text-muted">Page 0 of 0</div>
<button id="commentNext" class="btn btn-outline-secondary btn-sm">Next</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('script') ?>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const filterStart = document.getElementById('filterStart');
const filterEnd = document.getElementById('filterEnd');
const btnApplyFilter = document.getElementById('btnApplyFilter');
const btnSync = document.getElementById('btnSync');
const versionPrev = document.getElementById('versionPrev');
const versionNext = document.getElementById('versionNext');
const versionPageInfo = document.getElementById('versionPageInfo');
const commentPrev = document.getElementById('commentPrev');
const commentNext = document.getElementById('commentNext');
const commentPageInfo = document.getElementById('commentPageInfo');
const totalSnapshots = document.getElementById('totalSnapshots');
const totalComments = document.getElementById('totalComments');
const totalVersionRows = document.getElementById('totalVersionRows');
const totalCommentRows = document.getElementById('totalCommentRows');
const currentFileName = document.getElementById('currentFileName');
const currentFileVersion = document.getElementById('currentFileVersion');
const versionsState = {
page: 1,
perPage: 25,
total: 0,
totalPages: 0,
loaded: false,
};
const commentsState = {
page: 1,
perPage: 25,
total: 0,
totalPages: 0,
loaded: false,
};
setDefaultDateRange();
await loadSummary();
await loadTables();
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
btnApplyFilter.addEventListener('click', async () => {
versionsState.page = 1;
commentsState.page = 1;
versionsState.loaded = true;
commentsState.loaded = true;
await loadTables();
});
versionPrev.addEventListener('click', async () => {
if (versionsState.page > 1) {
versionsState.page--;
await loadVersions();
}
});
versionNext.addEventListener('click', async () => {
if (versionsState.page < versionsState.totalPages) {
versionsState.page++;
await loadVersions();
}
});
commentPrev.addEventListener('click', async () => {
if (commentsState.page > 1) {
commentsState.page--;
await loadComments();
}
});
commentNext.addEventListener('click', async () => {
if (commentsState.page < commentsState.totalPages) {
commentsState.page++;
await loadComments();
}
});
if (btnSync) {
btnSync.addEventListener('click', async () => {
btnSync.disabled = true;
btnSync.innerText = 'Syncing...';
try {
const response = await fetch(`<?= base_url('api/figma/sync') ?>`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert('Sync complete');
await loadSummary();
await loadTables();
} else {
alert(result.message || 'Sync failed');
}
} catch (error) {
alert('Sync request failed');
}
btnSync.disabled = false;
btnSync.innerText = 'Sync Now';
});
}
function setDefaultDateRange() {
const today = new Date();
const startOfYear = new Date(today.getFullYear(), 0, 1);
filterStart.value = toInputDate(startOfYear);
filterEnd.value = toInputDate(today);
}
function toInputDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function updatePager(prevBtn, nextBtn, infoEl, state) {
const totalPages = state.loaded ? state.totalPages : 0;
const page = state.loaded && totalPages > 0 ? state.page : 0;
prevBtn.disabled = !state.loaded || page <= 1;
nextBtn.disabled = !state.loaded || page >= totalPages || totalPages <= 1;
infoEl.innerText = `Page ${page} of ${totalPages}`;
}
function getBaseParams(page, perPage) {
const params = new URLSearchParams();
if (filterStart.value) params.set('start_date', filterStart.value);
if (filterEnd.value) params.set('end_date', filterEnd.value);
params.set('page', String(page));
params.set('per_page', String(perPage));
return params.toString();
}
async function loadSummary() {
const response = await fetch(`<?= base_url('api/figma/summary') ?>`);
const result = await response.json();
if (!response.ok || !result.data) {
return;
}
currentFileName.innerText = result.data.file?.name || '-';
currentFileVersion.innerText = result.data.latest_version_label || result.data.file?.version || '-';
totalSnapshots.innerText = String(result.data.versions ?? 0);
totalComments.innerText = String(result.data.comments ?? 0);
}
async function loadTables() {
await loadVersions();
await loadComments();
}
async function loadVersions() {
const tbody = document.querySelector('#tableVersions tbody');
tbody.innerHTML = '<tr><td colspan="5">Loading...</td></tr>';
const response = await fetch(`<?= base_url('api/figma/snapshots') ?>?${getBaseParams(versionsState.page, versionsState.perPage)}`);
const result = await response.json();
if (!response.ok || !Array.isArray(result.data)) {
versionsState.total = 0;
versionsState.totalPages = 0;
totalVersionRows.innerText = '0';
tbody.innerHTML = '<tr><td colspan="5">Failed loading snapshots</td></tr>';
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
return;
}
versionsState.total = Number(result.meta?.total ?? result.data.length ?? 0);
versionsState.totalPages = Number(result.meta?.total_pages ?? 0);
versionsState.page = Number(result.meta?.page ?? versionsState.page);
versionsState.perPage = Number(result.meta?.per_page ?? versionsState.perPage);
versionsState.loaded = true;
totalVersionRows.innerText = String(versionsState.total);
if (!result.data.length) {
tbody.innerHTML = '<tr><td colspan="5">No snapshots found</td></tr>';
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
return;
}
tbody.innerHTML = result.data.map(item => {
return `<tr>
<td>${escapeHtml(item.created_at_figma || '')}</td>
<td>${escapeHtml(item.label || item.version || item.figma_version_id || '')}</td>
<td>${escapeHtml(item.description || '')}</td>
<td>${escapeHtml(item.version || item.figma_version_id || '')}</td>
<td>${escapeHtml(item.editor_type || '')}</td>
</tr>`;
}).join('');
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
}
async function loadComments() {
const tbody = document.querySelector('#tableComments tbody');
tbody.innerHTML = '<tr><td colspan="4">Loading...</td></tr>';
const response = await fetch(`<?= base_url('api/figma/comments') ?>?${getBaseParams(commentsState.page, commentsState.perPage)}`);
const result = await response.json();
if (!response.ok || !Array.isArray(result.data)) {
commentsState.total = 0;
commentsState.totalPages = 0;
totalCommentRows.innerText = '0';
tbody.innerHTML = '<tr><td colspan="4">Failed loading comments</td></tr>';
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
return;
}
commentsState.total = Number(result.meta?.total ?? result.data.length ?? 0);
commentsState.totalPages = Number(result.meta?.total_pages ?? 0);
commentsState.page = Number(result.meta?.page ?? commentsState.page);
commentsState.perPage = Number(result.meta?.per_page ?? commentsState.perPage);
commentsState.loaded = true;
totalCommentRows.innerText = String(commentsState.total);
if (!result.data.length) {
tbody.innerHTML = '<tr><td colspan="4">No comments found</td></tr>';
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
return;
}
tbody.innerHTML = result.data.map(item => {
const comment = escapeHtml((item.message || '').slice(0, 160));
const status = item.is_resolved ? 'Resolved' : 'Open';
return `<tr>
<td>${escapeHtml(item.created_at_figma || '')}</td>
<td>${escapeHtml(item.user_name || '')}</td>
<td>${comment}</td>
<td>${status}</td>
</tr>`;
}).join('');
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
}
});
</script>
<?= $this->endSection() ?>