Last active
September 19, 2026 13:31
-
-
Save sunmeat/7d6009ef2d2c33574cd6e6e78c589f85 to your computer and use it in GitHub Desktop.
React + Web API filtration example (tanstack table)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Archivo+Black&display=swap'); | |
| *, | |
| *::before, | |
| *::after { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| :root { | |
| --bg: #050914; | |
| --bg-elevated: #0c1220; | |
| --surface: #10182c; | |
| --surface-hover: #182238; | |
| --border: #23304a; | |
| --text: #f4f6fb; | |
| --text-muted: #8b96b3; | |
| --primary: #e60a3e; | |
| --primary-hover: #ff1f52; | |
| --accent: #004d98; | |
| --accent-light: #2f7bff; | |
| --violet: #3d0e5e; | |
| --gold: #ffc72c; | |
| --success: #00e28a; | |
| --danger: #ff4d5e; | |
| --radius: 16px; | |
| --radius-sm: 10px; | |
| --shadow: 0 20px 50px rgba(0, 0, 0, 0.45); | |
| --shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.25); | |
| --gap-lg: clamp(20px, 4vw, 32px); | |
| --gap-md: clamp(14px, 2.5vw, 22px); | |
| --gap-sm: clamp(8px, 1.5vw, 12px); | |
| } | |
| html { | |
| -webkit-text-size-adjust: 100%; | |
| text-size-adjust: 100%; | |
| } | |
| body { | |
| font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; | |
| background: radial-gradient(1200px 600px at 15% -10%, rgba(0, 77, 152, 0.35), transparent 60%), radial-gradient(1000px 500px at 110% 0%, rgba(230, 10, 62, 0.25), transparent 55%), var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| min-height: 100dvh; | |
| line-height: 1.55; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| text-rendering: optimizeLegibility; | |
| overflow-x: hidden; | |
| } | |
| button, | |
| input, | |
| select { | |
| font-family: inherit; | |
| } | |
| button { | |
| -webkit-tap-highlight-color: transparent; | |
| touch-action: manipulation; | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, | |
| *::before, | |
| *::after { | |
| animation-duration: 0.01ms !important; | |
| animation-iteration-count: 1 !important; | |
| transition-duration: 0.01ms !important; | |
| scroll-behavior: auto !important; | |
| } | |
| } | |
| .app { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 100vh; | |
| min-height: 100dvh; | |
| } | |
| .header { | |
| background: linear-gradient(120deg, #050914 0%, #10182c 45%, #3d0e5e 100%); | |
| border-bottom: 1px solid var(--border); | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| -webkit-backdrop-filter: blur(14px); | |
| backdrop-filter: blur(14px); | |
| box-shadow: var(--shadow-soft); | |
| } | |
| .header-inner { | |
| max-width: 1320px; | |
| margin: 0 auto; | |
| padding: 0 clamp(16px, 4vw, 32px); | |
| min-height: 84px; | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| min-width: 0; | |
| } | |
| .brand-icon { | |
| font-size: 2.1rem; | |
| line-height: 1; | |
| flex-shrink: 0; | |
| filter: drop-shadow(0 4px 10px rgba(230, 10, 62, 0.5)); | |
| } | |
| .brand-text { | |
| display: flex; | |
| flex-direction: column; | |
| min-width: 0; | |
| } | |
| .brand-title { | |
| font-family: 'Archivo Black', 'Inter', sans-serif; | |
| font-size: clamp(1.05rem, 3vw, 1.4rem); | |
| letter-spacing: -0.5px; | |
| color: #fff; | |
| text-transform: uppercase; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .brand-sub { | |
| font-size: 0.68rem; | |
| text-transform: uppercase; | |
| letter-spacing: 2.5px; | |
| color: var(--gold); | |
| font-weight: 700; | |
| margin-top: 2px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .nav { | |
| display: flex; | |
| gap: 4px; | |
| background: rgba(255, 255, 255, 0.04); | |
| padding: 6px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| max-width: 100%; | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| scrollbar-width: none; | |
| } | |
| .nav::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .nav-btn { | |
| background: transparent; | |
| border: none; | |
| color: rgba(255, 255, 255, 0.65); | |
| padding: 11px clamp(16px, 4vw, 26px); | |
| border-radius: 999px; | |
| font-weight: 700; | |
| font-size: 0.88rem; | |
| letter-spacing: 0.3px; | |
| cursor: pointer; | |
| white-space: nowrap; | |
| transition: background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease; | |
| } | |
| .nav-btn:hover { | |
| color: #fff; | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .nav-btn:focus-visible { | |
| outline: 2px solid var(--accent-light); | |
| outline-offset: 2px; | |
| } | |
| .nav-btn.active { | |
| background: linear-gradient(135deg, var(--primary), #b3002f); | |
| color: #fff; | |
| box-shadow: 0 8px 20px rgba(230, 10, 62, 0.45); | |
| } | |
| .main { | |
| flex: 1; | |
| max-width: 1320px; | |
| width: 100%; | |
| margin: 0 auto; | |
| padding: clamp(24px, 5vw, 48px) clamp(16px, 4vw, 32px); | |
| } | |
| .alert { | |
| background: rgba(255, 77, 94, 0.12); | |
| border: 1px solid rgba(255, 77, 94, 0.4); | |
| color: #ff9aa5; | |
| padding: 16px 20px; | |
| border-radius: var(--radius-sm); | |
| margin-bottom: var(--gap-lg); | |
| font-weight: 500; | |
| -webkit-backdrop-filter: blur(6px); | |
| backdrop-filter: blur(6px); | |
| word-break: break-word; | |
| } | |
| .layout { | |
| display: grid; | |
| grid-template-columns: minmax(280px, 380px) 1fr; | |
| gap: var(--gap-lg); | |
| align-items: start; | |
| } | |
| @media (max-width: 960px) { | |
| .layout { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .panel { | |
| background: linear-gradient(180deg, var(--surface) 0%, rgba(16, 24, 44, 0.7) 100%); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| box-shadow: var(--shadow); | |
| min-width: 0; | |
| } | |
| .panel-head { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 18px clamp(16px, 4vw, 26px); | |
| background: linear-gradient(90deg, rgba(0, 77, 152, 0.5) 0%, transparent 100%); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .panel-head h2 { | |
| font-size: clamp(1rem, 3vw, 1.15rem); | |
| font-weight: 800; | |
| color: #fff; | |
| letter-spacing: -0.2px; | |
| } | |
| .count { | |
| font-size: 0.78rem; | |
| color: #1a1a1a; | |
| font-weight: 800; | |
| background: var(--gold); | |
| padding: 5px 12px; | |
| border-radius: 999px; | |
| white-space: nowrap; | |
| } | |
| .form { | |
| padding: clamp(18px, 4vw, 26px); | |
| } | |
| .field { | |
| margin-bottom: 18px; | |
| } | |
| .field label { | |
| display: block; | |
| font-size: 0.75rem; | |
| font-weight: 700; | |
| color: var(--text-muted); | |
| margin-bottom: 8px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .field input { | |
| width: 100%; | |
| font-size: max(0.95rem, 16px); | |
| padding: 13px 16px; | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| color: var(--text); | |
| outline: none; | |
| -webkit-appearance: none; | |
| appearance: none; | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; | |
| } | |
| .field input:focus, | |
| .field input:focus-visible { | |
| border-color: var(--accent-light); | |
| background: rgba(47, 123, 255, 0.06); | |
| box-shadow: 0 0 0 4px rgba(47, 123, 255, 0.18); | |
| } | |
| .field input::placeholder { | |
| color: #4d5a78; | |
| opacity: 1; | |
| } | |
| .field select { | |
| width: 100%; | |
| font-size: max(0.95rem, 16px); | |
| padding: 13px 40px 13px 16px; | |
| background-color: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| color: var(--text); | |
| outline: none; | |
| cursor: pointer; | |
| -webkit-appearance: none; | |
| -moz-appearance: none; | |
| appearance: none; | |
| background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b96b3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>"); | |
| background-repeat: no-repeat; | |
| background-position: right 14px center; | |
| background-size: 16px; | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; | |
| } | |
| .field select:hover { | |
| border-color: var(--accent-light); | |
| } | |
| .field select:focus, | |
| .field select:focus-visible { | |
| border-color: var(--accent-light); | |
| background-color: rgba(47, 123, 255, 0.06); | |
| box-shadow: 0 0 0 4px rgba(47, 123, 255, 0.18); | |
| } | |
| .field select option { | |
| background: var(--bg-elevated); | |
| color: var(--text); | |
| } | |
| .form-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| margin-top: 10px; | |
| } | |
| .btn { | |
| padding: 13px 22px; | |
| border-radius: var(--radius-sm); | |
| border: none; | |
| font-weight: 700; | |
| font-size: 0.9rem; | |
| letter-spacing: 0.2px; | |
| cursor: pointer; | |
| transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; | |
| } | |
| .btn:focus-visible { | |
| outline: 2px solid var(--accent-light); | |
| outline-offset: 2px; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, var(--primary) 0%, #ff2f61 100%); | |
| color: #fff; | |
| flex: 1 1 auto; | |
| box-shadow: 0 10px 24px rgba(230, 10, 62, 0.4); | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 14px 30px rgba(230, 10, 62, 0.55); | |
| } | |
| .btn-primary:active { | |
| transform: translateY(0); | |
| } | |
| .btn-ghost { | |
| background: rgba(255, 255, 255, 0.03); | |
| color: var(--text-muted); | |
| border: 1px solid var(--border); | |
| } | |
| .btn-ghost:hover { | |
| background: var(--surface-hover); | |
| color: #fff; | |
| border-color: var(--accent-light); | |
| } | |
| .btn-danger { | |
| background: linear-gradient(135deg, var(--danger) 0%, #c9182c 100%); | |
| color: #fff; | |
| box-shadow: 0 10px 24px rgba(255, 77, 94, 0.35); | |
| } | |
| .btn-danger:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 14px 30px rgba(255, 77, 94, 0.5); | |
| } | |
| .btn-danger:active { | |
| transform: translateY(0); | |
| } | |
| .btn-icon { | |
| width: 36px; | |
| height: 36px; | |
| padding: 0; | |
| border-radius: var(--radius-sm); | |
| border: none; | |
| cursor: pointer; | |
| font-size: 0.95rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| background: rgba(47, 123, 255, 0.14); | |
| color: var(--accent-light); | |
| transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; | |
| } | |
| .btn-icon:hover { | |
| background: var(--accent-light); | |
| color: #fff; | |
| transform: translateY(-1px); | |
| } | |
| .btn-icon:focus-visible { | |
| outline: 2px solid var(--accent-light); | |
| outline-offset: 2px; | |
| } | |
| .btn-icon.danger { | |
| background: rgba(255, 77, 94, 0.14); | |
| color: var(--danger); | |
| } | |
| .btn-icon.danger:hover { | |
| background: var(--danger); | |
| color: #fff; | |
| transform: translateY(-1px); | |
| } | |
| .modal-overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(2, 5, 12, 0.72); | |
| -webkit-backdrop-filter: blur(6px); | |
| backdrop-filter: blur(6px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| padding: 24px; | |
| animation: overlay-fade 0.2s ease; | |
| } | |
| @keyframes overlay-fade { | |
| from { | |
| opacity: 0; | |
| } | |
| to { | |
| opacity: 1; | |
| } | |
| } | |
| .modal { | |
| width: 100%; | |
| max-width: 420px; | |
| max-height: 90vh; | |
| max-height: 90dvh; | |
| overflow-y: auto; | |
| background: linear-gradient(180deg, var(--surface) 0%, var(--bg-elevated) 100%); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow); | |
| padding: clamp(22px, 5vw, 32px); | |
| text-align: center; | |
| animation: modal-pop 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.3); | |
| } | |
| @keyframes modal-pop { | |
| from { | |
| opacity: 0; | |
| transform: translateY(12px) scale(0.96); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| } | |
| .modal h3 { | |
| font-size: 1.2rem; | |
| font-weight: 800; | |
| color: #fff; | |
| margin-bottom: 12px; | |
| letter-spacing: -0.2px; | |
| } | |
| .modal p { | |
| color: var(--text-muted); | |
| font-size: 0.92rem; | |
| line-height: 1.6; | |
| margin-bottom: 26px; | |
| word-break: break-word; | |
| } | |
| .modal-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| } | |
| .modal-actions .btn { | |
| flex: 1 1 140px; | |
| } | |
| .list-panel { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .table-wrap { | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| flex: 1; | |
| } | |
| table { | |
| width: 100%; | |
| min-width: 640px; | |
| border-collapse: separate; | |
| border-spacing: 0; | |
| table-layout: auto; | |
| } | |
| th { | |
| text-align: left; | |
| padding: 14px clamp(12px, 3vw, 22px); | |
| font-size: 0.7rem; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| color: var(--text-muted); | |
| background: var(--bg-elevated); | |
| border-bottom: 1px solid var(--border); | |
| white-space: nowrap; | |
| vertical-align: middle; | |
| } | |
| td { | |
| padding: 14px clamp(12px, 3vw, 22px); | |
| border-bottom: 1px solid var(--border); | |
| font-size: 0.92rem; | |
| vertical-align: middle; | |
| overflow-wrap: break-word; | |
| } | |
| th:first-child, | |
| td:first-child { | |
| white-space: nowrap; | |
| width: 1%; | |
| } | |
| th:last-child, | |
| td.actions { | |
| white-space: nowrap; | |
| width: 1%; | |
| } | |
| tbody tr { | |
| transition: background 0.2s ease; | |
| } | |
| tbody tr:hover { | |
| background: var(--surface-hover); | |
| } | |
| .id { | |
| color: var(--text-muted); | |
| font-variant-numeric: tabular-nums; | |
| font-weight: 700; | |
| } | |
| .name { | |
| font-weight: 800; | |
| color: #fff; | |
| letter-spacing: -0.1px; | |
| } | |
| .actions { | |
| display: table-cell; | |
| white-space: nowrap; | |
| vertical-align: middle; | |
| } | |
| .actions .btn-icon { | |
| display: inline-flex; | |
| } | |
| .actions .btn-icon + .btn-icon { | |
| margin-left: 8px; | |
| } | |
| .empty { | |
| text-align: center; | |
| color: var(--text-muted); | |
| padding: 64px 18px !important; | |
| } | |
| .th-content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .filter-input { | |
| width: 100%; | |
| min-width: 64px; | |
| height: 32px; | |
| padding: 6px 9px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| color: var(--text); | |
| font-family: inherit; | |
| font-size: 0.75rem; | |
| font-weight: 500; | |
| outline: none; | |
| text-transform: none; | |
| letter-spacing: 0; | |
| -webkit-appearance: none; | |
| appearance: none; | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; | |
| } | |
| .filter-input:hover { | |
| border-color: #344566; | |
| } | |
| .filter-input:focus, | |
| .filter-input:focus-visible { | |
| border-color: var(--accent-light); | |
| background: var(--bg-elevated); | |
| box-shadow: 0 0 0 3px rgba(47, 123, 255, 0.15); | |
| } | |
| .filter-input::placeholder { | |
| color: #56627d; | |
| } | |
| .loader { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 18px; | |
| padding: 80px 20px; | |
| color: var(--text-muted); | |
| } | |
| .spinner { | |
| width: 44px; | |
| height: 44px; | |
| border: 3px solid var(--border); | |
| border-top-color: var(--primary); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| @media (max-width: 640px) { | |
| .header-inner { | |
| min-height: auto; | |
| padding-top: 14px; | |
| padding-bottom: 14px; | |
| } | |
| .nav { | |
| width: 100%; | |
| justify-content: flex-start; | |
| } | |
| .nav-btn { | |
| flex: 1; | |
| text-align: center; | |
| padding: 10px 12px; | |
| } | |
| .main { | |
| padding-top: 20px; | |
| } | |
| .panel-head { | |
| padding: 16px; | |
| } | |
| .form { | |
| padding: 16px; | |
| } | |
| .form-actions { | |
| flex-direction: column; | |
| } | |
| .form-actions .btn { | |
| width: 100%; | |
| } | |
| table { | |
| min-width: 520px; | |
| } | |
| th, | |
| td { | |
| padding: 10px 12px; | |
| } | |
| .modal { | |
| padding: 20px; | |
| } | |
| .modal-actions { | |
| flex-direction: column; | |
| } | |
| } | |
| @media (max-width: 380px) { | |
| .brand-icon { | |
| font-size: 1.7rem; | |
| } | |
| .brand-title { | |
| font-size: 1rem; | |
| } | |
| .btn-icon { | |
| width: 32px; | |
| height: 32px; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useState, useEffect, useCallback, useMemo } from 'react'; | |
| import { | |
| useTable, | |
| tableFeatures, | |
| // імпорт фічі для фільтрації колонок та допоміжних функцій TanStack Table | |
| columnFilteringFeature, | |
| createFilteredRowModel, | |
| createColumnHelper, | |
| filterFn_includesString, | |
| } from '@tanstack/react-table'; | |
| import './App.css'; | |
| const API_BASE_URL = '/api'; | |
| const columnHelper = createColumnHelper(); | |
| // конфігурація фіч таблиці: підключення фільтрації колонок та функції фільтрації за входженням підрядка | |
| const playersTableFeatures = tableFeatures({ | |
| columnFilteringFeature, | |
| filteredRowModel: createFilteredRowModel(), | |
| filterFns: { | |
| includesString: filterFn_includesString, | |
| }, | |
| }); | |
| export default function App() { | |
| const [activeTab, setActiveTab] = useState('players'); | |
| const [players, setPlayers] = useState([]); | |
| const [teams, setTeams] = useState([]); | |
| const [teamOptions, setTeamOptions] = useState([]); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [playerForm, setPlayerForm] = useState({ id: 0, name: '', age: '', position: '', teamId: '' }); | |
| const [teamForm, setTeamForm] = useState({ id: 0, name: '', coach: '' }); | |
| const [isEditing, setIsEditing] = useState(false); | |
| // визначення колонок таблиці із зазначенням типу фільтрації 'includesString' для кожного поля | |
| const playersColumns = useMemo( | |
| () => [ | |
| columnHelper.accessor('id', { | |
| header: 'ID', | |
| filterFn: 'includesString', // застосування фільтрації за підрядком для колонок | |
| }), | |
| columnHelper.accessor('name', { | |
| header: "Ім'я", | |
| filterFn: 'includesString', | |
| }), | |
| columnHelper.accessor('age', { | |
| header: 'Вік', | |
| filterFn: 'includesString', | |
| }), | |
| columnHelper.accessor('position', { | |
| header: 'Позиція', | |
| filterFn: 'includesString', | |
| }), | |
| columnHelper.accessor('team', { | |
| header: 'Команда', | |
| filterFn: 'includesString', | |
| }), | |
| ], | |
| [] | |
| ); | |
| // стан для збереження поточних значень фільтрів усіх колонок | |
| const [columnFilters, setColumnFilters] = useState([]); | |
| // ініціалізація екземпляра таблиці з передачею стану фільтрів та обробника їх зміни | |
| const playersTable = useTable({ | |
| features: playersTableFeatures, | |
| data: players, | |
| columns: playersColumns, | |
| state: { columnFilters }, // передача поточного стану фільтрів у таблицю | |
| onColumnFiltersChange: setColumnFilters, // оновлення стану при зміні значень у фільтрах | |
| }); | |
| // отримання вже відфільтрованого списку гравців з моделі рядків таблиці | |
| const filteredPlayers = playersTable.getRowModel().rows.map((row) => row.original); | |
| const resetForm = useCallback(() => { | |
| setPlayerForm({ id: 0, name: '', age: '', position: '', teamId: '' }); | |
| setTeamForm({ id: 0, name: '', coach: '' }); | |
| setIsEditing(false); | |
| }, []); | |
| const refreshTeamOptions = async () => { | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/teams`); | |
| if (!response.ok) return; | |
| const data = await response.json(); | |
| setTeamOptions(data); | |
| } catch { | |
| setTeamOptions([]); | |
| } | |
| }; | |
| const fetchData = useCallback(async () => { | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const endpoint = activeTab === 'players' ? 'players' : 'teams'; | |
| const response = await fetch(`${API_BASE_URL}/${endpoint}`); | |
| if (!response.ok) throw new Error(`Помилка завантаження: ${response.statusText}`); | |
| const data = await response.json(); | |
| if (activeTab === 'players') setPlayers(data); | |
| else setTeams(data); | |
| } catch (err) { | |
| setError(err.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [activeTab]); | |
| useEffect(() => { | |
| let ignore = false; | |
| const load = async () => { | |
| resetForm(); | |
| setColumnFilters([]); // скидання всіх активних фільтрів при зміні вкладки | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const endpoint = activeTab === 'players' ? 'players' : 'teams'; | |
| const response = await fetch(`${API_BASE_URL}/${endpoint}`); | |
| if (!response.ok) throw new Error(`Помилка завантаження: ${response.statusText}`); | |
| const data = await response.json(); | |
| if (activeTab === 'players') { | |
| const teamsResponse = await fetch(`${API_BASE_URL}/teams`); | |
| const teamsData = teamsResponse.ok ? await teamsResponse.json() : []; | |
| if (!ignore) { | |
| setPlayers(data); | |
| setTeamOptions(teamsData); | |
| } | |
| } else { | |
| if (!ignore) { | |
| setTeams(data); | |
| setTeamOptions(data); | |
| } | |
| } | |
| } catch (err) { | |
| if (!ignore) setError(err.message); | |
| } finally { | |
| if (!ignore) setLoading(false); | |
| } | |
| }; | |
| load(); | |
| return () => { ignore = true; }; | |
| }, [activeTab, resetForm]); | |
| const handleSubmit = async (e) => { | |
| e.preventDefault(); | |
| const isPlayer = activeTab === 'players'; | |
| const endpoint = isPlayer ? 'players' : 'teams'; | |
| const formData = isPlayer | |
| ? { ...playerForm, age: Number(playerForm.age), teamId: playerForm.teamId ? Number(playerForm.teamId) : null } | |
| : teamForm; | |
| const url = isEditing ? `${API_BASE_URL}/${endpoint}/${formData.id}` : `${API_BASE_URL}/${endpoint}`; | |
| const method = isEditing ? 'PUT' : 'POST'; | |
| try { | |
| const response = await fetch(url, { | |
| method, | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(formData), | |
| }); | |
| if (!response.ok) throw new Error('Не вдалося зберегти дані'); | |
| resetForm(); | |
| fetchData(); | |
| if (!isPlayer) refreshTeamOptions(); | |
| } catch (err) { | |
| setError(err.message); | |
| } | |
| }; | |
| const handleEdit = (item) => { | |
| setIsEditing(true); | |
| if (activeTab === 'players') { | |
| setPlayerForm({ | |
| id: item.id, | |
| name: item.name || '', | |
| age: item.age ?? '', | |
| position: item.position || '', | |
| teamId: item.teamId ?? '', | |
| }); | |
| } else { | |
| setTeamForm({ | |
| id: item.id, | |
| name: item.name || '', | |
| coach: item.coach || '', | |
| }); | |
| } | |
| }; | |
| const [confirmDialog, setConfirmDialog] = useState(null); | |
| const requestDelete = (id, name) => { | |
| setConfirmDialog({ id, name }); | |
| }; | |
| const cancelDelete = () => { | |
| setConfirmDialog(null); | |
| }; | |
| const confirmDelete = async () => { | |
| if (!confirmDialog) return; | |
| const id = confirmDialog.id; | |
| setConfirmDialog(null); | |
| const endpoint = activeTab === 'players' ? 'players' : 'teams'; | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/${endpoint}/${id}`, { method: 'DELETE' }); | |
| if (!response.ok) throw new Error('Помилка при видаленні'); | |
| fetchData(); | |
| if (activeTab === 'teams') refreshTeamOptions(); | |
| } catch (err) { | |
| setError(err.message); | |
| } | |
| }; | |
| return ( | |
| <div className="app"> | |
| <header className="header"> | |
| <div className="header-inner"> | |
| <div className="brand"> | |
| <span className="brand-icon">⚽</span> | |
| <div className="brand-text"> | |
| <span className="brand-title">Футбольна ліга</span> | |
| <span className="brand-sub">Менеджер гравців та команд</span> | |
| </div> | |
| </div> | |
| <nav className="nav"> | |
| <button | |
| className={`nav-btn ${activeTab === 'players' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('players')} | |
| > | |
| Гравці | |
| </button> | |
| <button | |
| className={`nav-btn ${activeTab === 'teams' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('teams')} | |
| > | |
| Команди | |
| </button> | |
| </nav> | |
| </div> | |
| </header> | |
| <main className="main"> | |
| {error && <div className="alert">{error}</div>} | |
| <div className="layout"> | |
| <section className="panel form-panel"> | |
| <div className="panel-head"> | |
| <h2>{isEditing ? 'Редагувати' : 'Додати'} {activeTab === 'players' ? 'гравця' : 'команду'}</h2> | |
| </div> | |
| <form onSubmit={handleSubmit} className="form"> | |
| {activeTab === 'players' ? ( | |
| <> | |
| <div className="field"> | |
| <label>Ім'я гравця</label> | |
| <input | |
| type="text" | |
| required | |
| value={playerForm.name} | |
| onChange={(e) => setPlayerForm({ ...playerForm, name: e.target.value })} | |
| placeholder="наприклад, Тимерлан Гусейнов" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Вік</label> | |
| <input | |
| type="number" | |
| required | |
| min="15" | |
| max="50" | |
| value={playerForm.age} | |
| onChange={(e) => setPlayerForm({ ...playerForm, age: e.target.value })} | |
| placeholder="25" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Позиція</label> | |
| <input | |
| type="text" | |
| required | |
| value={playerForm.position} | |
| onChange={(e) => setPlayerForm({ ...playerForm, position: e.target.value })} | |
| placeholder="наприклад, Форвард" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Команда</label> | |
| <select | |
| value={playerForm.teamId} | |
| onChange={(e) => setPlayerForm({ ...playerForm, teamId: e.target.value })} | |
| > | |
| <option value="">Без команди</option> | |
| {teamOptions.map((team) => ( | |
| <option key={team.id} value={team.id}> | |
| {team.name} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </> | |
| ) : ( | |
| <> | |
| <div className="field"> | |
| <label>Назва команди</label> | |
| <input | |
| type="text" | |
| required | |
| value={teamForm.name} | |
| onChange={(e) => setTeamForm({ ...teamForm, name: e.target.value })} | |
| placeholder="наприклад, ФК Чорноморець Одеса" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Тренер</label> | |
| <input | |
| type="text" | |
| required | |
| value={teamForm.coach} | |
| onChange={(e) => setTeamForm({ ...teamForm, coach: e.target.value })} | |
| placeholder="наприклад, Валерій Лобановський" | |
| /> | |
| </div> | |
| </> | |
| )} | |
| <div className="form-actions"> | |
| <button type="submit" className="btn btn-primary"> | |
| {isEditing ? 'Зберегти' : 'Створити'} | |
| </button> | |
| {isEditing && ( | |
| <button type="button" className="btn btn-ghost" onClick={resetForm}> | |
| Скасувати | |
| </button> | |
| )} | |
| </div> | |
| </form> | |
| </section> | |
| <section className="panel list-panel"> | |
| <div className="panel-head"> | |
| <h2>Список {activeTab === 'players' ? 'гравців' : 'команд'}</h2> | |
| <span className="count"> | |
| {/* відображення кількості записів з урахуванням відфільтрованих даних для гравців */} | |
| {(activeTab === 'players' ? filteredPlayers : teams).length} записів | |
| </span> | |
| </div> | |
| {loading ? ( | |
| <div className="loader"> | |
| <div className="spinner"></div> | |
| <span>Завантаження...</span> | |
| </div> | |
| ) : ( | |
| <div className="table-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| {activeTab === 'players' ? ( | |
| <> | |
| <th> | |
| <div className="th-content"> | |
| <span>ID</span> | |
| {/* поле інпуту для введення фільтра колонки id */} | |
| <input | |
| className="filter-input" | |
| type="text" | |
| value={playersTable.getColumn('id')?.getFilterValue() ?? ''} // читання поточного значення фільтра | |
| onChange={(e) => | |
| playersTable.getColumn('id')?.setFilterValue(e.target.value) // запис нового значення у фільтр колонки | |
| } | |
| placeholder="Фільтр..." | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span>Ім'я</span> | |
| {/* поле інпуту для введення фільтра колонки name */} | |
| <input | |
| className="filter-input" | |
| type="text" | |
| value={playersTable.getColumn('name')?.getFilterValue() ?? ''} // читання поточного значення фільтра | |
| onChange={(e) => | |
| playersTable.getColumn('name')?.setFilterValue(e.target.value) // запис нового значення у фільтр колонки | |
| } | |
| placeholder="Фільтр..." | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span>Вік</span> | |
| {/* поле інпуту для введення фільтра колонки age */} | |
| <input | |
| className="filter-input" | |
| type="text" | |
| value={playersTable.getColumn('age')?.getFilterValue() ?? ''} // читання поточного значення фільтра | |
| onChange={(e) => | |
| playersTable.getColumn('age')?.setFilterValue(e.target.value) // запис нового значення у фільтр колонки | |
| } | |
| placeholder="Фільтр..." | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span>Позиція</span> | |
| {/* поле інпуту для введення фільтра колонки position */} | |
| <input | |
| className="filter-input" | |
| type="text" | |
| value={playersTable.getColumn('position')?.getFilterValue() ?? ''} // читання поточного значення фільтра | |
| onChange={(e) => | |
| playersTable.getColumn('position')?.setFilterValue(e.target.value) // запис нового значення у фільтр колонки | |
| } | |
| placeholder="Фільтр..." | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span>Команда</span> | |
| {/* поле інпуту для введення фільтра колонки team */} | |
| <input | |
| className="filter-input" | |
| type="text" | |
| value={playersTable.getColumn('team')?.getFilterValue() ?? ''} // читання поточного значення фільтра | |
| onChange={(e) => | |
| playersTable.getColumn('team')?.setFilterValue(e.target.value) // запис нового значення у фільтр колонки | |
| } | |
| placeholder="Фільтр..." | |
| /> | |
| </div> | |
| </th> | |
| </> | |
| ) : ( | |
| <> | |
| <th>ID</th> | |
| <th>Команда</th> | |
| <th>Тренер</th> | |
| </> | |
| )} | |
| <th>Дії</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {/* рендеринг відфільтрованого списку гравців (filteredPlayers) або звичайного списку команд */} | |
| {(activeTab === 'players' ? filteredPlayers : teams).length === 0 ? ( | |
| <tr> | |
| <td colSpan={activeTab === 'players' ? 6 : 4} className="empty"> | |
| Дані відсутні | |
| </td> | |
| </tr> | |
| ) : ( | |
| (activeTab === 'players' ? filteredPlayers : teams).map((item) => ( | |
| <tr key={item.id}> | |
| <td className="id">#{item.id}</td> | |
| <td className="name">{item.name}</td> | |
| {activeTab === 'players' ? ( | |
| <> | |
| <td>{item.age}</td> | |
| <td>{item.position}</td> | |
| <td>{item.team || '—'}</td> | |
| </> | |
| ) : ( | |
| <td>{item.coach}</td> | |
| )} | |
| <td className="actions"> | |
| <button className="btn-icon" onClick={() => handleEdit(item)} title="Редагувати"> | |
| ✏️ | |
| </button> | |
| <button className="btn-icon danger" onClick={() => requestDelete(item.id, item.name)} title="Видалити"> | |
| 🗑️ | |
| </button> | |
| </td> | |
| </tr> | |
| )) | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </section> | |
| </div> | |
| </main> | |
| {confirmDialog && ( | |
| <div className="modal-overlay"> | |
| <div className="modal"> | |
| <h3>Підтвердження видалення</h3> | |
| <p> | |
| Ви дійсно бажаєте видалити {activeTab === 'players' ? 'гравця' : 'команду'} <strong>{confirmDialog.name}</strong>? | |
| </p> | |
| <div className="modal-actions"> | |
| <button className="btn btn-danger" onClick={confirmDelete}> | |
| Видалити | |
| </button> | |
| <button className="btn btn-ghost" onClick={cancelDelete}> | |
| Скасувати | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment