refactor(tapops): modularize page code
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
export class CounterAnimation {
|
||||
static DURATION_MS = 2000;
|
||||
|
||||
init() {
|
||||
const counters = document.querySelectorAll('.counter-value[data-target]');
|
||||
if (counters.length === 0) return;
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this._animate(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.2 });
|
||||
counters.forEach(c => observer.observe(c));
|
||||
} else {
|
||||
counters.forEach(c => this._animate(c));
|
||||
}
|
||||
}
|
||||
|
||||
_animate(el) {
|
||||
const target = Number.parseInt((el.dataset.target || '0').replace(/[^0-9]/g, ''), 10);
|
||||
if (Number.isNaN(target)) return;
|
||||
CounterAnimation.animateValue(el, target, el.dataset.suffix || '');
|
||||
}
|
||||
|
||||
// Reusable easing tween from 0 → target. Callable for stats whose
|
||||
// value is only known after an async fetch (no data-target needed).
|
||||
static animateValue(el, target, suffix = '') {
|
||||
if (!el || Number.isNaN(target)) return;
|
||||
const startTime = performance.now();
|
||||
|
||||
const tick = (now) => {
|
||||
const progress = Math.min((now - startTime) / CounterAnimation.DURATION_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
el.textContent = Math.floor(eased * target) + suffix;
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
else el.textContent = target + suffix;
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
export class CustomDropdown {
|
||||
constructor({ container, options, defaultValue, onChange, prefixIcon }) {
|
||||
this.container = container;
|
||||
this.options = options; // Array of { value, text, i18nKey }
|
||||
this.onChange = onChange;
|
||||
this.currentValue = defaultValue || options[0]?.value;
|
||||
this.prefixIcon = prefixIcon; // optional class name like 'fas fa-sort'
|
||||
this.isOpen = false;
|
||||
|
||||
this.elements = {};
|
||||
this._build();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_build() {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('custom-dropdown');
|
||||
|
||||
// Trigger Button
|
||||
const trigger = document.createElement('button');
|
||||
trigger.className = 'custom-dropdown-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.type = 'button';
|
||||
|
||||
const triggerLeft = document.createElement('div');
|
||||
triggerLeft.className = 'trigger-left-content';
|
||||
triggerLeft.style.display = 'flex';
|
||||
triggerLeft.style.alignItems = 'center';
|
||||
triggerLeft.style.gap = '10px';
|
||||
|
||||
if (this.prefixIcon) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = this.prefixIcon;
|
||||
triggerLeft.appendChild(icon);
|
||||
}
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'trigger-label';
|
||||
|
||||
// Find default text
|
||||
const defaultOpt = this.options.find(opt => opt.value === this.currentValue) || this.options[0];
|
||||
labelSpan.textContent = defaultOpt ? defaultOpt.text : '';
|
||||
if (defaultOpt?.i18nKey) {
|
||||
labelSpan.setAttribute('data-tp-i18n', defaultOpt.i18nKey);
|
||||
}
|
||||
|
||||
triggerLeft.appendChild(labelSpan);
|
||||
trigger.appendChild(triggerLeft);
|
||||
|
||||
const arrowIcon = document.createElement('i');
|
||||
arrowIcon.className = 'fas fa-chevron-down trigger-arrow';
|
||||
|
||||
trigger.appendChild(arrowIcon);
|
||||
this.container.appendChild(trigger);
|
||||
|
||||
// Menu wrapper
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'custom-dropdown-menu';
|
||||
menu.setAttribute('role', 'listbox');
|
||||
|
||||
// Populate options
|
||||
this.options.forEach(opt => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'custom-dropdown-item' + (opt.value === this.currentValue ? ' active' : '');
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
|
||||
const itemText = document.createElement('span');
|
||||
itemText.textContent = opt.text;
|
||||
if (opt.i18nKey) {
|
||||
itemText.setAttribute('data-tp-i18n', opt.i18nKey);
|
||||
}
|
||||
|
||||
item.appendChild(itemText);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
|
||||
this.container.appendChild(menu);
|
||||
|
||||
this.elements = { trigger, labelSpan, menu };
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// Toggle dropdown on trigger click
|
||||
this.elements.trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Handle item selection
|
||||
this.elements.menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.custom-dropdown-item');
|
||||
if (!item) return;
|
||||
|
||||
const value = item.getAttribute('data-value');
|
||||
this.select(value);
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') this.close();
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
// Close other custom dropdowns
|
||||
document.querySelectorAll('.custom-dropdown.active').forEach(dropdown => {
|
||||
if (dropdown !== this.container) {
|
||||
dropdown.classList.remove('active');
|
||||
dropdown.querySelector('.custom-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
this.container.classList.add('active');
|
||||
this.elements.trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.container.classList.remove('active');
|
||||
this.elements.trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
select(value) {
|
||||
if (value === this.currentValue) return;
|
||||
|
||||
this.currentValue = value;
|
||||
|
||||
// Update active class in menu items
|
||||
this.elements.menu.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
const isCurrent = item.getAttribute('data-value') === value;
|
||||
item.classList.toggle('active', isCurrent);
|
||||
});
|
||||
|
||||
// Update trigger label
|
||||
const selectedOpt = this.options.find(opt => opt.value === value);
|
||||
if (selectedOpt) {
|
||||
this.elements.labelSpan.textContent = selectedOpt.text;
|
||||
if (selectedOpt.i18nKey) {
|
||||
this.elements.labelSpan.setAttribute('data-tp-i18n', selectedOpt.i18nKey);
|
||||
} else {
|
||||
this.elements.labelSpan.removeAttribute('data-tp-i18n');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger callback
|
||||
if (typeof this.onChange === 'function') {
|
||||
this.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
this.select(value);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.currentValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
export class DateRangePicker {
|
||||
constructor({ containerId, onChange, initialStartDate = '', initialEndDate = '' }) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) return;
|
||||
|
||||
this.trigger = this.container.querySelector('#date-picker-trigger');
|
||||
this.popover = this.container.querySelector('#date-picker-popover');
|
||||
this.backdrop = this.container.querySelector('#date-picker-backdrop');
|
||||
this.label = this.container.querySelector('#date-picker-label');
|
||||
|
||||
this.startDisplay = this.container.querySelector('#picker-start-display');
|
||||
this.endDisplay = this.container.querySelector('#picker-end-display');
|
||||
|
||||
this.prevBtn = this.container.querySelector('#prev-month-btn');
|
||||
this.nextBtn = this.container.querySelector('#next-month-btn');
|
||||
this.leftMonthLabel = this.container.querySelector('#left-month-label');
|
||||
this.rightMonthLabel = this.container.querySelector('#right-month-label');
|
||||
|
||||
this.leftMonthName = this.container.querySelector('#left-month-name');
|
||||
this.rightMonthName = this.container.querySelector('#right-month-name');
|
||||
this.leftYearSelect = this.container.querySelector('#left-year-select');
|
||||
this.rightYearSelect = this.container.querySelector('#right-year-select');
|
||||
|
||||
this.leftPane = this.container.querySelector('#left-calendar-pane');
|
||||
this.rightPane = this.container.querySelector('#right-calendar-pane');
|
||||
|
||||
this.leftDaysContainer = this.container.querySelector('#left-days-container');
|
||||
this.rightDaysContainer = this.container.querySelector('#right-days-container');
|
||||
|
||||
this.cancelBtn = this.container.querySelector('#picker-cancel-btn');
|
||||
this.applyBtn = this.container.querySelector('#picker-apply-btn');
|
||||
|
||||
this.onChange = onChange;
|
||||
|
||||
// Active dates (saved)
|
||||
this.startDate = initialStartDate ? new Date(initialStartDate) : null;
|
||||
this.endDate = initialEndDate ? new Date(initialEndDate) : null;
|
||||
|
||||
// Temporary dates (being edited)
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
|
||||
// Calendar view month/year
|
||||
const today = new Date();
|
||||
if (this.endDate) {
|
||||
this.currentLeftMonth = this.endDate.getMonth();
|
||||
this.currentLeftYear = this.endDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init() {
|
||||
this._populateYearSelects();
|
||||
this._bindEvents();
|
||||
this._bindDisplayInputs();
|
||||
this._updateTriggerLabel();
|
||||
|
||||
// Listen to language changes
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (this.isOpen) {
|
||||
this._renderCalendars();
|
||||
}
|
||||
this._updateTriggerLabel();
|
||||
});
|
||||
}
|
||||
|
||||
_isEnglish() {
|
||||
if (typeof window.getLang === 'function') {
|
||||
const l = window.getLang();
|
||||
if (l === 'id' || l === 'en') return l === 'en';
|
||||
}
|
||||
try {
|
||||
const saved = localStorage.getItem('if_untan_lang');
|
||||
if (saved === 'id' || saved === 'en') return saved === 'en';
|
||||
} catch (e) {}
|
||||
const nav = (navigator.language || 'id').toLowerCase();
|
||||
return nav.startsWith('en');
|
||||
}
|
||||
|
||||
_clearTime(date) {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
_datesEqual(d1, d2) {
|
||||
if (!d1 || !d2) return false;
|
||||
return d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate();
|
||||
}
|
||||
|
||||
_getRightMonthYear() {
|
||||
let m = this.currentLeftMonth + 1;
|
||||
let y = this.currentLeftYear;
|
||||
if (m > 11) {
|
||||
m = 0;
|
||||
y += 1;
|
||||
}
|
||||
return { month: m, year: y };
|
||||
}
|
||||
|
||||
_navigateMonths(direction) {
|
||||
this.currentLeftMonth += direction;
|
||||
if (this.currentLeftMonth > 11) {
|
||||
this.currentLeftMonth = 0;
|
||||
this.currentLeftYear += 1;
|
||||
} else if (this.currentLeftMonth < 0) {
|
||||
this.currentLeftMonth = 11;
|
||||
this.currentLeftYear -= 1;
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_renderCalendars() {
|
||||
const isEn = this._isEnglish();
|
||||
const monthNames = isEn ?
|
||||
['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] :
|
||||
['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
|
||||
const dayHeaders = isEn ?
|
||||
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] :
|
||||
['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
|
||||
if (this.leftMonthName) {
|
||||
this.leftMonthName.textContent = monthNames[this.currentLeftMonth];
|
||||
}
|
||||
if (this.leftYearSelect) {
|
||||
this.leftYearSelect.value = this.currentLeftYear;
|
||||
}
|
||||
|
||||
const rightMY = this._getRightMonthYear();
|
||||
if (this.rightMonthName) {
|
||||
this.rightMonthName.textContent = monthNames[rightMY.month];
|
||||
}
|
||||
if (this.rightYearSelect) {
|
||||
this.rightYearSelect.value = rightMY.year;
|
||||
}
|
||||
|
||||
const drawHeaders = (pane) => {
|
||||
if (!pane) return;
|
||||
const dayHeadersEl = pane.querySelector('.day-headers');
|
||||
if (!dayHeadersEl) return;
|
||||
dayHeadersEl.innerHTML = '';
|
||||
dayHeaders.forEach(day => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = day;
|
||||
dayHeadersEl.appendChild(span);
|
||||
});
|
||||
};
|
||||
|
||||
drawHeaders(this.leftPane);
|
||||
drawHeaders(this.rightPane);
|
||||
|
||||
if (this.leftDaysContainer) {
|
||||
this._populateDaysContainer(this.leftDaysContainer, this.currentLeftMonth, this.currentLeftYear);
|
||||
}
|
||||
if (this.rightDaysContainer) {
|
||||
this._populateDaysContainer(this.rightDaysContainer, rightMY.month, rightMY.year);
|
||||
}
|
||||
|
||||
this._updateDisplayInputs();
|
||||
this._updatePresetSelectionVisuals();
|
||||
}
|
||||
|
||||
_populateDaysContainer(container, month, year) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const numDays = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
let offset = (firstDay.getDay() + 6) % 7;
|
||||
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'day-cell empty';
|
||||
container.appendChild(cell);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
|
||||
for (let day = 1; day <= numDays; day++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'day-cell';
|
||||
cell.textContent = day;
|
||||
|
||||
const cellDate = new Date(year, month, day);
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (cellDate > today) {
|
||||
cell.classList.add('disabled');
|
||||
} else {
|
||||
if (this.tempStartDate && this.tempEndDate) {
|
||||
const start = this._clearTime(this.tempStartDate);
|
||||
const end = this._clearTime(this.tempEndDate);
|
||||
|
||||
if (this._datesEqual(cellDate, start)) {
|
||||
cell.classList.add('range-start');
|
||||
}
|
||||
if (this._datesEqual(cellDate, end)) {
|
||||
cell.classList.add('range-end');
|
||||
}
|
||||
if (cellDate > start && cellDate < end) {
|
||||
cell.classList.add('range-between');
|
||||
}
|
||||
} else if (this.tempStartDate && this._datesEqual(cellDate, this._clearTime(this.tempStartDate))) {
|
||||
cell.classList.add('range-start');
|
||||
cell.classList.add('range-end');
|
||||
}
|
||||
|
||||
cell.addEventListener('click', () => {
|
||||
this._handleDayClick(cellDate);
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
_handleDayClick(date) {
|
||||
if (!this.tempStartDate || (this.tempStartDate && this.tempEndDate)) {
|
||||
this.tempStartDate = new Date(date);
|
||||
this.tempEndDate = null;
|
||||
} else {
|
||||
if (date < this.tempStartDate) {
|
||||
this.tempStartDate = new Date(date);
|
||||
this.tempEndDate = null;
|
||||
} else {
|
||||
this.tempEndDate = new Date(date);
|
||||
}
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_formatDateDisplay(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}/${mm}/${yyyy}`;
|
||||
}
|
||||
|
||||
_updateDisplayInputs() {
|
||||
if (this.startDisplay) {
|
||||
this.startDisplay.value = this._formatDateDisplay(this.tempStartDate);
|
||||
}
|
||||
if (this.endDisplay) {
|
||||
this.endDisplay.value = this._formatDateDisplay(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
_calculatePreset(presetName) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let start = null;
|
||||
let end = null;
|
||||
|
||||
switch (presetName) {
|
||||
case 'today':
|
||||
start = new Date(today);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'yesterday':
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 1);
|
||||
end = new Date(start);
|
||||
break;
|
||||
case 'last7':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 6);
|
||||
break;
|
||||
case 'last14':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 13);
|
||||
break;
|
||||
case 'last30':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 29);
|
||||
break;
|
||||
case 'thisWeek':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
const day = today.getDay();
|
||||
const diff = today.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start.setDate(diff);
|
||||
break;
|
||||
case 'lastWeek':
|
||||
const temp = new Date(today);
|
||||
const tempDay = temp.getDay();
|
||||
const tempDiff = temp.getDate() - tempDay + (tempDay === 0 ? -6 : 1);
|
||||
start = new Date(temp);
|
||||
start.setDate(tempDiff - 7);
|
||||
end = new Date(temp);
|
||||
end.setDate(tempDiff - 1);
|
||||
break;
|
||||
case 'thisMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'lastMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
end = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
start = null;
|
||||
end = null;
|
||||
break;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
_handlePresetClick(presetName) {
|
||||
const { start, end } = this._calculatePreset(presetName);
|
||||
this.tempStartDate = start;
|
||||
this.tempEndDate = end;
|
||||
|
||||
const today = new Date();
|
||||
if (this.tempEndDate) {
|
||||
this.currentLeftMonth = this.tempEndDate.getMonth();
|
||||
this.currentLeftYear = this.tempEndDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_updatePresetSelectionVisuals() {
|
||||
this.container.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
if (!this.tempStartDate && !this.tempEndDate) {
|
||||
this.container.querySelector('[data-preset="all"]')?.classList.add('active');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tempStartDate && this.tempEndDate) {
|
||||
const activePreset = ['today', 'yesterday', 'last7', 'last14', 'last30', 'thisWeek', 'lastWeek', 'thisMonth', 'lastMonth'].find(preset => {
|
||||
const { start, end } = this._calculatePreset(preset);
|
||||
return start && end &&
|
||||
this._datesEqual(this.tempStartDate, start) &&
|
||||
this._datesEqual(this.tempEndDate, end);
|
||||
});
|
||||
|
||||
if (activePreset) {
|
||||
this.container.querySelector(`[data-preset="${activePreset}"]`)?.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateTriggerLabel() {
|
||||
if (!this.startDate && !this.endDate) {
|
||||
if (this.label) {
|
||||
this.label.textContent = window.tpT?.('filter_all_time') || 'Semua Waktu';
|
||||
this.label.setAttribute('data-tp-i18n', 'filter_all_time');
|
||||
}
|
||||
} else {
|
||||
if (this.label) {
|
||||
this.label.removeAttribute('data-tp-i18n');
|
||||
const startFormatted = this._formatDateDisplay(this.startDate);
|
||||
const endFormatted = this._formatDateDisplay(this.endDate);
|
||||
if (this._datesEqual(this.startDate, this.endDate)) {
|
||||
this.label.textContent = startFormatted;
|
||||
} else {
|
||||
this.label.textContent = `${startFormatted} - ${endFormatted}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
this.popover.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
if (this.prevBtn) {
|
||||
this.prevBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._navigateMonths(-1);
|
||||
});
|
||||
}
|
||||
if (this.nextBtn) {
|
||||
this.nextBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._navigateMonths(1);
|
||||
});
|
||||
}
|
||||
|
||||
const presetsList = this.container.querySelector('.presets-list');
|
||||
if (presetsList) {
|
||||
presetsList.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.preset-btn');
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const preset = btn.getAttribute('data-preset');
|
||||
this._handlePresetClick(preset);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.applyBtn) {
|
||||
this.applyBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleApply();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.backdrop) {
|
||||
this.backdrop.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
if (this.isOpen) {
|
||||
this._handleCancel();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isOpen) {
|
||||
this._handleCancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_bindDisplayInputs() {
|
||||
const parseDate = (val) => {
|
||||
const m = val.trim().match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4})$/);
|
||||
if (!m) return null;
|
||||
const d = parseInt(m[1], 10);
|
||||
const mon = parseInt(m[2], 10) - 1;
|
||||
const y = parseInt(m[3], 10);
|
||||
|
||||
if (mon < 0 || mon > 11 || d < 1 || d > 31 || y < 1000 || y > 9999) return null;
|
||||
|
||||
const testDate = new Date(y, mon, d);
|
||||
if (testDate.getFullYear() !== y || testDate.getMonth() !== mon || testDate.getDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
return testDate;
|
||||
};
|
||||
|
||||
const setupInput = (input, isStart) => {
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const CTRL_KEYS = new Set(['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter']);
|
||||
if (!/^\d$/.test(e.key) && e.key !== '/' && e.key !== '-' && e.key !== '.' && !CTRL_KEYS.has(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
if (e.inputType && e.inputType.startsWith('delete')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let val = input.value.replace(/[^\d]/g, '');
|
||||
let formatted = val;
|
||||
if (val.length > 2) {
|
||||
formatted = val.substring(0, 2) + '/' + val.substring(2);
|
||||
}
|
||||
if (val.length > 4) {
|
||||
formatted = val.substring(0, 2) + '/' + val.substring(2, 4) + '/' + val.substring(4, 8);
|
||||
}
|
||||
input.value = formatted;
|
||||
|
||||
const parsed = parseDate(formatted);
|
||||
if (parsed) {
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
if (parsed <= today) {
|
||||
if (isStart) {
|
||||
this.tempStartDate = parsed;
|
||||
if (this.tempEndDate && this.tempEndDate < this.tempStartDate) {
|
||||
this.tempEndDate = new Date(this.tempStartDate);
|
||||
}
|
||||
} else {
|
||||
this.tempEndDate = parsed;
|
||||
if (this.tempStartDate && this.tempStartDate > this.tempEndDate) {
|
||||
this.tempStartDate = new Date(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentLeftMonth = parsed.getMonth();
|
||||
this.currentLeftYear = parsed.getFullYear();
|
||||
|
||||
const rightMY = this._getRightMonthYear();
|
||||
const isEn = this._isEnglish();
|
||||
const monthNames = isEn ?
|
||||
['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] :
|
||||
['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
if (this.leftMonthName) this.leftMonthName.textContent = monthNames[this.currentLeftMonth];
|
||||
if (this.leftYearSelect) this.leftYearSelect.value = this.currentLeftYear;
|
||||
if (this.rightMonthName) this.rightMonthName.textContent = monthNames[rightMY.month];
|
||||
if (this.rightYearSelect) this.rightYearSelect.value = rightMY.year;
|
||||
if (this.leftDaysContainer) this._populateDaysContainer(this.leftDaysContainer, this.currentLeftMonth, this.currentLeftYear);
|
||||
if (this.rightDaysContainer) this._populateDaysContainer(this.rightDaysContainer, rightMY.month, rightMY.year);
|
||||
this._updatePresetSelectionVisuals();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
const parsed = parseDate(input.value);
|
||||
if (!parsed) {
|
||||
if (isStart) {
|
||||
input.value = this._formatDateDisplay(this.tempStartDate);
|
||||
} else {
|
||||
input.value = this._formatDateDisplay(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupInput(this.startDisplay, true);
|
||||
setupInput(this.endDisplay, false);
|
||||
}
|
||||
|
||||
_populateYearSelects() {
|
||||
if (!this.leftYearSelect || !this.rightYearSelect) return;
|
||||
|
||||
this.leftYearSelect.innerHTML = '';
|
||||
this.rightYearSelect.innerHTML = '';
|
||||
|
||||
const startYear = 2020;
|
||||
const endYear = new Date().getFullYear() + 4;
|
||||
|
||||
for (let y = startYear; y <= endYear; y++) {
|
||||
const optLeft = document.createElement('option');
|
||||
optLeft.value = y;
|
||||
optLeft.textContent = y;
|
||||
this.leftYearSelect.appendChild(optLeft);
|
||||
|
||||
const optRight = document.createElement('option');
|
||||
optRight.value = y;
|
||||
optRight.textContent = y;
|
||||
this.rightYearSelect.appendChild(optRight);
|
||||
}
|
||||
|
||||
this.leftYearSelect.addEventListener('change', () => {
|
||||
this._handleYearChange(parseInt(this.leftYearSelect.value, 10), true);
|
||||
});
|
||||
|
||||
this.rightYearSelect.addEventListener('change', () => {
|
||||
this._handleYearChange(parseInt(this.rightYearSelect.value, 10), false);
|
||||
});
|
||||
}
|
||||
|
||||
_handleYearChange(year, isLeft) {
|
||||
if (isLeft) {
|
||||
this.currentLeftYear = year;
|
||||
} else {
|
||||
const rightMY = this._getRightMonthYear();
|
||||
const rightMonth = rightMY.month;
|
||||
if (rightMonth === 0) {
|
||||
this.currentLeftYear = year - 1;
|
||||
} else {
|
||||
this.currentLeftYear = year;
|
||||
}
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
document.querySelectorAll('.custom-dropdown.active').forEach(dd => {
|
||||
dd.classList.remove('active');
|
||||
dd.querySelector('.custom-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
this.container.classList.add('active');
|
||||
this.trigger.setAttribute('aria-expanded', 'true');
|
||||
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
|
||||
const today = new Date();
|
||||
if (this.endDate) {
|
||||
this.currentLeftMonth = this.endDate.getMonth();
|
||||
this.currentLeftYear = this.endDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
|
||||
this._renderCalendars();
|
||||
|
||||
if (window.innerWidth <= 768) {
|
||||
document.body.classList.add('date-picker-open');
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.container.classList.remove('active');
|
||||
this.trigger.setAttribute('aria-expanded', 'false');
|
||||
document.body.classList.remove('date-picker-open');
|
||||
}
|
||||
|
||||
_handleCancel() {
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
this.close();
|
||||
}
|
||||
|
||||
_handleApply() {
|
||||
this.startDate = this.tempStartDate;
|
||||
this.endDate = this.tempEndDate;
|
||||
|
||||
this._updateTriggerLabel();
|
||||
this.close();
|
||||
|
||||
const startStr = this._getDateString(this.startDate);
|
||||
const endStr = this._getDateString(this.endDate);
|
||||
if (this.onChange) {
|
||||
this.onChange(startStr, endStr);
|
||||
}
|
||||
}
|
||||
|
||||
_getDateString(date) {
|
||||
if (!date) return '';
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DirectusClient } from '../services/directus-client.js';
|
||||
import { FileDownloader } from '../services/file-downloader.js';
|
||||
import { DocumentFormatter } from '../utils/document-formatter.js';
|
||||
|
||||
export class DocumentCardView {
|
||||
constructor(item) {
|
||||
this.item = item;
|
||||
this.title = item.nama_berkas;
|
||||
this.tags = DocumentFormatter.parseTags(item.tag);
|
||||
this.category = DocumentFormatter.categoryFromTags(this.tags);
|
||||
this.fileType = DocumentFormatter.fileTypeFromName(this.title);
|
||||
this.assetUrl = DirectusClient.assetUrl(item.file);
|
||||
this.viewerUrl = DirectusClient.viewerUrl(item);
|
||||
this.downloadName = DocumentFormatter.downloadFilename(this.title, this.fileType);
|
||||
this.dateIso = item.tanggal_upload || '';
|
||||
this.dateKey = this.dateIso ? this.dateIso.substring(0, 10) : '';
|
||||
}
|
||||
|
||||
render() {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'document-card';
|
||||
card.dataset.date = this.dateKey;
|
||||
|
||||
const typeUpper = this.fileType.toUpperCase();
|
||||
const typeClass = this.fileType === 'pdf' ? 'doc-type-pdf' : 'doc-type-docx';
|
||||
const iconClass = this.fileType === 'pdf' ? 'fas fa-file-pdf' : 'fas fa-file-word';
|
||||
const formattedDate = DocumentFormatter.formatDate(this.dateIso);
|
||||
|
||||
const safeTitle = DocumentFormatter.escapeHtml(this.title);
|
||||
const safeViewerUrl = DocumentFormatter.escapeAttr(this.viewerUrl);
|
||||
const safeAssetUrl = DocumentFormatter.escapeAttr(this.assetUrl);
|
||||
|
||||
// Build tag badges HTML.
|
||||
const tagBadgesHtml = this.tags.length
|
||||
? `<div class="doc-tags"><i class="fas fa-tags doc-tags-icon"></i>${this.tags.map(t => `<span class="doc-tag">${DocumentFormatter.escapeHtml(t)}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="doc-type-icon ${typeClass}"><i class="${iconClass}"></i></div>
|
||||
<h3 class="doc-title">${safeTitle}</h3>
|
||||
<div class="doc-meta">
|
||||
<span class="doc-badge">${typeUpper}</span>
|
||||
<span class="doc-badge date"><i class="far fa-calendar-alt"></i> ${formattedDate}</span>
|
||||
</div>
|
||||
${tagBadgesHtml}
|
||||
<div class="doc-actions">
|
||||
<a href="${safeViewerUrl}" class="btn-action-primary" target="_blank" rel="noopener"><span data-tp-i18n="card_open">Buka Berkas</span> <i class="fas fa-external-link-alt"></i></a>
|
||||
<a href="${safeAssetUrl}" class="btn-action-outline"><span data-tp-i18n="card_download">Unduh Berkas</span> <i class="fas fa-download"></i></a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector('.btn-action-outline').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
FileDownloader.download(this.assetUrl, this.downloadName);
|
||||
});
|
||||
|
||||
// Prefetch the asset when the user hovers over the card so the
|
||||
// browser cache already has the file by the time the viewer loads.
|
||||
let prefetched = false;
|
||||
card.addEventListener('mouseenter', () => {
|
||||
if (prefetched) return;
|
||||
prefetched = true;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.as = 'fetch';
|
||||
link.crossOrigin = 'anonymous';
|
||||
link.href = this.assetUrl;
|
||||
document.head.appendChild(link);
|
||||
}, { once: true });
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user