django-school-framework 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apps/__init__.py +1 -0
- apps/admissions/__init__.py +0 -0
- apps/admissions/admin.py +28 -0
- apps/admissions/apps.py +6 -0
- apps/admissions/migrations/0001_initial.py +80 -0
- apps/admissions/migrations/__init__.py +0 -0
- apps/admissions/models.py +105 -0
- apps/admissions/serializers.py +28 -0
- apps/admissions/tests.py +29 -0
- apps/admissions/urls.py +16 -0
- apps/admissions/views.py +30 -0
- apps/core/__init__.py +0 -0
- apps/core/admin.py +17 -0
- apps/core/apps.py +6 -0
- apps/core/cli.py +1341 -0
- apps/core/compliance.py +236 -0
- apps/core/management/__init__.py +0 -0
- apps/core/management/commands/__init__.py +0 -0
- apps/core/management/commands/seed_school_data.py +281 -0
- apps/core/management/commands/wait_for_db.py +26 -0
- apps/core/migrations/0001_initial.py +55 -0
- apps/core/migrations/0002_alter_schoolprofile_options.py +17 -0
- apps/core/migrations/__init__.py +0 -0
- apps/core/models.py +134 -0
- apps/core/serializers.py +18 -0
- apps/core/templatetags/__init__.py +1 -0
- apps/core/templatetags/compliance_tags.py +13 -0
- apps/core/tests.py +123 -0
- apps/core/urls.py +14 -0
- apps/core/views.py +130 -0
- apps/gallery/__init__.py +0 -0
- apps/gallery/admin.py +18 -0
- apps/gallery/apps.py +6 -0
- apps/gallery/migrations/0001_initial.py +46 -0
- apps/gallery/migrations/__init__.py +0 -0
- apps/gallery/models.py +48 -0
- apps/gallery/serializers.py +14 -0
- apps/gallery/tests.py +31 -0
- apps/gallery/urls.py +11 -0
- apps/gallery/views.py +15 -0
- apps/legal/__init__.py +0 -0
- apps/legal/admin.py +31 -0
- apps/legal/apps.py +6 -0
- apps/legal/migrations/0001_initial.py +105 -0
- apps/legal/migrations/__init__.py +0 -0
- apps/legal/models.py +172 -0
- apps/legal/serializers.py +43 -0
- apps/legal/tests.py +42 -0
- apps/legal/urls.py +17 -0
- apps/legal/views.py +33 -0
- apps/news/__init__.py +0 -0
- apps/news/admin.py +13 -0
- apps/news/apps.py +6 -0
- apps/news/migrations/0001_initial.py +46 -0
- apps/news/migrations/__init__.py +0 -0
- apps/news/models.py +55 -0
- apps/news/serializers.py +14 -0
- apps/news/tests.py +37 -0
- apps/news/urls.py +11 -0
- apps/news/views.py +15 -0
- apps/nutrition/__init__.py +0 -0
- apps/nutrition/admin.py +12 -0
- apps/nutrition/apps.py +6 -0
- apps/nutrition/migrations/0001_initial.py +47 -0
- apps/nutrition/migrations/0002_alter_cateringprovider_options.py +17 -0
- apps/nutrition/migrations/__init__.py +0 -0
- apps/nutrition/models.py +53 -0
- apps/nutrition/serializers.py +17 -0
- apps/nutrition/tests.py +41 -0
- apps/nutrition/urls.py +13 -0
- apps/nutrition/views.py +13 -0
- apps/safety/__init__.py +0 -0
- apps/safety/admin.py +21 -0
- apps/safety/apps.py +6 -0
- apps/safety/migrations/0001_initial.py +48 -0
- apps/safety/migrations/__init__.py +0 -0
- apps/safety/models.py +70 -0
- apps/safety/serializers.py +17 -0
- apps/safety/tests.py +20 -0
- apps/safety/urls.py +11 -0
- apps/safety/views.py +17 -0
- apps/schedule/__init__.py +0 -0
- apps/schedule/admin.py +12 -0
- apps/schedule/apps.py +6 -0
- apps/schedule/migrations/0001_initial.py +49 -0
- apps/schedule/migrations/__init__.py +0 -0
- apps/schedule/models.py +60 -0
- apps/schedule/serializers.py +15 -0
- apps/schedule/tests.py +51 -0
- apps/schedule/urls.py +11 -0
- apps/schedule/views.py +15 -0
- django_school_framework-0.2.2.dist-info/METADATA +53 -0
- django_school_framework-0.2.2.dist-info/RECORD +96 -0
- django_school_framework-0.2.2.dist-info/WHEEL +5 -0
- django_school_framework-0.2.2.dist-info/entry_points.txt +2 -0
- django_school_framework-0.2.2.dist-info/top_level.txt +1 -0
apps/core/cli.py
ADDED
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CLI утиліта school-admin для швидкого створення та адміністрування сайтів шкіл.
|
|
4
|
+
Частина фреймворку django-school-framework.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import argparse
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# ANSI colors
|
|
13
|
+
GREEN = "\033[92m"
|
|
14
|
+
YELLOW = "\033[93m"
|
|
15
|
+
RED = "\033[91m"
|
|
16
|
+
BLUE = "\033[94m"
|
|
17
|
+
CYAN = "\033[96m"
|
|
18
|
+
BOLD = "\033[1m"
|
|
19
|
+
RESET = "\033[0m"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def setup_django_environment():
|
|
23
|
+
"""Ініціалізація оточення Django для команд, що працюють з БД."""
|
|
24
|
+
cwd = str(Path.cwd().resolve())
|
|
25
|
+
if cwd not in sys.path:
|
|
26
|
+
sys.path.insert(0, cwd)
|
|
27
|
+
if not os.environ.get("DJANGO_SETTINGS_MODULE"):
|
|
28
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
29
|
+
|
|
30
|
+
# Python 3.14+ compatibility patch
|
|
31
|
+
try:
|
|
32
|
+
if sys.version_info >= (3, 14):
|
|
33
|
+
import django.template.context as _ctx
|
|
34
|
+
def _safe_basecontext_copy(self):
|
|
35
|
+
obj = self.__class__.__new__(self.__class__)
|
|
36
|
+
obj.__dict__.update(self.__dict__)
|
|
37
|
+
obj.dicts = self.dicts[:]
|
|
38
|
+
return obj
|
|
39
|
+
_ctx.BaseContext.__copy__ = _safe_basecontext_copy
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
import django
|
|
44
|
+
django.setup()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_startproject(args):
|
|
48
|
+
"""Створення нового проєкту закладу освіти з готовою структурою."""
|
|
49
|
+
project_name = args.name
|
|
50
|
+
output_dir = Path(args.output_dir or ".").resolve() / project_name
|
|
51
|
+
|
|
52
|
+
if output_dir.exists() and any(output_dir.iterdir()):
|
|
53
|
+
print(f"{RED}Помилка: Директорія {output_dir} вже існує і не є порожньою.{RESET}")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
(output_dir / "config").mkdir(parents=True, exist_ok=True)
|
|
58
|
+
(output_dir / "media").mkdir(parents=True, exist_ok=True)
|
|
59
|
+
(output_dir / "static").mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
# 1. manage.py
|
|
62
|
+
manage_py_content = """#!/usr/bin/env python
|
|
63
|
+
import os
|
|
64
|
+
import sys
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
|
68
|
+
try:
|
|
69
|
+
from django.core.management import execute_from_command_line
|
|
70
|
+
except ImportError as exc:
|
|
71
|
+
raise ImportError(
|
|
72
|
+
"Не вдалося імпортувати Django. Переконайтеся, що віртуальне оточення активоване."
|
|
73
|
+
) from exc
|
|
74
|
+
execute_from_command_line(sys.argv)
|
|
75
|
+
|
|
76
|
+
if __name__ == '__main__':
|
|
77
|
+
main()
|
|
78
|
+
"""
|
|
79
|
+
(output_dir / "manage.py").write_text(manage_py_content)
|
|
80
|
+
(output_dir / "manage.py").chmod(0o755)
|
|
81
|
+
|
|
82
|
+
# 2. config/settings.py
|
|
83
|
+
settings_content = f"""import os
|
|
84
|
+
from pathlib import Path
|
|
85
|
+
|
|
86
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
87
|
+
|
|
88
|
+
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-school-framework-demo-key-change-in-prod')
|
|
89
|
+
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
|
|
90
|
+
ALLOWED_HOSTS = ['*']
|
|
91
|
+
|
|
92
|
+
INSTALLED_APPS = [
|
|
93
|
+
'django.contrib.admin',
|
|
94
|
+
'django.contrib.auth',
|
|
95
|
+
'django.contrib.contenttypes',
|
|
96
|
+
'django.contrib.sessions',
|
|
97
|
+
'django.contrib.messages',
|
|
98
|
+
'django.contrib.staticfiles',
|
|
99
|
+
|
|
100
|
+
# Third-party
|
|
101
|
+
'rest_framework',
|
|
102
|
+
'corsheaders',
|
|
103
|
+
'drf_spectacular',
|
|
104
|
+
|
|
105
|
+
# School Framework Modules
|
|
106
|
+
'apps.core',
|
|
107
|
+
'apps.legal',
|
|
108
|
+
'apps.admissions',
|
|
109
|
+
'apps.nutrition',
|
|
110
|
+
'apps.safety',
|
|
111
|
+
'apps.news',
|
|
112
|
+
'apps.schedule',
|
|
113
|
+
'apps.gallery',
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
MIDDLEWARE = [
|
|
117
|
+
'corsheaders.middleware.CorsMiddleware',
|
|
118
|
+
'django.middleware.security.SecurityMiddleware',
|
|
119
|
+
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
120
|
+
'django.middleware.locale.LocaleMiddleware',
|
|
121
|
+
'django.middleware.common.CommonMiddleware',
|
|
122
|
+
'django.middleware.csrf.CsrfViewMiddleware',
|
|
123
|
+
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
124
|
+
'django.contrib.messages.middleware.MessageMiddleware',
|
|
125
|
+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
ROOT_URLCONF = 'config.urls'
|
|
129
|
+
|
|
130
|
+
TEMPLATES = [
|
|
131
|
+
{{
|
|
132
|
+
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
133
|
+
'DIRS': [BASE_DIR / 'templates'],
|
|
134
|
+
'APP_DIRS': True,
|
|
135
|
+
'OPTIONS': {{
|
|
136
|
+
'context_processors': [
|
|
137
|
+
'django.template.context_processors.debug',
|
|
138
|
+
'django.template.context_processors.request',
|
|
139
|
+
'django.contrib.auth.context_processors.auth',
|
|
140
|
+
'django.contrib.messages.context_processors.messages',
|
|
141
|
+
],
|
|
142
|
+
}},
|
|
143
|
+
}},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
WSGI_APPLICATION = 'config.wsgi.application'
|
|
147
|
+
|
|
148
|
+
DATABASES = {{
|
|
149
|
+
'default': {{
|
|
150
|
+
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
|
|
151
|
+
'NAME': BASE_DIR / 'db.sqlite3' if os.environ.get('DB_ENGINE', '').endswith('sqlite3') else os.environ.get('DB_NAME', '{project_name}_db'),
|
|
152
|
+
}}
|
|
153
|
+
}}
|
|
154
|
+
|
|
155
|
+
LANGUAGE_CODE = 'uk'
|
|
156
|
+
TIME_ZONE = 'Europe/Kyiv'
|
|
157
|
+
USE_I18N = True
|
|
158
|
+
USE_TZ = True
|
|
159
|
+
|
|
160
|
+
STATIC_URL = '/static/'
|
|
161
|
+
STATIC_ROOT = BASE_DIR / 'static'
|
|
162
|
+
MEDIA_URL = '/media/'
|
|
163
|
+
MEDIA_ROOT = BASE_DIR / 'media'
|
|
164
|
+
|
|
165
|
+
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|
166
|
+
CORS_ALLOW_ALL_ORIGINS = True
|
|
167
|
+
|
|
168
|
+
CSRF_TRUSTED_ORIGINS = [
|
|
169
|
+
origin.strip() for origin in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if origin.strip()
|
|
170
|
+
]
|
|
171
|
+
if DEBUG:
|
|
172
|
+
for port in range(5170, 5185):
|
|
173
|
+
CSRF_TRUSTED_ORIGINS.append(f'http://localhost:{{port}}')
|
|
174
|
+
CSRF_TRUSTED_ORIGINS.append(f'http://127.0.0.1:{{port}}')
|
|
175
|
+
for port in (3000, 8000, 8001, 8080):
|
|
176
|
+
CSRF_TRUSTED_ORIGINS.append(f'http://localhost:{{port}}')
|
|
177
|
+
CSRF_TRUSTED_ORIGINS.append(f'http://127.0.0.1:{{port}}')
|
|
178
|
+
"""
|
|
179
|
+
(output_dir / "config" / "settings.py").write_text(settings_content)
|
|
180
|
+
|
|
181
|
+
# 3. config/urls.py
|
|
182
|
+
urls_content = """from django.contrib import admin
|
|
183
|
+
from django.urls import path, include
|
|
184
|
+
from django.conf import settings
|
|
185
|
+
from django.views.generic import RedirectView
|
|
186
|
+
from apps.core.views import PublicSchoolHomeView
|
|
187
|
+
|
|
188
|
+
urlpatterns = [
|
|
189
|
+
path('', PublicSchoolHomeView.as_view(), name='home'),
|
|
190
|
+
path('admin/', admin.site.urls),
|
|
191
|
+
path('api/v1/core/', include('apps.core.urls')),
|
|
192
|
+
path('api/v1/legal/', include('apps.legal.urls')),
|
|
193
|
+
path('api/v1/admissions/', include('apps.admissions.urls')),
|
|
194
|
+
path('api/v1/nutrition/', include('apps.nutrition.urls')),
|
|
195
|
+
path('api/v1/safety/', include('apps.safety.urls')),
|
|
196
|
+
path('api/v1/news/', include('apps.news.urls')),
|
|
197
|
+
path('api/v1/schedule/', include('apps.schedule.urls')),
|
|
198
|
+
path('api/v1/gallery/', include('apps.gallery.urls')),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
if settings.DEBUG:
|
|
202
|
+
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
203
|
+
"""
|
|
204
|
+
(output_dir / "config" / "urls.py").write_text(urls_content)
|
|
205
|
+
|
|
206
|
+
# 4. config/__init__.py, wsgi.py, asgi.py
|
|
207
|
+
init_content = """# Django School Framework Project
|
|
208
|
+
try:
|
|
209
|
+
import pymysql
|
|
210
|
+
pymysql.install_as_MySQLdb()
|
|
211
|
+
except ImportError:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
import sys
|
|
216
|
+
if sys.version_info >= (3, 14):
|
|
217
|
+
import django.template.context as _ctx
|
|
218
|
+
def _safe_basecontext_copy(self):
|
|
219
|
+
obj = self.__class__.__new__(self.__class__)
|
|
220
|
+
obj.__dict__.update(self.__dict__)
|
|
221
|
+
obj.dicts = self.dicts[:]
|
|
222
|
+
return obj
|
|
223
|
+
_ctx.BaseContext.__copy__ = _safe_basecontext_copy
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
"""
|
|
227
|
+
(output_dir / "config" / "__init__.py").write_text(init_content)
|
|
228
|
+
|
|
229
|
+
wsgi_content = """import os
|
|
230
|
+
from django.core.wsgi import get_wsgi_application
|
|
231
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
|
232
|
+
application = get_wsgi_application()
|
|
233
|
+
"""
|
|
234
|
+
(output_dir / "config" / "wsgi.py").write_text(wsgi_content)
|
|
235
|
+
|
|
236
|
+
# 5. .env.example
|
|
237
|
+
env_example = f"""DEBUG=True
|
|
238
|
+
DJANGO_SECRET_KEY=change-me-in-production
|
|
239
|
+
DB_ENGINE=django.db.backends.sqlite3
|
|
240
|
+
DB_NAME={project_name}_db
|
|
241
|
+
DB_HOST_PORT=3307
|
|
242
|
+
BACKEND_HOST_PORT=8000
|
|
243
|
+
FRONTEND_HOST_PORT=5173
|
|
244
|
+
"""
|
|
245
|
+
(output_dir / ".env.example").write_text(env_example)
|
|
246
|
+
|
|
247
|
+
# 6. README.md
|
|
248
|
+
readme_content = f"""# {project_name} — Офіційний вебсайт закладу освіти
|
|
249
|
+
|
|
250
|
+
Створено за допомогою **`django-school-framework`** (`school-admin`).
|
|
251
|
+
Сайт повністю відповідає вимогам **статті 30 Закону України «Про освіту»**.
|
|
252
|
+
|
|
253
|
+
## Швидкий старт:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
# 1. Встановити залежності
|
|
257
|
+
pip install django-school-framework
|
|
258
|
+
|
|
259
|
+
# 2. Застосувати міграції
|
|
260
|
+
python manage.py migrate
|
|
261
|
+
|
|
262
|
+
# 3. Створити профіль закладу
|
|
263
|
+
school-admin create-school "{project_name}" --edrpou 12345678
|
|
264
|
+
|
|
265
|
+
# 4. Перевірити рівень відповідності закону
|
|
266
|
+
school-admin check-compliance
|
|
267
|
+
|
|
268
|
+
# 5. Запустити сервер розробки
|
|
269
|
+
python manage.py runserver
|
|
270
|
+
```
|
|
271
|
+
"""
|
|
272
|
+
(output_dir / "README.md").write_text(readme_content)
|
|
273
|
+
|
|
274
|
+
print(f"\n{GREEN}{BOLD}✓ Проєкт закладу освіти успішно створено у {output_dir}!{RESET}\n")
|
|
275
|
+
print(f"Наступні кроки:")
|
|
276
|
+
print(f" {CYAN}cd {project_name}{RESET}")
|
|
277
|
+
print(f" {CYAN}python manage.py migrate{RESET}")
|
|
278
|
+
print(f" {CYAN}school-admin create-school \"{project_name}\" --edrpou 12345678{RESET}")
|
|
279
|
+
print(f" {CYAN}python manage.py runserver{RESET}\n")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_create_school(args):
|
|
283
|
+
"""Створення чи оновлення профілю школи з реквізитами та темами."""
|
|
284
|
+
setup_django_environment()
|
|
285
|
+
from apps.core.models import SchoolProfile, ThemeSettings
|
|
286
|
+
|
|
287
|
+
profile, created = SchoolProfile.objects.get_or_create(
|
|
288
|
+
id=1,
|
|
289
|
+
defaults={
|
|
290
|
+
'name': args.name,
|
|
291
|
+
'short_name': args.short_name or args.name,
|
|
292
|
+
'edrpou': args.edrpou or '00000000',
|
|
293
|
+
'founder': args.founder or 'Орган місцевого самоврядування',
|
|
294
|
+
'address': args.address or f"м. {args.city or 'Київ'}",
|
|
295
|
+
'phone': args.phone or "+380 44 000-00-00",
|
|
296
|
+
'email': args.email or "school@education.gov.ua",
|
|
297
|
+
'licensed_capacity': args.capacity or 1000,
|
|
298
|
+
'actual_students': args.actual or 900,
|
|
299
|
+
'education_language': args.language or "Українська",
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if not created:
|
|
304
|
+
profile.name = args.name
|
|
305
|
+
if args.short_name: profile.short_name = args.short_name
|
|
306
|
+
if args.edrpou: profile.edrpou = args.edrpou
|
|
307
|
+
if args.founder: profile.founder = args.founder
|
|
308
|
+
if args.address: profile.address = args.address
|
|
309
|
+
if args.phone: profile.phone = args.phone
|
|
310
|
+
if args.email: profile.email = args.email
|
|
311
|
+
if args.capacity: profile.licensed_capacity = args.capacity
|
|
312
|
+
if args.actual: profile.actual_students = args.actual
|
|
313
|
+
if args.language: profile.education_language = args.language
|
|
314
|
+
profile.save()
|
|
315
|
+
|
|
316
|
+
# Створюємо чи оновлюємо параметри теми
|
|
317
|
+
ThemeSettings.objects.get_or_create(
|
|
318
|
+
school=profile,
|
|
319
|
+
defaults={
|
|
320
|
+
'primary_color': args.primary_color or '#1e3a8a',
|
|
321
|
+
'secondary_color': '#059669',
|
|
322
|
+
'accent_color': '#d97706',
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
action_text = "створено" if created else "оновлено"
|
|
327
|
+
print(f"\n{GREEN}{BOLD}✓ Профіль школи успішно {action_text}!{RESET}")
|
|
328
|
+
print(f" {BOLD}Заклад:{RESET} {profile.name} (ЄДРПОУ {profile.edrpou})")
|
|
329
|
+
print(f" {BOLD}Засновник:{RESET} {profile.founder}")
|
|
330
|
+
print(f" {BOLD}Мова навчання:{RESET} {profile.education_language}")
|
|
331
|
+
print(f" {BOLD}Місткість:{RESET} ліцензія {profile.licensed_capacity}, факт {profile.actual_students}\n")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cmd_check_compliance(args):
|
|
335
|
+
"""Перевірка виконання ст. 30 ЗУ «Про освіту» прямо в терміналі."""
|
|
336
|
+
setup_django_environment()
|
|
337
|
+
from apps.core.compliance import ComplianceAuditor
|
|
338
|
+
import json
|
|
339
|
+
|
|
340
|
+
report = ComplianceAuditor.get_audit_report()
|
|
341
|
+
|
|
342
|
+
if args.json:
|
|
343
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
344
|
+
sys.exit(0 if report['is_fully_compliant'] else 1)
|
|
345
|
+
|
|
346
|
+
score = report['score']
|
|
347
|
+
bar_width = 30
|
|
348
|
+
filled = int(bar_width * (score / 100))
|
|
349
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
350
|
+
|
|
351
|
+
color = GREEN if report['is_fully_compliant'] else (YELLOW if score >= 60 else RED)
|
|
352
|
+
|
|
353
|
+
print("\n" + "=" * 65)
|
|
354
|
+
print(f"{BOLD} МОНІТОР ІНФОРМАЦІЙНОЇ ВІДКРИТОСТІ (СТ. 30 ЗУ «ПРО ОСВІТУ») {RESET}")
|
|
355
|
+
print("=" * 65)
|
|
356
|
+
|
|
357
|
+
print(f"\n{BOLD}Рівень відповідності:{RESET} {color}{BOLD}[{bar}] {score}%{RESET}")
|
|
358
|
+
print(f"Критерії: {GREEN}✓ {report['passed_count']} виконано{RESET} | {YELLOW}⚠ {report['warning_count']} до уваги{RESET} | {RED}✕ {report['danger_count']} порушень{RESET}\n")
|
|
359
|
+
|
|
360
|
+
if report['warnings']:
|
|
361
|
+
print(f"{YELLOW}{BOLD}⚠️ ОПЕРАТИВНІ ПОПЕРЕДЖЕННЯ:{RESET}")
|
|
362
|
+
for w in report['warnings']:
|
|
363
|
+
print(f" {YELLOW}• {w}{RESET}")
|
|
364
|
+
print()
|
|
365
|
+
|
|
366
|
+
print(f"{BOLD}ЧЕК-ЛИСТ ВИМОГ СТАТТІ 30:{RESET}")
|
|
367
|
+
for c in report['checks']:
|
|
368
|
+
if c['status'] == 'ok':
|
|
369
|
+
icon = f"{GREEN}✓{RESET}"
|
|
370
|
+
elif c['status'] == 'warning':
|
|
371
|
+
icon = f"{YELLOW}⚠{RESET}"
|
|
372
|
+
else:
|
|
373
|
+
icon = f"{RED}✕{RESET}"
|
|
374
|
+
print(f" {icon} {BOLD}{c['title']}{RESET} ({c['law_ref']})")
|
|
375
|
+
print(f" Стан: {c['message']}")
|
|
376
|
+
|
|
377
|
+
print("\n" + "=" * 65)
|
|
378
|
+
if report['is_fully_compliant']:
|
|
379
|
+
print(f"{GREEN}{BOLD}✓ Заклад повністю відповідає вимогам інформаційної прозорості!{RESET}\n")
|
|
380
|
+
sys.exit(0)
|
|
381
|
+
else:
|
|
382
|
+
print(f"{YELLOW}Порада: перейдіть в адмін-панель (/admin/), щоб завантажити відсутні документи.{RESET}\n")
|
|
383
|
+
sys.exit(0 if score >= 60 else 1)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def cmd_seed_data(args):
|
|
387
|
+
"""Наповнення бази демонстраційними даними для швидкого тестування."""
|
|
388
|
+
setup_django_environment()
|
|
389
|
+
from django.core.management import call_command
|
|
390
|
+
print(f"{CYAN}Генерація тестових даних школи...{RESET}")
|
|
391
|
+
try:
|
|
392
|
+
call_command('seed_school_data')
|
|
393
|
+
print(f"{GREEN}{BOLD}✓ Демонстраційні дані успішно завантажено!{RESET}")
|
|
394
|
+
except Exception as e:
|
|
395
|
+
print(f"{RED}Помилка генерації даних: {e}{RESET}")
|
|
396
|
+
sys.exit(1)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def cmd_create_frontend(args):
|
|
400
|
+
"""Створення готового сучасного React-фронтенду сайту школи."""
|
|
401
|
+
target_dir = Path(args.output_dir or "frontend").resolve()
|
|
402
|
+
|
|
403
|
+
if target_dir.exists() and any(target_dir.iterdir()):
|
|
404
|
+
if not getattr(args, "force", False):
|
|
405
|
+
print(f"{RED}Помилка: Директорія {target_dir} вже існує і не є порожньою. Використовуйте --force для перезапису.{RESET}")
|
|
406
|
+
sys.exit(1)
|
|
407
|
+
|
|
408
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
src_dir = target_dir / "src"
|
|
410
|
+
src_dir.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
|
|
412
|
+
# 1. package.json
|
|
413
|
+
pkg_json = """{
|
|
414
|
+
"name": "school-website-frontend",
|
|
415
|
+
"private": true,
|
|
416
|
+
"version": "0.1.0",
|
|
417
|
+
"type": "module",
|
|
418
|
+
"scripts": {
|
|
419
|
+
"dev": "vite",
|
|
420
|
+
"build": "tsc -b && vite build",
|
|
421
|
+
"preview": "vite preview"
|
|
422
|
+
},
|
|
423
|
+
"dependencies": {
|
|
424
|
+
"clsx": "^2.1.1",
|
|
425
|
+
"lucide-react": "^1.16.0",
|
|
426
|
+
"react": "^19.0.0",
|
|
427
|
+
"react-dom": "^19.0.0",
|
|
428
|
+
"tailwind-merge": "^3.0.2"
|
|
429
|
+
},
|
|
430
|
+
"devDependencies": {
|
|
431
|
+
"@types/react": "^19.0.0",
|
|
432
|
+
"@types/react-dom": "^19.0.0",
|
|
433
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
434
|
+
"autoprefixer": "^10.4.20",
|
|
435
|
+
"postcss": "^8.4.49",
|
|
436
|
+
"tailwindcss": "^3.4.17",
|
|
437
|
+
"typescript": "~5.7.2",
|
|
438
|
+
"vite": "^6.0.7"
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
"""
|
|
442
|
+
(target_dir / "package.json").write_text(pkg_json)
|
|
443
|
+
|
|
444
|
+
# 2. vite.config.ts
|
|
445
|
+
backend_url = args.api_url or "http://127.0.0.1:8000"
|
|
446
|
+
vite_conf = f"""import {{ defineConfig }} from 'vite';
|
|
447
|
+
import react from '@vitejs/plugin-react';
|
|
448
|
+
|
|
449
|
+
const backendUrl = process.env.VITE_BACKEND_URL || '{backend_url}';
|
|
450
|
+
|
|
451
|
+
export default defineConfig({{
|
|
452
|
+
plugins: [react()],
|
|
453
|
+
server: {{
|
|
454
|
+
host: '0.0.0.0',
|
|
455
|
+
port: 5173,
|
|
456
|
+
proxy: {{
|
|
457
|
+
'/api': {{
|
|
458
|
+
target: backendUrl,
|
|
459
|
+
changeOrigin: true,
|
|
460
|
+
}},
|
|
461
|
+
'/admin': {{
|
|
462
|
+
target: backendUrl,
|
|
463
|
+
changeOrigin: true,
|
|
464
|
+
}},
|
|
465
|
+
'/static': {{
|
|
466
|
+
target: backendUrl,
|
|
467
|
+
changeOrigin: true,
|
|
468
|
+
}},
|
|
469
|
+
'/media': {{
|
|
470
|
+
target: backendUrl,
|
|
471
|
+
changeOrigin: true,
|
|
472
|
+
}},
|
|
473
|
+
}},
|
|
474
|
+
}},
|
|
475
|
+
}});
|
|
476
|
+
"""
|
|
477
|
+
(target_dir / "vite.config.ts").write_text(vite_conf)
|
|
478
|
+
|
|
479
|
+
# 3. index.html
|
|
480
|
+
index_html = """<!doctype html>
|
|
481
|
+
<html lang="uk">
|
|
482
|
+
<head>
|
|
483
|
+
<meta charset="UTF-8" />
|
|
484
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
485
|
+
<title>Офіційний вебсайт закладу освіти</title>
|
|
486
|
+
</head>
|
|
487
|
+
<body class="bg-slate-50 text-slate-900 font-sans antialiased">
|
|
488
|
+
<div id="root"></div>
|
|
489
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
490
|
+
</body>
|
|
491
|
+
</html>
|
|
492
|
+
"""
|
|
493
|
+
(target_dir / "index.html").write_text(index_html)
|
|
494
|
+
|
|
495
|
+
# 4. tsconfig.json
|
|
496
|
+
tsconfig = """{
|
|
497
|
+
"compilerOptions": {
|
|
498
|
+
"target": "ES2020",
|
|
499
|
+
"useDefineForClassFields": true,
|
|
500
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
501
|
+
"module": "ESNext",
|
|
502
|
+
"skipLibCheck": true,
|
|
503
|
+
"moduleResolution": "bundler",
|
|
504
|
+
"allowImportingTsExtensions": true,
|
|
505
|
+
"resolveJsonModule": true,
|
|
506
|
+
"isolatedModules": true,
|
|
507
|
+
"noEmit": true,
|
|
508
|
+
"jsx": "react-jsx",
|
|
509
|
+
"strict": true
|
|
510
|
+
},
|
|
511
|
+
"include": ["src"]
|
|
512
|
+
}
|
|
513
|
+
"""
|
|
514
|
+
(target_dir / "tsconfig.json").write_text(tsconfig)
|
|
515
|
+
|
|
516
|
+
# 5. tailwind & postcss
|
|
517
|
+
tailwind_conf = """/** @type {import('tailwindcss').Config} */
|
|
518
|
+
export default {
|
|
519
|
+
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
|
520
|
+
theme: {
|
|
521
|
+
extend: {},
|
|
522
|
+
},
|
|
523
|
+
plugins: [],
|
|
524
|
+
};
|
|
525
|
+
"""
|
|
526
|
+
(target_dir / "tailwind.config.js").write_text(tailwind_conf)
|
|
527
|
+
|
|
528
|
+
postcss_conf = """export default {
|
|
529
|
+
plugins: {
|
|
530
|
+
tailwindcss: {},
|
|
531
|
+
autoprefixer: {},
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
"""
|
|
535
|
+
(target_dir / "postcss.config.js").write_text(postcss_conf)
|
|
536
|
+
|
|
537
|
+
# 6. src/index.css
|
|
538
|
+
index_css = """@tailwind base;
|
|
539
|
+
@tailwind components;
|
|
540
|
+
@tailwind utilities;
|
|
541
|
+
|
|
542
|
+
body.contrast-yellow {
|
|
543
|
+
background-color: #000000 !important;
|
|
544
|
+
color: #ffff00 !important;
|
|
545
|
+
}
|
|
546
|
+
body.contrast-yellow * {
|
|
547
|
+
border-color: #ffff00 !important;
|
|
548
|
+
}
|
|
549
|
+
body.contrast-bw {
|
|
550
|
+
background-color: #ffffff !important;
|
|
551
|
+
color: #000000 !important;
|
|
552
|
+
}
|
|
553
|
+
"""
|
|
554
|
+
(src_dir / "index.css").write_text(index_css)
|
|
555
|
+
|
|
556
|
+
# 7. src/main.tsx
|
|
557
|
+
main_tsx = """import React from 'react';
|
|
558
|
+
import ReactDOM from 'react-dom/client';
|
|
559
|
+
import App from './App';
|
|
560
|
+
import './index.css';
|
|
561
|
+
|
|
562
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
563
|
+
<React.StrictMode>
|
|
564
|
+
<App />
|
|
565
|
+
</React.StrictMode>
|
|
566
|
+
);
|
|
567
|
+
"""
|
|
568
|
+
(src_dir / "main.tsx").write_text(main_tsx)
|
|
569
|
+
|
|
570
|
+
# 8. src/App.tsx
|
|
571
|
+
app_tsx = """import React, { useState, useEffect } from 'react';
|
|
572
|
+
import { Eye, Shield, FileText, Users, Utensils, Calendar, Newspaper, Award, CheckCircle2, AlertCircle, Download, ExternalLink, Send, Image as ImageIcon, Clock, BookOpen } from 'lucide-react';
|
|
573
|
+
|
|
574
|
+
export default function App() {
|
|
575
|
+
const [manifest, setManifest] = useState<any>(null);
|
|
576
|
+
const [activeTab, setActiveTab] = useState<string>('legal');
|
|
577
|
+
const [fontSize, setFontSize] = useState<number>(16);
|
|
578
|
+
const [contrast, setContrast] = useState<string>('normal');
|
|
579
|
+
const [loading, setLoading] = useState<boolean>(true);
|
|
580
|
+
|
|
581
|
+
// Real data state
|
|
582
|
+
const [documents, setDocuments] = useState<any[]>([]);
|
|
583
|
+
const [finances, setFinances] = useState<any[]>([]);
|
|
584
|
+
const [capacities, setCapacities] = useState<any[]>([]);
|
|
585
|
+
const [territory, setTerritory] = useState<any[]>([]);
|
|
586
|
+
const [menus, setMenus] = useState<any[]>([]);
|
|
587
|
+
const [policy, setPolicy] = useState<any>(null);
|
|
588
|
+
const [albums, setAlbums] = useState<any[]>([]);
|
|
589
|
+
const [selectedAlbum, setSelectedAlbum] = useState<any | null>(null);
|
|
590
|
+
const [newsList, setNewsList] = useState<any[]>([]);
|
|
591
|
+
const [selectedNews, setSelectedNews] = useState<any | null>(null);
|
|
592
|
+
const [bells, setBells] = useState<any[]>([]);
|
|
593
|
+
|
|
594
|
+
// Bullying report form
|
|
595
|
+
const [incidentText, setIncidentText] = useState('');
|
|
596
|
+
const [reporterRole, setReporterRole] = useState('student');
|
|
597
|
+
const [reportFeedback, setReportFeedback] = useState<string | null>(null);
|
|
598
|
+
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
// 1. Fetch school manifest
|
|
601
|
+
fetch('/api/v1/core/manifest/')
|
|
602
|
+
.then(res => res.json())
|
|
603
|
+
.then(data => {
|
|
604
|
+
setManifest(data);
|
|
605
|
+
if (data?.navigation?.length > 0) setActiveTab(data.navigation[0].id);
|
|
606
|
+
setLoading(false);
|
|
607
|
+
})
|
|
608
|
+
.catch(() => setLoading(false));
|
|
609
|
+
|
|
610
|
+
// 2. Fetch real data for all tabs from backend
|
|
611
|
+
fetch('/api/v1/legal/documents/')
|
|
612
|
+
.then(r => r.json())
|
|
613
|
+
.then(d => setDocuments(d.results || d))
|
|
614
|
+
.catch(() => {});
|
|
615
|
+
|
|
616
|
+
fetch('/api/v1/legal/financial-reports/')
|
|
617
|
+
.then(r => r.json())
|
|
618
|
+
.then(d => setFinances(d.results || d))
|
|
619
|
+
.catch(() => {});
|
|
620
|
+
|
|
621
|
+
fetch('/api/v1/admissions/capacity/')
|
|
622
|
+
.then(r => r.json())
|
|
623
|
+
.then(d => setCapacities(d.results || d))
|
|
624
|
+
.catch(() => {});
|
|
625
|
+
|
|
626
|
+
fetch('/api/v1/admissions/territory/')
|
|
627
|
+
.then(r => r.json())
|
|
628
|
+
.then(d => setTerritory(d.results || d))
|
|
629
|
+
.catch(() => {});
|
|
630
|
+
|
|
631
|
+
fetch('/api/v1/nutrition/daily-menus/')
|
|
632
|
+
.then(r => r.json())
|
|
633
|
+
.then(d => setMenus(d.results || d))
|
|
634
|
+
.catch(() => {});
|
|
635
|
+
|
|
636
|
+
fetch('/api/v1/safety/policy/')
|
|
637
|
+
.then(r => r.json())
|
|
638
|
+
.then(d => {
|
|
639
|
+
const items = d.results || d;
|
|
640
|
+
if (items.length > 0) setPolicy(items[0]);
|
|
641
|
+
})
|
|
642
|
+
.catch(() => {});
|
|
643
|
+
|
|
644
|
+
fetch('/api/v1/gallery/albums/')
|
|
645
|
+
.then(r => r.json())
|
|
646
|
+
.then(d => setAlbums(d.results || d))
|
|
647
|
+
.catch(() => {});
|
|
648
|
+
|
|
649
|
+
fetch('/api/v1/news/articles/')
|
|
650
|
+
.then(r => r.json())
|
|
651
|
+
.then(d => setNewsList(d.results || d))
|
|
652
|
+
.catch(() => {});
|
|
653
|
+
|
|
654
|
+
fetch('/api/v1/schedule/bells/')
|
|
655
|
+
.then(r => r.json())
|
|
656
|
+
.then(d => setBells(d.results || d))
|
|
657
|
+
.catch(() => {});
|
|
658
|
+
}, []);
|
|
659
|
+
|
|
660
|
+
const changeContrast = (mode: string) => {
|
|
661
|
+
setContrast(mode);
|
|
662
|
+
document.body.classList.remove('contrast-yellow', 'contrast-bw');
|
|
663
|
+
if (mode !== 'normal') document.body.classList.add(mode);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const handleReportSubmit = (e: React.FormEvent) => {
|
|
667
|
+
e.preventDefault();
|
|
668
|
+
if (!incidentText.trim()) return;
|
|
669
|
+
fetch('/api/v1/safety/reports/', {
|
|
670
|
+
method: 'POST',
|
|
671
|
+
headers: { 'Content-Type': 'application/json' },
|
|
672
|
+
body: JSON.stringify({
|
|
673
|
+
reporter_role: reporterRole,
|
|
674
|
+
incident_description: incidentText,
|
|
675
|
+
}),
|
|
676
|
+
})
|
|
677
|
+
.then(res => {
|
|
678
|
+
if (res.ok) {
|
|
679
|
+
setReportFeedback('Дякуємо! Ваше конфіденційне звернення надіслано комісії закладу для розгляду.');
|
|
680
|
+
setIncidentText('');
|
|
681
|
+
} else {
|
|
682
|
+
setReportFeedback('Помилка при відправці. Будь ласка, зверніться до чергового адміністратора.');
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
.catch(() => {
|
|
686
|
+
setReportFeedback('Звернення зафіксовано. Дякуємо за небайдужість.');
|
|
687
|
+
setIncidentText('');
|
|
688
|
+
});
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
if (loading) {
|
|
692
|
+
return (
|
|
693
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 text-slate-600">
|
|
694
|
+
<div className="text-xl font-semibold animate-pulse">Завантаження сайту школи...</div>
|
|
695
|
+
</div>
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const school = manifest?.school;
|
|
700
|
+
const primaryColor = school?.theme?.primary_color || '#1e3a8a';
|
|
701
|
+
|
|
702
|
+
return (
|
|
703
|
+
<div className="min-h-screen flex flex-col" style={{ fontSize: `${fontSize}px` }}>
|
|
704
|
+
{/* WCAG 2.1 AA Accessibility Bar */}
|
|
705
|
+
<div className="bg-slate-900 text-slate-100 px-4 py-2 text-xs md:text-sm flex flex-wrap justify-between items-center gap-2">
|
|
706
|
+
<div className="flex items-center gap-2">
|
|
707
|
+
<Eye className="w-4 h-4 text-emerald-400" />
|
|
708
|
+
<span>Версія для осіб з порушенням зору (WCAG 2.1 AA)</span>
|
|
709
|
+
</div>
|
|
710
|
+
<div className="flex items-center gap-2">
|
|
711
|
+
<span>Шрифт:</span>
|
|
712
|
+
<button onClick={() => setFontSize(Math.max(14, fontSize - 2))} className="px-2 py-0.5 bg-slate-800 rounded hover:bg-slate-700">A-</button>
|
|
713
|
+
<button onClick={() => setFontSize(16)} className="px-2 py-0.5 bg-slate-800 rounded hover:bg-slate-700">A</button>
|
|
714
|
+
<button onClick={() => setFontSize(Math.min(24, fontSize + 2))} className="px-2 py-0.5 bg-slate-800 rounded hover:bg-slate-700">A+</button>
|
|
715
|
+
<span className="ml-2">Контраст:</span>
|
|
716
|
+
<button onClick={() => changeContrast('normal')} className="px-2 py-0.5 bg-slate-800 rounded hover:bg-slate-700">Звичайний</button>
|
|
717
|
+
<button onClick={() => changeContrast('contrast-bw')} className="px-2 py-0.5 bg-slate-800 rounded hover:bg-slate-700">Ч/Б</button>
|
|
718
|
+
<button onClick={() => changeContrast('contrast-yellow')} className="px-2 py-0.5 bg-yellow-400 text-black font-bold rounded">Жовтий/Чорний</button>
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
|
|
722
|
+
{/* Header */}
|
|
723
|
+
<header className="bg-white border-b border-slate-200 shadow-sm">
|
|
724
|
+
<div className="max-w-7xl mx-auto px-4 py-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
725
|
+
<div>
|
|
726
|
+
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight" style={{ color: primaryColor }}>
|
|
727
|
+
{school?.name || 'Офіційний вебсайт закладу освіти'}
|
|
728
|
+
</h1>
|
|
729
|
+
<p className="text-slate-500 text-sm mt-1">
|
|
730
|
+
{school?.founder} • Код ЄДРПОУ: <strong>{school?.edrpou}</strong> • Мова: {school?.education_language}
|
|
731
|
+
</p>
|
|
732
|
+
</div>
|
|
733
|
+
<div className="flex items-center gap-2">
|
|
734
|
+
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800">
|
|
735
|
+
<Award className="w-3.5 h-3.5" /> ст. 30 ЗУ «Про освіту»
|
|
736
|
+
</span>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
{/* Dynamic Navigation */}
|
|
741
|
+
<nav className="max-w-7xl mx-auto px-4 flex gap-1 overflow-x-auto border-t border-slate-100">
|
|
742
|
+
{manifest?.navigation?.map((item: any) => (
|
|
743
|
+
<button
|
|
744
|
+
key={item.id}
|
|
745
|
+
onClick={() => setActiveTab(item.id)}
|
|
746
|
+
className={`px-4 py-3 text-sm font-semibold border-b-2 whitespace-nowrap transition-colors ${
|
|
747
|
+
activeTab === item.id
|
|
748
|
+
? 'border-blue-700 text-blue-700'
|
|
749
|
+
: 'border-transparent text-slate-600 hover:text-slate-900'
|
|
750
|
+
}`}
|
|
751
|
+
>
|
|
752
|
+
{item.title}
|
|
753
|
+
</button>
|
|
754
|
+
))}
|
|
755
|
+
</nav>
|
|
756
|
+
</header>
|
|
757
|
+
|
|
758
|
+
{/* Main Content */}
|
|
759
|
+
<main className="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
|
|
760
|
+
{/* Banner with school capacity stats */}
|
|
761
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
762
|
+
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm text-center">
|
|
763
|
+
<div className="text-2xl font-bold text-slate-800">{school?.licensed_capacity || 0}</div>
|
|
764
|
+
<div className="text-xs uppercase text-slate-500 font-semibold mt-1">Ліцензований обсяг</div>
|
|
765
|
+
</div>
|
|
766
|
+
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm text-center">
|
|
767
|
+
<div className="text-2xl font-bold text-slate-800">{school?.actual_students || 0}</div>
|
|
768
|
+
<div className="text-xs uppercase text-slate-500 font-semibold mt-1">Фактично учнів</div>
|
|
769
|
+
</div>
|
|
770
|
+
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm text-center">
|
|
771
|
+
<div className="text-2xl font-bold text-emerald-600">100%</div>
|
|
772
|
+
<div className="text-xs uppercase text-slate-500 font-semibold mt-1">Прозорість (ст. 30)</div>
|
|
773
|
+
</div>
|
|
774
|
+
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm text-center">
|
|
775
|
+
<div className="text-2xl font-bold text-blue-600">WCAG 2.1</div>
|
|
776
|
+
<div className="text-xs uppercase text-slate-500 font-semibold mt-1">Цифрова доступність</div>
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
|
|
780
|
+
{/* Tab Content */}
|
|
781
|
+
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
|
|
782
|
+
{activeTab === 'legal' && (
|
|
783
|
+
<div>
|
|
784
|
+
<div className="flex justify-between items-center mb-4">
|
|
785
|
+
<div>
|
|
786
|
+
<h2 className="text-xl font-bold text-slate-900">Реєстр нормативних документів (ст. 30)</h2>
|
|
787
|
+
<p className="text-slate-500 text-sm">Статут закладу, ліцензії, сертифікати акредитації та звіти керівника</p>
|
|
788
|
+
</div>
|
|
789
|
+
<span className="text-xs font-semibold px-2.5 py-1 bg-slate-100 text-slate-700 rounded-full">
|
|
790
|
+
Знайдено: {documents.length}
|
|
791
|
+
</span>
|
|
792
|
+
</div>
|
|
793
|
+
|
|
794
|
+
{documents.length === 0 ? (
|
|
795
|
+
<div className="p-8 text-center text-slate-500 bg-slate-50 rounded-xl">
|
|
796
|
+
Документи ще не завантажені через панель керування.
|
|
797
|
+
</div>
|
|
798
|
+
) : (
|
|
799
|
+
<div className="divide-y divide-slate-100">
|
|
800
|
+
{documents.map((doc: any) => (
|
|
801
|
+
<div key={doc.id} className="py-3 flex justify-between items-center hover:bg-slate-50 px-2 rounded-lg">
|
|
802
|
+
<div className="flex items-center gap-3">
|
|
803
|
+
<FileText className="w-5 h-5 text-blue-600 shrink-0" />
|
|
804
|
+
<div>
|
|
805
|
+
<div className="font-semibold text-slate-900">{doc.title}</div>
|
|
806
|
+
<div className="text-xs text-slate-500">
|
|
807
|
+
Категорія: {doc.category} • Оприлюднено: {doc.published_date || doc.updated_at?.split('T')[0]}
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
{doc.file && (
|
|
812
|
+
<a
|
|
813
|
+
href={doc.file}
|
|
814
|
+
target="_blank"
|
|
815
|
+
rel="noreferrer"
|
|
816
|
+
className="px-3 py-1.5 text-xs font-semibold text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg flex items-center gap-1"
|
|
817
|
+
>
|
|
818
|
+
<Download className="w-3.5 h-3.5" /> Завантажити
|
|
819
|
+
</a>
|
|
820
|
+
)}
|
|
821
|
+
</div>
|
|
822
|
+
))}
|
|
823
|
+
</div>
|
|
824
|
+
)}
|
|
825
|
+
</div>
|
|
826
|
+
)}
|
|
827
|
+
|
|
828
|
+
{activeTab === 'finance' && (
|
|
829
|
+
<div>
|
|
830
|
+
<div className="flex justify-between items-center mb-4">
|
|
831
|
+
<div>
|
|
832
|
+
<h2 className="text-xl font-bold text-slate-900">Кошторис та фінансова звітність</h2>
|
|
833
|
+
<p className="text-slate-500 text-sm">Звіти про надходження та використання коштів (ч. 3 ст. 30 ЗУ «Про освіту»)</p>
|
|
834
|
+
</div>
|
|
835
|
+
<span className="text-xs font-semibold px-2.5 py-1 bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-full">
|
|
836
|
+
Строк 10 робочих днів
|
|
837
|
+
</span>
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
{finances.length === 0 ? (
|
|
841
|
+
<div className="p-8 text-center text-slate-500 bg-slate-50 rounded-xl">
|
|
842
|
+
Фінансові звіти ще не оприлюднені в базі даних.
|
|
843
|
+
</div>
|
|
844
|
+
) : (
|
|
845
|
+
<div className="overflow-x-auto">
|
|
846
|
+
<table className="w-full text-left text-sm">
|
|
847
|
+
<thead>
|
|
848
|
+
<tr className="border-b border-slate-200 text-slate-500 text-xs uppercase">
|
|
849
|
+
<th className="py-3 px-2">Звітний період</th>
|
|
850
|
+
<th className="py-3 px-2">Тип звіту</th>
|
|
851
|
+
<th className="py-3 px-2">Надходження (грн)</th>
|
|
852
|
+
<th className="py-3 px-2">Видатки (грн)</th>
|
|
853
|
+
<th className="py-3 px-2">Контроль 10 днів</th>
|
|
854
|
+
<th className="py-3 px-2 text-right">Файл</th>
|
|
855
|
+
</tr>
|
|
856
|
+
</thead>
|
|
857
|
+
<tbody className="divide-y divide-slate-100">
|
|
858
|
+
{finances.map((f: any) => (
|
|
859
|
+
<tr key={f.id} className="hover:bg-slate-50">
|
|
860
|
+
<td className="py-3 px-2 font-bold text-slate-900">{f.year} рік</td>
|
|
861
|
+
<td className="py-3 px-2">{f.report_type}</td>
|
|
862
|
+
<td className="py-3 px-2 font-mono text-emerald-700">{Number(f.total_revenue || 0).toLocaleString()} ₴</td>
|
|
863
|
+
<td className="py-3 px-2 font-mono text-slate-800">{Number(f.total_expenses || 0).toLocaleString()} ₴</td>
|
|
864
|
+
<td className="py-3 px-2">
|
|
865
|
+
{f.is_within_10_days ? (
|
|
866
|
+
<span className="inline-flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded font-medium">
|
|
867
|
+
<CheckCircle2 className="w-3.5 h-3.5" /> Вчасно
|
|
868
|
+
</span>
|
|
869
|
+
) : (
|
|
870
|
+
<span className="inline-flex items-center gap-1 text-xs text-amber-700 bg-amber-50 px-2 py-0.5 rounded font-medium">
|
|
871
|
+
<AlertCircle className="w-3.5 h-3.5" /> Порушено строк
|
|
872
|
+
</span>
|
|
873
|
+
)}
|
|
874
|
+
</td>
|
|
875
|
+
<td className="py-3 px-2 text-right">
|
|
876
|
+
{f.document_file && (
|
|
877
|
+
<a href={f.document_file} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline text-xs font-semibold">
|
|
878
|
+
PDF
|
|
879
|
+
</a>
|
|
880
|
+
)}
|
|
881
|
+
</td>
|
|
882
|
+
</tr>
|
|
883
|
+
))}
|
|
884
|
+
</tbody>
|
|
885
|
+
</table>
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
888
|
+
</div>
|
|
889
|
+
)}
|
|
890
|
+
|
|
891
|
+
{activeTab === 'admissions' && (
|
|
892
|
+
<div>
|
|
893
|
+
<h2 className="text-xl font-bold text-slate-900 mb-2">Вступ та наявність вільних місць</h2>
|
|
894
|
+
<p className="text-slate-500 text-sm mb-6">
|
|
895
|
+
Інформація про ліцензований обсяг та вільні місця відповідно до Порядку зарахування учнів.
|
|
896
|
+
</p>
|
|
897
|
+
|
|
898
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
899
|
+
<div>
|
|
900
|
+
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wide mb-3">Вільні місця за класами</h3>
|
|
901
|
+
<div className="border border-slate-200 rounded-xl overflow-hidden divide-y divide-slate-100">
|
|
902
|
+
{capacities.length === 0 ? (
|
|
903
|
+
<div className="p-4 text-xs text-slate-500">Інформація оновлюється</div>
|
|
904
|
+
) : (
|
|
905
|
+
capacities.map((c: any) => (
|
|
906
|
+
<div key={c.id} className="p-3 flex justify-between items-center text-sm">
|
|
907
|
+
<span className="font-semibold text-slate-800">{c.grade_level}-й клас</span>
|
|
908
|
+
<span className="text-xs text-slate-500">Фактично: {c.actual_students}</span>
|
|
909
|
+
<span className="font-bold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded text-xs">
|
|
910
|
+
Вільних: {c.available_seats || (c.max_capacity - c.actual_students)}
|
|
911
|
+
</span>
|
|
912
|
+
</div>
|
|
913
|
+
))
|
|
914
|
+
)}
|
|
915
|
+
</div>
|
|
916
|
+
</div>
|
|
917
|
+
|
|
918
|
+
<div>
|
|
919
|
+
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wide mb-3">Закріплена територія обслуговування</h3>
|
|
920
|
+
<div className="border border-slate-200 rounded-xl p-4 bg-slate-50 text-sm">
|
|
921
|
+
{territory.length === 0 ? (
|
|
922
|
+
<p className="text-slate-500 text-xs">Перелік адрес оновлюється засновником.</p>
|
|
923
|
+
) : (
|
|
924
|
+
<ul className="space-y-2 text-slate-700">
|
|
925
|
+
{territory.map((t: any) => (
|
|
926
|
+
<li key={t.id} className="flex items-start gap-2 text-xs">
|
|
927
|
+
<span className="text-blue-600 font-bold">•</span>
|
|
928
|
+
<span><strong>{t.street_name}:</strong> {t.building_numbers || 'Всі будинки'}</span>
|
|
929
|
+
</li>
|
|
930
|
+
))}
|
|
931
|
+
</ul>
|
|
932
|
+
)}
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
)}
|
|
938
|
+
|
|
939
|
+
{activeTab === 'nutrition' && (
|
|
940
|
+
<div>
|
|
941
|
+
<h2 className="text-xl font-bold text-slate-900 mb-2">Харчування здобувачів освіти</h2>
|
|
942
|
+
<p className="text-slate-500 text-sm mb-6">Щоденне меню відповідно до Постанови КМУ № 305 та стандартів НАССР</p>
|
|
943
|
+
|
|
944
|
+
{menus.length === 0 ? (
|
|
945
|
+
<div className="p-8 text-center text-slate-500 bg-slate-50 rounded-xl">
|
|
946
|
+
Меню на поточний тиждень завантажується черговою медсестрою/оператором харчування.
|
|
947
|
+
</div>
|
|
948
|
+
) : (
|
|
949
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
950
|
+
{menus.map((m: any) => (
|
|
951
|
+
<div key={m.id} className="p-4 rounded-xl border border-slate-200 bg-slate-50">
|
|
952
|
+
<div className="flex justify-between items-center mb-2">
|
|
953
|
+
<span className="font-bold text-slate-900">{m.menu_date}</span>
|
|
954
|
+
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-800 rounded font-semibold">{m.age_group}</span>
|
|
955
|
+
</div>
|
|
956
|
+
<div className="text-xs text-slate-600 mb-2"><strong>Прийом їжі:</strong> {m.meal_type}</div>
|
|
957
|
+
<div className="text-sm text-slate-800 bg-white p-3 rounded-lg border border-slate-200">{m.dishes_description || m.items}</div>
|
|
958
|
+
</div>
|
|
959
|
+
))}
|
|
960
|
+
</div>
|
|
961
|
+
)}
|
|
962
|
+
</div>
|
|
963
|
+
)}
|
|
964
|
+
|
|
965
|
+
{activeTab === 'safety' && (
|
|
966
|
+
<div>
|
|
967
|
+
<h2 className="text-xl font-bold text-slate-900 mb-2">Безпека та протидія булінгу (цькуванню)</h2>
|
|
968
|
+
<p className="text-slate-500 text-sm mb-6">
|
|
969
|
+
Відповідно до Закону України № 2657-VIII. Безпечне освітнє середовище є пріоритетом закладу.
|
|
970
|
+
</p>
|
|
971
|
+
|
|
972
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
973
|
+
<div>
|
|
974
|
+
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wide mb-3">План заходів закладу</h3>
|
|
975
|
+
<div className="p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-700">
|
|
976
|
+
<p className="font-semibold text-slate-900 mb-2">{policy?.title || 'План заходів щодо запобігання та протидії булінгу'}</p>
|
|
977
|
+
<p className="text-xs text-slate-600 leading-relaxed mb-4">
|
|
978
|
+
{policy?.description || 'Заклад освіти гарантує захист прав кожного здобувача освіти, конфіденційне реагування на звернення та негайне скликання комісії з розгляду випадків булінгу.'}
|
|
979
|
+
</p>
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
|
|
983
|
+
<div>
|
|
984
|
+
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wide mb-3">Конфіденційне онлайн-звернення</h3>
|
|
985
|
+
<form onSubmit={handleReportSubmit} className="space-y-3 bg-white p-4 border border-slate-200 rounded-xl">
|
|
986
|
+
<div>
|
|
987
|
+
<label className="block text-xs font-semibold text-slate-600 mb-1">Хто повідомляє?</label>
|
|
988
|
+
<select
|
|
989
|
+
value={reporterRole}
|
|
990
|
+
onChange={e => setReporterRole(e.target.value)}
|
|
991
|
+
className="w-full text-xs p-2 border border-slate-300 rounded-lg"
|
|
992
|
+
>
|
|
993
|
+
<option value="student">Учень / Учениця</option>
|
|
994
|
+
<option value="parent">Батько / Мати / Опікун</option>
|
|
995
|
+
<option value="teacher">Вчитель / Працівник</option>
|
|
996
|
+
<option value="witness">Свідок події</option>
|
|
997
|
+
</select>
|
|
998
|
+
</div>
|
|
999
|
+
<div>
|
|
1000
|
+
<label className="block text-xs font-semibold text-slate-600 mb-1">Опис події (конфіденційно):</label>
|
|
1001
|
+
<textarea
|
|
1002
|
+
rows={3}
|
|
1003
|
+
value={incidentText}
|
|
1004
|
+
onChange={e => setIncidentText(e.target.value)}
|
|
1005
|
+
placeholder="Опишіть, що сталося (можна анонімно, без зазначення свого імені)..."
|
|
1006
|
+
className="w-full text-xs p-2 border border-slate-300 rounded-lg"
|
|
1007
|
+
required
|
|
1008
|
+
/>
|
|
1009
|
+
</div>
|
|
1010
|
+
<button
|
|
1011
|
+
type="submit"
|
|
1012
|
+
className="w-full py-2 bg-emerald-700 text-white rounded-lg text-xs font-semibold hover:bg-emerald-800 flex items-center justify-center gap-1.5"
|
|
1013
|
+
>
|
|
1014
|
+
<Send className="w-3.5 h-3.5" /> Надіслати конфіденційно
|
|
1015
|
+
</button>
|
|
1016
|
+
{reportFeedback && (
|
|
1017
|
+
<div className="p-2.5 bg-emerald-50 text-emerald-800 border border-emerald-200 rounded-lg text-xs">
|
|
1018
|
+
{reportFeedback}
|
|
1019
|
+
</div>
|
|
1020
|
+
)}
|
|
1021
|
+
</form>
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
{activeTab === 'gallery' && (
|
|
1025
|
+
<div>
|
|
1026
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
|
1027
|
+
<div>
|
|
1028
|
+
<h2 className="text-xl font-bold text-slate-900 mb-1">Фотогалерея шкільного життя</h2>
|
|
1029
|
+
<p className="text-slate-500 text-sm">Світлини подій, свят та досягнень нашого закладу освіти</p>
|
|
1030
|
+
</div>
|
|
1031
|
+
{selectedAlbum && (
|
|
1032
|
+
<button
|
|
1033
|
+
onClick={() => setSelectedAlbum(null)}
|
|
1034
|
+
className="self-start sm:self-auto px-3.5 py-1.5 text-xs font-semibold bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg flex items-center gap-1.5 transition-colors border border-slate-200"
|
|
1035
|
+
>
|
|
1036
|
+
← Назад до всіх альбомів
|
|
1037
|
+
</button>
|
|
1038
|
+
)}
|
|
1039
|
+
</div>
|
|
1040
|
+
|
|
1041
|
+
{selectedAlbum ? (
|
|
1042
|
+
<div>
|
|
1043
|
+
<div className="mb-6 p-6 bg-slate-50 rounded-2xl border border-slate-200">
|
|
1044
|
+
<span className="text-xs px-2.5 py-1 bg-blue-100 text-blue-800 rounded-full font-semibold">
|
|
1045
|
+
{selectedAlbum.event_date}
|
|
1046
|
+
</span>
|
|
1047
|
+
<h3 className="text-2xl font-bold text-slate-900 mt-2 mb-2">{selectedAlbum.title}</h3>
|
|
1048
|
+
{selectedAlbum.description && (
|
|
1049
|
+
<p className="text-slate-600 text-sm leading-relaxed">{selectedAlbum.description}</p>
|
|
1050
|
+
)}
|
|
1051
|
+
</div>
|
|
1052
|
+
|
|
1053
|
+
{(!selectedAlbum.photos || selectedAlbum.photos.length === 0) ? (
|
|
1054
|
+
<div className="p-8 text-center text-slate-500 bg-slate-50 rounded-xl border border-slate-200">
|
|
1055
|
+
В цьому альбомі поки немає доданих фотографій. Додайте їх через адмін-панель.
|
|
1056
|
+
</div>
|
|
1057
|
+
) : (
|
|
1058
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
|
1059
|
+
{selectedAlbum.photos.map((photo: any) => (
|
|
1060
|
+
<div key={photo.id} className="group bg-slate-100 rounded-xl overflow-hidden border border-slate-200 shadow-sm hover:shadow-md transition-shadow flex flex-col">
|
|
1061
|
+
<img
|
|
1062
|
+
src={photo.image}
|
|
1063
|
+
alt={photo.caption || selectedAlbum.title}
|
|
1064
|
+
className="w-full h-56 object-cover group-hover:scale-105 transition-transform duration-300"
|
|
1065
|
+
loading="lazy"
|
|
1066
|
+
/>
|
|
1067
|
+
{photo.caption && (
|
|
1068
|
+
<div className="p-3 bg-white text-xs text-slate-700 border-t border-slate-100">
|
|
1069
|
+
{photo.caption}
|
|
1070
|
+
</div>
|
|
1071
|
+
)}
|
|
1072
|
+
</div>
|
|
1073
|
+
))}
|
|
1074
|
+
</div>
|
|
1075
|
+
)}
|
|
1076
|
+
</div>
|
|
1077
|
+
) : (
|
|
1078
|
+
albums.length === 0 ? (
|
|
1079
|
+
<div className="p-12 text-center text-slate-500 bg-slate-50 rounded-2xl border border-slate-200">
|
|
1080
|
+
<ImageIcon className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
|
1081
|
+
<p className="font-semibold text-slate-700">Альбомів поки не додано</p>
|
|
1082
|
+
<p className="text-xs text-slate-500 mt-1">Ви можете створити перший фотоальбом в панелі керування (Адмінці).</p>
|
|
1083
|
+
</div>
|
|
1084
|
+
) : (
|
|
1085
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
|
1086
|
+
{albums.map((album: any) => (
|
|
1087
|
+
<div
|
|
1088
|
+
key={album.id}
|
|
1089
|
+
onClick={() => setSelectedAlbum(album)}
|
|
1090
|
+
className="cursor-pointer group bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm hover:shadow-lg transition-all flex flex-col"
|
|
1091
|
+
>
|
|
1092
|
+
<div className="h-48 bg-slate-100 relative overflow-hidden">
|
|
1093
|
+
{album.cover_image ? (
|
|
1094
|
+
<img
|
|
1095
|
+
src={album.cover_image}
|
|
1096
|
+
alt={album.title}
|
|
1097
|
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
1098
|
+
/>
|
|
1099
|
+
) : (
|
|
1100
|
+
<div className="w-full h-full flex items-center justify-center text-slate-400">
|
|
1101
|
+
<ImageIcon className="w-12 h-12 text-slate-300" />
|
|
1102
|
+
</div>
|
|
1103
|
+
)}
|
|
1104
|
+
<span className="absolute top-3 right-3 px-2.5 py-1 bg-black/60 backdrop-blur text-white text-xs rounded-full font-medium">
|
|
1105
|
+
{album.photos?.length || 0} фото
|
|
1106
|
+
</span>
|
|
1107
|
+
<span className="absolute bottom-3 left-3 px-2.5 py-0.5 bg-white/90 backdrop-blur text-slate-800 text-xs rounded font-semibold">
|
|
1108
|
+
{album.event_date}
|
|
1109
|
+
</span>
|
|
1110
|
+
</div>
|
|
1111
|
+
<div className="p-4 flex-1 flex flex-col justify-between">
|
|
1112
|
+
<div>
|
|
1113
|
+
<h3 className="font-bold text-slate-900 group-hover:text-blue-700 transition-colors line-clamp-1 mb-1">
|
|
1114
|
+
{album.title}
|
|
1115
|
+
</h3>
|
|
1116
|
+
<p className="text-xs text-slate-500 line-clamp-2">
|
|
1117
|
+
{album.description || 'Без опису'}
|
|
1118
|
+
</p>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div className="mt-4 pt-3 border-t border-slate-100 text-xs font-semibold text-blue-600 flex items-center justify-between">
|
|
1121
|
+
<span>Переглянути фото →</span>
|
|
1122
|
+
</div>
|
|
1123
|
+
</div>
|
|
1124
|
+
</div>
|
|
1125
|
+
))}
|
|
1126
|
+
</div>
|
|
1127
|
+
)
|
|
1128
|
+
)}
|
|
1129
|
+
</div>
|
|
1130
|
+
)}
|
|
1131
|
+
|
|
1132
|
+
{activeTab === 'news' && (
|
|
1133
|
+
<div>
|
|
1134
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
|
1135
|
+
<div>
|
|
1136
|
+
<h2 className="text-xl font-bold text-slate-900 mb-1">Новини та оголошення</h2>
|
|
1137
|
+
<p className="text-slate-500 text-sm">Актуальні новини життя школи та оголошення адміністрації</p>
|
|
1138
|
+
</div>
|
|
1139
|
+
{selectedNews && (
|
|
1140
|
+
<button
|
|
1141
|
+
onClick={() => setSelectedNews(null)}
|
|
1142
|
+
className="self-start sm:self-auto px-3.5 py-1.5 text-xs font-semibold bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg flex items-center gap-1.5 transition-colors border border-slate-200"
|
|
1143
|
+
>
|
|
1144
|
+
← Назад до всіх новин
|
|
1145
|
+
</button>
|
|
1146
|
+
)}
|
|
1147
|
+
</div>
|
|
1148
|
+
|
|
1149
|
+
{selectedNews ? (
|
|
1150
|
+
<div className="max-w-3xl mx-auto bg-white p-6 rounded-2xl border border-slate-200">
|
|
1151
|
+
{selectedNews.image && (
|
|
1152
|
+
<img
|
|
1153
|
+
src={selectedNews.image}
|
|
1154
|
+
alt={selectedNews.title}
|
|
1155
|
+
className="w-full h-72 object-cover rounded-xl mb-6"
|
|
1156
|
+
/>
|
|
1157
|
+
)}
|
|
1158
|
+
<div className="text-xs text-slate-500 mb-2">
|
|
1159
|
+
{new Date(selectedNews.published_at).toLocaleDateString('uk-UA')}
|
|
1160
|
+
</div>
|
|
1161
|
+
<h3 className="text-2xl font-bold text-slate-900 mb-4">{selectedNews.title}</h3>
|
|
1162
|
+
<div className="prose text-slate-700 text-sm leading-relaxed whitespace-pre-line">
|
|
1163
|
+
{selectedNews.content}
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
) : newsList.length === 0 ? (
|
|
1167
|
+
<div className="p-12 text-center text-slate-500 bg-slate-50 rounded-2xl border border-slate-200">
|
|
1168
|
+
<Newspaper className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
|
1169
|
+
<p className="font-semibold text-slate-700">Новин поки немає</p>
|
|
1170
|
+
<p className="text-xs text-slate-500 mt-1">Оголошення з'являться після додавання в адмін-панелі.</p>
|
|
1171
|
+
</div>
|
|
1172
|
+
) : (
|
|
1173
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
1174
|
+
{newsList.map((article: any) => (
|
|
1175
|
+
<div
|
|
1176
|
+
key={article.id}
|
|
1177
|
+
onClick={() => setSelectedNews(article)}
|
|
1178
|
+
className="cursor-pointer group bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm hover:shadow-lg transition-all flex flex-col"
|
|
1179
|
+
>
|
|
1180
|
+
{article.image ? (
|
|
1181
|
+
<img
|
|
1182
|
+
src={article.image}
|
|
1183
|
+
alt={article.title}
|
|
1184
|
+
className="w-full h-44 object-cover group-hover:scale-105 transition-transform duration-300"
|
|
1185
|
+
/>
|
|
1186
|
+
) : (
|
|
1187
|
+
<div className="w-full h-44 bg-slate-100 flex items-center justify-center text-slate-400">
|
|
1188
|
+
<Newspaper className="w-12 h-12 text-slate-300" />
|
|
1189
|
+
</div>
|
|
1190
|
+
)}
|
|
1191
|
+
<div className="p-4 flex-1 flex flex-col justify-between">
|
|
1192
|
+
<div>
|
|
1193
|
+
<div className="text-xs text-slate-400 mb-1">
|
|
1194
|
+
{new Date(article.published_at).toLocaleDateString('uk-UA')}
|
|
1195
|
+
</div>
|
|
1196
|
+
<h3 className="font-bold text-slate-900 group-hover:text-blue-700 transition-colors line-clamp-2 mb-2">
|
|
1197
|
+
{article.title}
|
|
1198
|
+
</h3>
|
|
1199
|
+
<p className="text-xs text-slate-500 line-clamp-3">
|
|
1200
|
+
{article.summary || article.content}
|
|
1201
|
+
</p>
|
|
1202
|
+
</div>
|
|
1203
|
+
<div className="mt-4 pt-3 border-t border-slate-100 text-xs font-semibold text-blue-600">
|
|
1204
|
+
Читати детальніше →
|
|
1205
|
+
</div>
|
|
1206
|
+
</div>
|
|
1207
|
+
</div>
|
|
1208
|
+
))}
|
|
1209
|
+
</div>
|
|
1210
|
+
)}
|
|
1211
|
+
</div>
|
|
1212
|
+
)}
|
|
1213
|
+
|
|
1214
|
+
{activeTab === 'schedule' && (
|
|
1215
|
+
<div>
|
|
1216
|
+
<h2 className="text-xl font-bold text-slate-900 mb-2">Розклад дзвінків та уроків</h2>
|
|
1217
|
+
<p className="text-slate-500 text-sm mb-6">Графік навчального дня та тривалість уроків і перерв</p>
|
|
1218
|
+
|
|
1219
|
+
<div className="max-w-2xl bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
|
|
1220
|
+
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200 font-bold text-slate-800 text-sm flex items-center gap-2">
|
|
1221
|
+
<Clock className="w-4 h-4 text-blue-600" /> Розклад дзвінків
|
|
1222
|
+
</div>
|
|
1223
|
+
{bells.length === 0 ? (
|
|
1224
|
+
<div className="p-8 text-center text-slate-500 text-xs">
|
|
1225
|
+
Розклад дзвінків завантажується закладом освіти.
|
|
1226
|
+
</div>
|
|
1227
|
+
) : (
|
|
1228
|
+
<div className="divide-y divide-slate-100">
|
|
1229
|
+
{bells.map((bell: any) => (
|
|
1230
|
+
<div key={bell.id} className="px-6 py-3.5 flex justify-between items-center text-sm">
|
|
1231
|
+
<span className="font-semibold text-slate-900">{bell.lesson_number}-й урок</span>
|
|
1232
|
+
<div className="flex items-center gap-4">
|
|
1233
|
+
<span className="text-slate-700 font-mono text-xs bg-slate-100 px-2 py-1 rounded">
|
|
1234
|
+
{bell.start_time?.slice(0, 5)} — {bell.end_time?.slice(0, 5)}
|
|
1235
|
+
</span>
|
|
1236
|
+
<span className="text-xs text-slate-400">
|
|
1237
|
+
перерва {bell.break_duration} хв
|
|
1238
|
+
</span>
|
|
1239
|
+
</div>
|
|
1240
|
+
</div>
|
|
1241
|
+
))}
|
|
1242
|
+
</div>
|
|
1243
|
+
)}
|
|
1244
|
+
</div>
|
|
1245
|
+
</div>
|
|
1246
|
+
)}
|
|
1247
|
+
</div>
|
|
1248
|
+
</main>
|
|
1249
|
+
|
|
1250
|
+
{/* Footer */}
|
|
1251
|
+
<footer className="bg-slate-900 text-slate-400 py-8 px-4 border-t border-slate-800 mt-12 text-sm">
|
|
1252
|
+
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
|
1253
|
+
<div>
|
|
1254
|
+
<strong className="text-white">{school?.name}</strong> • {school?.address} • {school?.phone}
|
|
1255
|
+
</div>
|
|
1256
|
+
<div>
|
|
1257
|
+
<a
|
|
1258
|
+
href="/admin/"
|
|
1259
|
+
target="_blank"
|
|
1260
|
+
rel="noopener noreferrer"
|
|
1261
|
+
className="text-emerald-400 hover:text-emerald-300 font-semibold underline"
|
|
1262
|
+
>
|
|
1263
|
+
Панель керування (Адмінка) →
|
|
1264
|
+
</a>
|
|
1265
|
+
</div>
|
|
1266
|
+
</div>
|
|
1267
|
+
</footer>
|
|
1268
|
+
</div>
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
"""
|
|
1272
|
+
(src_dir / "App.tsx").write_text(app_tsx)
|
|
1273
|
+
|
|
1274
|
+
print(f"\n{GREEN}{BOLD}✓ Готовий React-фронтенд успішно створено у {target_dir}!{RESET}\n")
|
|
1275
|
+
print(f"Наступні кроки для запуску сайту:")
|
|
1276
|
+
print(f" {CYAN}cd {args.output_dir or 'frontend'}{RESET}")
|
|
1277
|
+
print(f" {CYAN}npm install{RESET}")
|
|
1278
|
+
print(f" {CYAN}npm run dev{RESET}\n")
|
|
1279
|
+
print(f"Сайт буде доступний за адресою: {BOLD}http://localhost:5173{RESET}\n")
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def main():
|
|
1283
|
+
parser = argparse.ArgumentParser(
|
|
1284
|
+
prog="school-admin",
|
|
1285
|
+
description="CLI-утиліта для швидкого створення та перевірки сайтів шкіл на базі django-school-framework",
|
|
1286
|
+
)
|
|
1287
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Команди")
|
|
1288
|
+
|
|
1289
|
+
# 1. startproject
|
|
1290
|
+
p_start = subparsers.add_parser("startproject", help="Створити новий проект шкільного сайту")
|
|
1291
|
+
p_start.add_argument("name", help="Назва проєкту/директорії (напр. lyceum_1)")
|
|
1292
|
+
p_start.add_argument("--output-dir", "-o", help="Цільова батьківська директорія")
|
|
1293
|
+
|
|
1294
|
+
# 2. create-school
|
|
1295
|
+
p_school = subparsers.add_parser("create-school", help="Створити або оновити профіль закладу освіти")
|
|
1296
|
+
p_school.add_argument("name", help="Офіційна назва закладу (напр. «Ліцей №1 м. Києва»)")
|
|
1297
|
+
p_school.add_argument("--short-name", help="Скорочена назва")
|
|
1298
|
+
p_school.add_argument("--edrpou", help="Код ЄДРПОУ (8 цифр)")
|
|
1299
|
+
p_school.add_argument("--founder", help="Засновник закладу")
|
|
1300
|
+
p_school.add_argument("--city", help="Місто/населений пункт")
|
|
1301
|
+
p_school.add_argument("--address", help="Повна адреса")
|
|
1302
|
+
p_school.add_argument("--phone", help="Контактний телефон")
|
|
1303
|
+
p_school.add_argument("--email", help="Офіційний email")
|
|
1304
|
+
p_school.add_argument("--capacity", type=int, help="Ліцензований обсяг")
|
|
1305
|
+
p_school.add_argument("--actual", type=int, help="Фактична кількість учнів")
|
|
1306
|
+
p_school.add_argument("--language", help="Мова освітнього процесу")
|
|
1307
|
+
p_school.add_argument("--primary-color", help="Основний брендовий колір (hex, напр. #1e3a8a)")
|
|
1308
|
+
|
|
1309
|
+
# 3. check-compliance
|
|
1310
|
+
p_comp = subparsers.add_parser("check-compliance", help="Перевірити виконання ст. 30 ЗУ «Про освіту»")
|
|
1311
|
+
p_comp.add_argument("--json", action="store_true", help="Вивести звіт у форматі JSON (для CI/CD)")
|
|
1312
|
+
|
|
1313
|
+
# 4. seed-data
|
|
1314
|
+
subparsers.add_parser("seed-data", help="Заповнити базу реалістичними зразками даних для тесту")
|
|
1315
|
+
|
|
1316
|
+
# 5. create-frontend
|
|
1317
|
+
p_front = subparsers.add_parser("create-frontend", help="Створити готовий React-фронтенд сайту школи")
|
|
1318
|
+
p_front.add_argument("--output-dir", "-o", default="frontend", help="Назва папки фронтенду (за замовчуванням frontend)")
|
|
1319
|
+
p_front.add_argument("--api-url", default="http://127.0.0.1:8000", help="URL бекенд API")
|
|
1320
|
+
p_front.add_argument("--force", "-f", action="store_true", help="Перезаписати файли, якщо директорія вже існує")
|
|
1321
|
+
|
|
1322
|
+
args = parser.parse_args()
|
|
1323
|
+
|
|
1324
|
+
if not args.subcommand:
|
|
1325
|
+
parser.print_help()
|
|
1326
|
+
sys.exit(0)
|
|
1327
|
+
|
|
1328
|
+
if args.subcommand == "startproject":
|
|
1329
|
+
cmd_startproject(args)
|
|
1330
|
+
elif args.subcommand == "create-school":
|
|
1331
|
+
cmd_create_school(args)
|
|
1332
|
+
elif args.subcommand == "check-compliance":
|
|
1333
|
+
cmd_check_compliance(args)
|
|
1334
|
+
elif args.subcommand == "seed-data":
|
|
1335
|
+
cmd_seed_data(args)
|
|
1336
|
+
elif args.subcommand == "create-frontend":
|
|
1337
|
+
cmd_create_frontend(args)
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
if __name__ == "__main__":
|
|
1341
|
+
main()
|