Przejdź do treści
<code>pen.pl

Partie palarni — magazynowa tabela kawy

Wizualizacja danych

Podgląd

Ładowanie podglądu…

Magazyn palarni z dwoma filtrami, sortowaniem jakości i zapasu oraz paginacją partii.

0,0 / 5 Brak ocen

Kod źródłowy

Licencja MIT — także do użytku komercyjnego. Zachowaj informacje o autorze i licencji.

HTML
<main class="roast-ledger" id="coffee-table"><aside class="roast-side"><p>PALARNIA / MAGAZYN 03</p><h1>Partie<br>zielonego<br>ziarna</h1><div class="bean" aria-hidden="true">C</div><p class="side-note">Stan partii przed planem palenia na tydzień 42.</p></aside><section class="roast-content">
  <div class="roast-filters"><label>Przeszukaj partie<input type="search" data-search placeholder="Numer lub nazwa plantacji"></label><label>Pochodzenie<select id="coffee-origin" data-filter><option value="">Każde</option><option>Etiopia</option><option>Brazylia</option><option>Kolumbia</option><option>Kenia</option><option>Gwatemala</option><option>Rwanda</option><option>Indonezja</option></select></label><label>Obróbka<select id="coffee-process" data-filter><option value="">Każda</option><option>Myta</option><option>Naturalna</option><option>Miodowa</option></select></label><button type="button" data-reset>Wyczyść filtry</button></div>
  <p class="roast-status" role="status" aria-live="polite"></p><div class="table-scroll" tabindex="0" aria-label="Tabela partii kawy — przewijaj poziomo na małym ekranie"><table><caption>Magazyn partii / sezon 2026</caption><thead><tr>
    <th scope="col"><button type="button" data-sort="lot">Partia <span data-sort-mark aria-hidden="true">↕</span></button></th><th scope="col"><button type="button" data-sort="name">Plantacja <span data-sort-mark aria-hidden="true">↕</span></button></th><th scope="col"><button type="button" data-sort="origin">Kraj <span data-sort-mark aria-hidden="true">↕</span></button></th><th scope="col"><button type="button" data-sort="process">Obróbka <span data-sort-mark aria-hidden="true">↕</span></button></th><th scope="col"><button type="button" data-sort="score">Ocena <span data-sort-mark aria-hidden="true">↕</span></button></th><th scope="col"><button type="button" data-sort="stock">Stan <span data-sort-mark aria-hidden="true">↕</span></button></th>
  </tr></thead><tbody><tr><td colspan="6">Sprawdzanie magazynu…</td></tr></tbody></table></div><nav class="roast-pages" data-pages aria-label="Stronicowanie tabeli"></nav>
</section></main>
CSS
*{box-sizing:border-box}body{margin:0;background:#2b1b12;color:#2b1b12;font-family:"Trebuchet MS",Arial,sans-serif}.roast-ledger{min-height:100vh;display:grid;grid-template-columns:minmax(190px,27%) 1fr;background:#f4e1b9}.roast-side{padding:clamp(22px,4vw,52px);background:#a43d22;color:#fff1d0;border-right:8px solid #2b1b12}.roast-side>p:first-child{font:700 11px/1.4 "Courier New",monospace;letter-spacing:.12em}.roast-side h1{margin:28px 0;font:700 clamp(36px,5.4vw,72px)/.82 Georgia,serif;letter-spacing:-.055em}.bean{width:78px;height:108px;border:4px solid #fff1d0;border-radius:50%;display:grid;place-items:center;font:700 42px Georgia,serif;transform:rotate(12deg)}.side-note{max-width:210px;margin-top:32px;line-height:1.5}.roast-content{min-width:0;padding:clamp(18px,3vw,40px)}.roast-filters{display:grid;grid-template-columns:1.2fr .7fr .7fr auto;gap:10px;align-items:end;border-bottom:3px solid #2b1b12;padding-bottom:16px}.roast-filters label{display:grid;gap:6px;font:700 11px "Courier New",monospace;text-transform:uppercase;letter-spacing:.07em}.roast-filters input,.roast-filters select,.roast-filters button{height:46px;border:2px solid #2b1b12;border-radius:2px;background:#fff8e9;color:#2b1b12;font:inherit;padding:0 10px}.roast-filters button{background:#2b1b12;color:#fff8e9;cursor:pointer}.roast-status{min-height:20px;margin:11px 0 7px;font:12px "Courier New",monospace}.table-scroll{max-width:100%;overflow-x:auto;background:#fff8e9;border:2px solid #2b1b12;box-shadow:7px 7px 0 #cb8c4e}.table-scroll:focus-visible{outline:4px solid #00716b;outline-offset:3px}table{width:100%;min-width:760px;border-collapse:collapse}caption{text-align:left;padding:15px 13px;font:700 19px Georgia,serif;border-bottom:3px solid #2b1b12}th,td{text-align:left;padding:11px 12px;border-bottom:1px solid #b78b60}thead{background:#e9c988}thead th{padding:0 10px}th button{width:100%;min-height:48px;padding:0;border:0;background:transparent;color:#2b1b12;font:700 11px "Courier New",monospace;text-transform:uppercase;text-align:left;cursor:pointer}th[aria-sort]{background:#00716b}th[aria-sort] button{color:#fff}td small{display:block;margin-top:3px;color:#765b47;font:10px "Courier New",monospace;text-transform:uppercase}.lot{font:700 13px "Courier New",monospace}.score{font-size:20px;font-weight:800;color:#a43d22}.stock{white-space:nowrap}.stock i{display:inline-block;width:26px;height:8px;margin-right:7px;background:#00716b}.empty-row{text-align:center;padding:40px}.roast-pages{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}.roast-pages button{min-width:44px;min-height:44px;border:2px solid #2b1b12;border-radius:2px;background:#fff8e9;color:#2b1b12;font-weight:700;cursor:pointer}.roast-pages button[aria-current="page"]{background:#a43d22;color:#fff;border-color:#a43d22}.roast-pages button:disabled{opacity:.42;cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible{outline:4px solid #00716b;outline-offset:2px}@media(max-width:760px){.roast-ledger{display:block}.roast-side{border-right:0;border-bottom:8px solid #2b1b12;padding:20px}.roast-side h1{margin:10px 0}.bean,.side-note{display:none}.roast-content{padding:16px 12px 24px}.roast-filters{grid-template-columns:1fr 1fr}.roast-filters label:first-child{grid-column:1/-1}.roast-filters button{align-self:end}}@media(max-width:420px){.roast-filters{grid-template-columns:1fr}.roast-filters label:first-child{grid-column:auto}.roast-pages button:first-child,.roast-pages button:last-child{flex:1 0 100%}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
JavaScript
$(() => {
  const records = [{"lot":"ET-2607","name":"Bensa Bombe","origin":"Etiopia","process":"Naturalna","roast":"Jasne","score":87.5,"stock":18},{"lot":"ET-2611","name":"Halo Beriti","origin":"Etiopia","process":"Myta","roast":"Jasne","score":86.8,"stock":32},{"lot":"ET-2614","name":"Chelchele","origin":"Etiopia","process":"Naturalna","roast":"Jasne","score":88.1,"stock":11},{"lot":"BR-2602","name":"Fazenda Lua","origin":"Brazylia","process":"Naturalna","roast":"Średnie","score":84.6,"stock":64},{"lot":"BR-2609","name":"Serra Azul","origin":"Brazylia","process":"Miodowa","roast":"Średnie","score":85.2,"stock":48},{"lot":"CO-2604","name":"Huila Norte","origin":"Kolumbia","process":"Myta","roast":"Jasne","score":86.2,"stock":27},{"lot":"CO-2610","name":"Sierra Nevada","origin":"Kolumbia","process":"Miodowa","roast":"Średnie","score":85.7,"stock":39},{"lot":"CO-2613","name":"Nariño Alto","origin":"Kolumbia","process":"Myta","roast":"Jasne","score":87.0,"stock":21},{"lot":"KE-2601","name":"Gakuyu-ini","origin":"Kenia","process":"Myta","roast":"Jasne","score":88.4,"stock":14},{"lot":"KE-2608","name":"Kii AA","origin":"Kenia","process":"Myta","roast":"Jasne","score":87.9,"stock":16},{"lot":"GT-2603","name":"El Injerto","origin":"Gwatemala","process":"Myta","roast":"Średnie","score":86.0,"stock":35},{"lot":"GT-2612","name":"Acatenango","origin":"Gwatemala","process":"Miodowa","roast":"Jasne","score":86.6,"stock":24},{"lot":"RW-2605","name":"Gitesi Hills","origin":"Rwanda","process":"Myta","roast":"Jasne","score":86.9,"stock":19},{"lot":"RW-2615","name":"Nyamasheke","origin":"Rwanda","process":"Naturalna","roast":"Jasne","score":87.2,"stock":13},{"lot":"ID-2606","name":"Kerinci","origin":"Indonezja","process":"Miodowa","roast":"Średnie","score":85.4,"stock":42}];
  const numericColumns = new Set(["score", "stock"]);
  const collator = new Intl.Collator('pl', { numeric: true, sensitivity: 'base' });
  const state = { query: '', sort: 'score', direction: 'desc', page: 1 };
  const pageSize = 5;
  const $root = $('#coffee-table');
  const $body = $root.find('tbody');
  const $pages = $root.find('[data-pages]');
  const $status = $root.find('[role="status"]');

  function filteredRecords() {
    const query = state.query.trim().toLocaleLowerCase('pl');
    return records.filter((record) => {
      const matchesQuery = !query || Object.values(record).join(' ').toLocaleLowerCase('pl').includes(query);
      const origin = $('#coffee-origin').val();
      const process = $('#coffee-process').val();
      const matchesFilters = (!origin || record.origin === origin) && (!process || record.process === process);
      return matchesQuery && matchesFilters;
    });
  }

  function compare(left, right) {
    const key = state.sort;
    const result = numericColumns.has(key)
      ? Number(left[key]) - Number(right[key])
      : collator.compare(String(left[key]), String(right[key]));
    return state.direction === 'asc' ? result : -result;
  }

  function rowTemplate(record) {
    return `<tr><th class="lot" scope="row">${record.lot}</th><td>${record.name}<small>${record.roast} palenie</small></td><td>${record.origin}</td><td>${record.process}</td><td class="score">${record.score.toFixed(1)}</td><td class="stock"><i style="width:${Math.max(8,Math.min(42,record.stock/1.6))}px" aria-hidden="true"></i>${record.stock} kg</td></tr>`;
  }

  function render() {
    const result = filteredRecords().sort(compare);
    const totalPages = Math.max(1, Math.ceil(result.length / pageSize));
    state.page = Math.min(state.page, totalPages);
    const visible = result.slice((state.page - 1) * pageSize, state.page * pageSize);

    $body.html(visible.length
      ? visible.map(rowTemplate).join('')
      : '<tr><td class="empty-row" colspan="6">Brak wyników. Zmień filtry lub wyczyść wyszukiwanie.</td></tr>');

    $root.find('thead th').removeAttr('aria-sort');
    $root.find('[data-sort]').each(function () {
      const active = this.dataset.sort === state.sort;
      $(this).find('[data-sort-mark]').text(active ? (state.direction === 'asc' ? '↑' : '↓') : '↕');
      if (active) $(this).closest('th').attr('aria-sort', state.direction === 'asc' ? 'ascending' : 'descending');
    });

    const buttons = [];
    buttons.push(`<button type="button" data-page="${state.page - 1}" ${state.page === 1 ? 'disabled' : ''}>Poprzednia</button>`);
    for (let page = 1; page <= totalPages; page += 1) {
      buttons.push(`<button type="button" data-page="${page}" ${page === state.page ? 'aria-current="page"' : ''} aria-label="Strona ${page}">${page}</button>`);
    }
    buttons.push(`<button type="button" data-page="${state.page + 1}" ${state.page === totalPages ? 'disabled' : ''}>Następna</button>`);
    $pages.html(buttons.join(''));

    const direction = state.direction === 'asc' ? 'rosnąco' : 'malejąco';
    $status.text(`Wyniki: ${result.length} · Strona ${state.page} z ${totalPages} · Sortowanie: ${direction}.`);
  }

  $root.on('input', '[data-search]', function () {
    state.query = this.value;
    state.page = 1;
    render();
  });
  $root.on('change', '[data-filter]', function () {
    state.page = 1;
    render();
  });
  $root.on('click', '[data-sort]', function () {
    const key = this.dataset.sort;
    state.direction = state.sort === key && state.direction === 'asc' ? 'desc' : 'asc';
    state.sort = key;
    state.page = 1;
    render();
    this.focus();
  });
  $root.on('click', '[data-page]', function () {
    if (this.disabled) return;
    state.page = Number(this.dataset.page);
    render();
    $pages.find(`[data-page="${state.page}"]`).focus();
  });
  $root.on('click', '[data-reset]', () => {
    $root.find('[data-search]').val('');
    $root.find('select[data-filter]').prop('selectedIndex', 0);
    $root.find('input[type="radio"][data-filter]').first().prop('checked', true);
    state.query = '';
    state.sort = 'score';
    state.direction = 'desc';
    state.page = 1;
    render();
    $root.find('[data-search]').focus();
  });

  render();
});
Biblioteki

JavaScript

Edytuj swój pen

Podaj adres e-mail użyty przy zapisie. Wyślemy link do edycji.

Link jest ważny przez 30 minut.