feat: implementasi custom date range picker dengan preset dan navigasi kalender

* Mengganti field input tanggal yang sudah ada dengan UI date range picker baru.
* Menambahkan popover kalender untuk memilih rentang tanggal dengan navigasi bulan.
* Menambahkan tombol preset untuk pilihan rentang tanggal umum secara cepat, seperti Hari Ini dan 7 Hari Terakhir.
* Memperbarui string lokalisasi untuk fitur date range picker baru.
* Melakukan refactor JavaScript untuk menangani pemilihan tanggal dan state management pada picker baru.
This commit is contained in:
Dodo
2026-06-04 17:28:18 +07:00
parent b0918c1bb9
commit 529693cd5f
4 changed files with 1226 additions and 235 deletions
+441 -125
View File
@@ -269,6 +269,7 @@
.search-container { .search-container {
flex: 1; flex: 1;
min-width: 260px;
max-width: 500px; max-width: 500px;
} }
@@ -316,7 +317,8 @@
} }
.date-range-group { .date-range-group {
min-width: 320px; min-width: 300px;
position: relative;
} }
.control-label { .control-label {
@@ -347,94 +349,393 @@
pointer-events: none; pointer-events: none;
} }
/* --- Date Range Filter --- */ /* --- Date Range Filter & Popover --- */
.date-range-inputs { .date-picker-trigger {
width: 100%;
padding: 14px 18px;
background: white;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
font-size: 0.9rem;
font-weight: 700;
color: var(--primary-blue);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.date-picker-trigger:hover {
border-color: var(--secondary-gold);
background: #fffdf5;
}
.date-picker-trigger:focus-visible {
outline: none;
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
}
.date-picker-trigger .trigger-left-content {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 4px 12px;
border-radius: 4px;
transition: all 0.2s ease;
} }
.date-range-inputs:focus-within { .date-picker-trigger .trigger-arrow {
font-size: 0.8rem;
transition: transform 0.25s ease;
color: var(--primary-blue);
}
.date-range-group.active .date-picker-trigger .trigger-arrow {
transform: rotate(180deg);
}
.date-range-group.active .date-picker-trigger {
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
}
/* Backdrop */
.date-picker-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 40;
}
/* Popover */
.date-picker-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 630px;
background: white;
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
display: none;
flex-direction: column;
z-index: 45;
overflow: hidden;
}
.date-range-group.active .date-picker-popover {
display: flex;
}
/* Top Bar */
.date-picker-top-bar {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
background: #f8fafc;
display: flex;
justify-content: flex-end;
}
.date-display-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.date-display-field input {
width: 110px;
padding: 6px 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
color: var(--primary-blue);
text-align: center;
background: white; background: white;
} }
/* Custom dd/mm/yyyy date input */ .date-picker-separator {
.custom-date-input { color: #94a3b8;
position: relative;
display: flex;
align-items: center;
gap: 1px;
padding: 6px 0;
}
.date-sep {
font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: #94a3b8;
user-select: none;
padding: 0 2px;
flex-shrink: 0;
} }
.date-part { /* Body */
border: none; .date-picker-body {
background: transparent;
font-family: inherit;
font-size: 0.85rem;
font-weight: 700;
color: var(--primary-blue);
text-align: center;
padding: 2px 4px;
border-radius: 3px;
transition: background 0.15s ease;
min-width: 0;
}
.date-dd, .date-mm { width: 34px; }
.date-yyyy { width: 56px; }
.date-part::placeholder {
color: #94a3b8;
font-weight: 400;
}
.date-part:focus {
outline: none;
background: rgba(0, 49, 80, 0.07);
}
.date-calendar-btn {
background: none;
border: none;
cursor: pointer;
color: #94a3b8;
padding: 4px 2px 4px 6px;
display: flex; display: flex;
background: white;
}
/* Calendar Pane */
.calendar-container {
flex: 1;
padding: 16px;
}
.calendar-nav {
display: flex;
justify-content: space-between;
align-items: center; align-items: center;
font-size: 0.9rem; margin-bottom: 16px;
flex-shrink: 0;
transition: color 0.2s ease;
}
.date-calendar-btn:hover {
color: var(--primary-blue);
}
.date-hidden-picker {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px;
height: 1px;
border: none;
padding: 0; padding: 0;
margin: 0; }
overflow: hidden;
.month-labels {
display: flex;
gap: 90px;
color: var(--primary-blue);
font-size: 0.95rem;
}
.month-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
}
.year-select-wrapper {
position: relative;
display: inline-flex;
align-items: center;
border-radius: 4px;
transition: background 0.2s ease;
}
.year-select-wrapper:hover {
background: #f1f5f9;
}
.calendar-year-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: transparent;
border: none;
padding: 2px 16px 2px 6px;
font-family: inherit;
font-size: 0.95rem;
font-weight: 700;
color: var(--primary-blue);
cursor: pointer;
outline: none;
}
.year-select-wrapper .select-arrow {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
font-size: 0.6rem;
color: var(--primary-blue);
pointer-events: none;
}
.nav-btn {
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.nav-btn:hover {
background: #f1f5f9;
color: var(--primary-blue);
}
.calendar-grids {
display: flex;
gap: 20px;
}
.calendar-grid-pane {
flex: 1;
}
.day-headers {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
font-size: 0.72rem;
font-weight: 700;
color: #94a3b8;
margin-bottom: 8px;
}
.days-container {
display: grid;
grid-template-columns: repeat(7, 1fr);
row-gap: 4px;
column-gap: 2px;
}
.day-cell {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.82rem;
font-weight: 600;
color: #475569;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.day-cell:hover {
background: #f1f5f9;
color: var(--primary-blue);
}
.day-cell.empty {
pointer-events: none;
visibility: hidden;
}
.day-cell.disabled {
color: #cbd5e1;
pointer-events: none;
}
.day-cell.range-start {
background: var(--primary-blue) !important;
color: white !important;
border-radius: 4px 0 0 4px;
}
.day-cell.range-end {
background: var(--primary-blue) !important;
color: white !important;
border-radius: 0 4px 4px 0;
}
.day-cell.range-start.range-end {
border-radius: 4px !important;
}
.day-cell.range-between {
background: #eff6ff !important;
color: var(--primary-blue) !important;
border-radius: 0;
}
/* Presets Pane */
.presets-container {
width: 190px;
border-left: 1px solid var(--border-color);
padding: 16px;
background: white;
display: flex;
flex-direction: column;
gap: 12px;
}
.presets-title {
font-size: 0.7rem;
font-weight: 800;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
display: block;
}
.presets-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow-y: auto;
}
.preset-btn {
background: transparent;
border: none;
text-align: left;
padding: 8px 12px;
font-size: 0.8rem;
font-weight: 600;
color: #475569;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
font-family: inherit;
}
.preset-btn:hover {
background: #f8fafc;
color: var(--primary-blue);
}
.preset-btn.active {
background: #eff6ff;
color: var(--primary-blue);
}
.preset-btn.active::after {
content: '✓';
font-weight: 700;
color: var(--primary-blue);
font-size: 0.85rem;
}
/* Footer Action Buttons */
.date-picker-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid var(--border-color);
background: #f8fafc;
}
.action-btn {
padding: 8px 20px;
font-size: 0.85rem;
font-weight: 700;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.cancel-btn {
background: white;
border: 1px solid #cbd5e1;
color: #475569;
}
.cancel-btn:hover {
background: #f1f5f9;
border-color: #94a3b8;
color: #1e293b;
}
.apply-btn {
background: var(--primary-blue);
border: 1px solid var(--primary-blue);
color: white;
}
.apply-btn:hover {
background: #002238;
border-color: #002238;
} }
.range-divider { .range-divider {
@@ -713,68 +1014,83 @@ a.btn-action-outline:hover {
.date-range-group { .date-range-group {
min-width: 0; min-width: 0;
position: relative;
} }
/* Stack start/end date pickers vertically to avoid overflow */ body.date-picker-open {
.date-range-inputs {
display: flex;
flex-direction: column;
gap: 6px;
border: none;
background: transparent;
padding: 0;
}
.date-range-inputs .input-wrapper {
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 10px 12px;
border-radius: 4px;
display: flex;
align-items: center;
width: 100%;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
overflow: hidden; overflow: hidden;
} }
.date-range-inputs .input-wrapper:focus-within { body.date-picker-open .date-picker-backdrop {
background: white; display: block;
border-color: var(--primary-blue); z-index: 998;
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
} }
/* Custom date input fills the wrapper width */ .date-picker-popover {
.custom-date-input { /* Default mobile popover positioning inside media query is overridden below,
width: 100%; but let's define the base transitions or layouts. */
padding: 0;
} }
/* DD / MM stay compact; YYYY stretches to fill remaining space */ @media (max-width: 768px) {
.date-dd, .date-mm { .date-picker-popover {
flex: 0 0 auto; position: fixed;
width: 30px; top: 50%;
} left: 50%;
right: auto;
transform: translate(-50%, -50%);
width: calc(100% - 32px);
max-width: 380px;
max-height: 90vh;
overflow-y: auto;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
z-index: 999;
}
.date-yyyy { .date-picker-top-bar {
flex: 1; justify-content: center;
width: auto; }
min-width: 44px;
text-align: left;
}
.date-part { .calendar-grids {
font-size: 0.82rem; gap: 0;
} }
/* S/D label between pickers becomes a small inline tag */ #right-calendar-pane {
.range-divider { display: none;
font-size: 0.68rem; }
font-weight: 800;
color: #94a3b8; .month-labels {
padding: 3px 10px; gap: 0;
text-transform: uppercase; justify-content: center;
text-align: center; flex: 1;
align-self: center; }
#right-month-label {
display: none;
}
.date-picker-body {
flex-direction: column;
}
.presets-container {
width: 100%;
border-left: none;
border-top: 1px solid var(--border-color);
padding: 12px 16px;
}
.presets-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
max-height: none;
}
.preset-btn {
padding: 6px 10px;
font-size: 0.75rem;
}
} }
.select-wrapper, .select-wrapper,
+78 -23
View File
@@ -943,31 +943,86 @@
</div> </div>
<div class="filter-controls"> <div class="filter-controls">
<div class="control-group date-range-group"> <div class="control-group date-range-group" id="date-range-control-group">
<span class="control-label" data-tp-i18n="label_period">Periode Unggahan</span> <span class="control-label" data-tp-i18n="label_period">Periode Unggahan</span>
<div class="date-range-inputs"> <button type="button" id="date-picker-trigger" class="date-picker-trigger">
<div class="input-wrapper custom-date-input"> <div class="trigger-left-content">
<input type="text" inputmode="numeric" maxlength="2" placeholder="DD" class="date-part date-dd" id="date-start-dd" autocomplete="off"> <i class="far fa-calendar-alt"></i>
<span class="date-sep">/</span> <span id="date-picker-label" data-tp-i18n="filter_all_time">Semua Waktu</span>
<input type="text" inputmode="numeric" maxlength="2" placeholder="MM" class="date-part date-mm" id="date-start-mm" autocomplete="off">
<span class="date-sep">/</span>
<input type="text" inputmode="numeric" maxlength="4" placeholder="YYYY" class="date-part date-yyyy" id="date-start-yyyy" autocomplete="off">
<button type="button" class="date-calendar-btn" id="date-start-cal-btn" aria-label="Pilih tanggal mulai">
<i class="far fa-calendar-alt"></i>
</button>
<input type="date" class="date-hidden-picker" id="date-start-picker" tabindex="-1" aria-hidden="true">
</div> </div>
<span class="range-divider" data-tp-i18n="range_divider">s/d</span> <i class="fas fa-chevron-down trigger-arrow"></i>
<div class="input-wrapper custom-date-input"> </button>
<input type="text" inputmode="numeric" maxlength="2" placeholder="DD" class="date-part date-dd" id="date-end-dd" autocomplete="off">
<span class="date-sep">/</span> <div id="date-picker-backdrop" class="date-picker-backdrop"></div>
<input type="text" inputmode="numeric" maxlength="2" placeholder="MM" class="date-part date-mm" id="date-end-mm" autocomplete="off">
<span class="date-sep">/</span> <div id="date-picker-popover" class="date-picker-popover">
<input type="text" inputmode="numeric" maxlength="4" placeholder="YYYY" class="date-part date-yyyy" id="date-end-yyyy" autocomplete="off"> <div class="date-picker-top-bar">
<button type="button" class="date-calendar-btn" id="date-end-cal-btn" aria-label="Pilih tanggal selesai"> <div class="date-display-inputs">
<i class="far fa-calendar-alt"></i> <div class="date-display-field">
</button> <input type="text" id="picker-start-display" placeholder="DD/MM/YYYY">
<input type="date" class="date-hidden-picker" id="date-end-picker" tabindex="-1" aria-hidden="true"> </div>
<span class="date-picker-separator">-</span>
<div class="date-display-field">
<input type="text" id="picker-end-display" placeholder="DD/MM/YYYY">
</div>
</div>
</div>
<div class="date-picker-body">
<div class="calendar-container">
<div class="calendar-nav">
<button type="button" id="prev-month-btn" class="nav-btn"><i class="fas fa-chevron-left"></i></button>
<div class="month-labels">
<div id="left-month-label" class="month-label">
<span id="left-month-name">Mei</span>
<div class="year-select-wrapper">
<select id="left-year-select" class="calendar-year-select"></select>
<i class="fas fa-chevron-down select-arrow"></i>
</div>
</div>
<div id="right-month-label" class="month-label">
<span id="right-month-name">Juni</span>
<div class="year-select-wrapper">
<select id="right-year-select" class="calendar-year-select"></select>
<i class="fas fa-chevron-down select-arrow"></i>
</div>
</div>
</div>
<button type="button" id="next-month-btn" class="nav-btn"><i class="fas fa-chevron-right"></i></button>
</div>
<div class="calendar-grids">
<div class="calendar-grid-pane" id="left-calendar-pane">
<div class="day-headers"></div>
<div class="days-container" id="left-days-container"></div>
</div>
<div class="calendar-grid-pane" id="right-calendar-pane">
<div class="day-headers"></div>
<div class="days-container" id="right-days-container"></div>
</div>
</div>
</div>
<div class="presets-container">
<span class="presets-title" data-tp-i18n="presets_title">RENTANG WAKTU</span>
<div class="presets-list">
<button type="button" class="preset-btn" data-preset="all" data-tp-i18n="filter_all_time">Semua Waktu</button>
<button type="button" class="preset-btn" data-preset="today" data-tp-i18n="preset_today">Hari Ini</button>
<button type="button" class="preset-btn" data-preset="yesterday" data-tp-i18n="preset_yesterday">Kemarin</button>
<button type="button" class="preset-btn" data-preset="last7" data-tp-i18n="preset_last_7_days">7 Hari Terakhir</button>
<button type="button" class="preset-btn" data-preset="last14" data-tp-i18n="preset_last_14_days">14 Hari Terakhir</button>
<button type="button" class="preset-btn" data-preset="last30" data-tp-i18n="preset_last_30_days">30 Hari Terakhir</button>
<button type="button" class="preset-btn" data-preset="thisWeek" data-tp-i18n="preset_this_week">Minggu Ini</button>
<button type="button" class="preset-btn" data-preset="lastWeek" data-tp-i18n="preset_last_week">Minggu Lalu</button>
<button type="button" class="preset-btn" data-preset="thisMonth" data-tp-i18n="preset_this_month">Bulan Ini</button>
<button type="button" class="preset-btn" data-preset="lastMonth" data-tp-i18n="preset_last_month">Bulan Lalu</button>
</div>
</div>
</div>
<div class="date-picker-footer">
<button type="button" id="picker-cancel-btn" class="action-btn cancel-btn" data-tp-i18n="btn_cancel">Batal</button>
<button type="button" id="picker-apply-btn" class="action-btn apply-btn" data-tp-i18n="btn_update">Perbarui</button>
</div> </div>
</div> </div>
</div> </div>
+26
View File
@@ -43,6 +43,19 @@
sort_oldest: "Terlama", sort_oldest: "Terlama",
sort_az: "Nama A-Z", sort_az: "Nama A-Z",
loading: "Menghubungkan ke repositori berkas...", loading: "Menghubungkan ke repositori berkas...",
filter_all_time: "Semua Waktu",
presets_title: "RENTANG WAKTU",
preset_today: "Hari Ini",
preset_yesterday: "Kemarin",
preset_last_7_days: "7 Hari Terakhir",
preset_last_14_days: "14 Hari Terakhir",
preset_last_30_days: "30 Hari Terakhir",
preset_this_week: "Minggu Ini",
preset_last_week: "Minggu Lalu",
preset_this_month: "Bulan Ini",
preset_last_month: "Bulan Lalu",
btn_cancel: "Batal",
btn_update: "Perbarui",
// Repository — dynamic (cards + states, used by main.js) // Repository — dynamic (cards + states, used by main.js)
card_open: "Buka Berkas", card_open: "Buka Berkas",
@@ -104,6 +117,19 @@
sort_oldest: "Oldest", sort_oldest: "Oldest",
sort_az: "Name A-Z", sort_az: "Name A-Z",
loading: "Connecting to the file repository...", loading: "Connecting to the file repository...",
filter_all_time: "All Time",
presets_title: "DATE RANGE",
preset_today: "Today",
preset_yesterday: "Yesterday",
preset_last_7_days: "Last 7 Days",
preset_last_14_days: "Last 14 Days",
preset_last_30_days: "Last 30 Days",
preset_this_week: "This Week",
preset_last_week: "Last Week",
preset_this_month: "This Month",
preset_last_month: "Last Month",
btn_cancel: "Cancel",
btn_update: "Update",
// Repository — dynamic (cards + states, used by main.js) // Repository — dynamic (cards + states, used by main.js)
card_open: "Open File", card_open: "Open File",
+681 -87
View File
@@ -412,6 +412,673 @@ class CustomDropdown {
} }
} }
/* ===========================================
DateRangePicker (Custom Calendar/Preset Popover)
=========================================== */
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}`;
}
}
/* =========================================== /* ===========================================
DocumentRepository DocumentRepository
=========================================== */ =========================================== */
@@ -429,21 +1096,6 @@ class DocumentRepository {
this.statActiveEl = document.getElementById('stat-active-value'); this.statActiveEl = document.getElementById('stat-active-value');
this.statUpdatesEl = document.getElementById('stat-updates-value'); this.statUpdatesEl = document.getElementById('stat-updates-value');
this.dateStartParts = {
dd: document.getElementById('date-start-dd'),
mm: document.getElementById('date-start-mm'),
yyyy: document.getElementById('date-start-yyyy'),
picker: document.getElementById('date-start-picker'),
calBtn: document.getElementById('date-start-cal-btn'),
};
this.dateEndParts = {
dd: document.getElementById('date-end-dd'),
mm: document.getElementById('date-end-mm'),
yyyy: document.getElementById('date-end-yyyy'),
picker: document.getElementById('date-end-picker'),
calBtn: document.getElementById('date-end-cal-btn'),
};
this.cards = []; this.cards = [];
this.paginationContainer = null; this.paginationContainer = null;
this.state = { this.state = {
@@ -456,67 +1108,6 @@ class DocumentRepository {
}; };
} }
// Returns YYYY-MM-DD string only when dd, mm, and yyyy are all filled and valid.
_getDateString(parts) {
const dd = parts.dd?.value.trim() || '';
const mm = parts.mm?.value.trim() || '';
const yyyy = parts.yyyy?.value.trim() || '';
if (!dd || !mm || yyyy.length < 4) return '';
const d = parseInt(dd, 10);
const m = parseInt(mm, 10);
const y = parseInt(yyyy, 10);
if (isNaN(d) || isNaN(m) || isNaN(y)) return '';
if (d < 1 || d > 31 || m < 1 || m > 12 || y < 1 || y > 9999) return '';
return `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
// Wire up a set of dd/mm/yyyy text inputs:
// • numeric-only keydown guard
// • auto-advance dd→mm after 2 chars, mm→yyyy after 2 chars
// • backspace from empty field navigates back
// • calendar button opens native date picker; selection syncs to text inputs
// • calls onUpdate() on every change
_bindDateParts(parts, onUpdate) {
const { dd, mm, yyyy, picker, calBtn } = parts;
const CTRL_KEYS = new Set(['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter']);
const setup = (input, prev, next) => {
if (!input) return;
input.addEventListener('keydown', (e) => {
if (!/^\d$/.test(e.key) && !CTRL_KEYS.has(e.key)) e.preventDefault();
if (e.key === 'Backspace' && !input.value && prev) prev.focus();
});
input.addEventListener('input', () => {
input.value = input.value.replace(/\D/g, '');
onUpdate();
const max = parseInt(input.getAttribute('maxlength'), 10);
if (next && input.value.length >= max) next.focus();
});
};
setup(dd, null, mm);
setup(mm, dd, yyyy);
setup(yyyy, mm, null);
// Calendar button → open native picker, then sync selection back to text inputs.
if (calBtn && picker) {
calBtn.addEventListener('click', () => {
try { picker.showPicker(); } catch { picker.click(); }
});
picker.addEventListener('change', () => {
if (!picker.value) return;
const [y, m, d] = picker.value.split('-');
if (dd) dd.value = d;
if (mm) mm.value = m;
if (yyyy) yyyy.value = y;
onUpdate();
});
}
}
async init() { async init() {
if (!this.gridContainer) return; if (!this.gridContainer) return;
@@ -662,17 +1253,20 @@ class DocumentRepository {
this._applyFilters(); this._applyFilters();
}); });
this._bindDateParts(this.dateStartParts, () => { // Initialize Custom Date Range Picker
this.state.startDate = this._getDateString(this.dateStartParts); const datePickerGroup = document.getElementById('date-range-control-group');
this.state.page = 1; if (datePickerGroup) {
this._applyFilters(); this.dateRangePicker = new DateRangePicker({
}); containerId: 'date-range-control-group',
onChange: (startDate, endDate) => {
this._bindDateParts(this.dateEndParts, () => { this.state.startDate = startDate;
this.state.endDate = this._getDateString(this.dateEndParts); this.state.endDate = endDate;
this.state.page = 1; this.state.page = 1;
this._applyFilters(); this._applyFilters();
}); }
});
window.applyTp?.(datePickerGroup);
}
if (this.sortFilterWrapper) { if (this.sortFilterWrapper) {
const sortOptions = [ const sortOptions = [