Nova aplikacija "Raspored časova" (raspored)

Više djece, smjene se računaju iz "sidra" (jedan datum + smjena tad; mijenja se
sedmično 1,2,1,2...). Odgovara: koje časove ima, kad počinje/završava, koja smjena
— za danas/sutra/prekosutra/juče/naziv dana/konkretan datum.
- store.py: djeca, sidro->smjena za bilo koji datum, raspored po smjeni/danu,
  uparivanje imena (padeži), CSV import (kolone dijete..predmet), grid uređivanje.
- manifest.py: alat raspored_upit(dijete, kada, sta=casovi|pocetak|kraj|smjena);
  bosanska gramatika (N časa/časova, akuzativ dana). Sve lokalno (inline).
- admin.py + templates: pregled djece (+smjena danas), dodaj/obriši dijete,
  postavi razred+sidro, grid rasporeda (uredi/dodaj/obriši po času), CSV upload.
- Registrovano u load_apps() i ADMIN_APPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
idriz
2026-09-13 08:20:25 +02:00
parent 47cb617e9e
commit a2d7adc8c7
8 changed files with 676 additions and 2 deletions

View File

@@ -20,11 +20,12 @@ from flask import (
# Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from idriz.apps.obroci import admin as _obroci_admin # noqa: E402
from idriz.apps.raspored import admin as _raspored_admin # noqa: E402
from idriz.apps.superheroji import admin as _superheroji_admin # noqa: E402
from idriz.apps.vaktija import admin as _vaktija_admin # noqa: E402
# Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija).
ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin]
ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin, _raspored_admin]
# --- Konfiguracija -----------------------------------------------------------

View File

@@ -10,7 +10,8 @@ from .base import AppContext, AppSpec, AppTool # noqa: F401
def load_apps() -> list[AppSpec]:
from .bojanka import manifest as bojanka
from .obroci import manifest as obroci
from .raspored import manifest as raspored
from .sazetak import manifest as sazetak
from .superheroji import manifest as superheroji
from .vaktija import manifest as vaktija
return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC]
return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC, raspored.SPEC]

View File

View File

@@ -0,0 +1,98 @@
"""Admin za 'Raspored časova': djeca, smjena (sidro), grid rasporeda, CSV import.
Flask Blueprint, bez openai zavisnosti.
"""
import datetime as dt
from flask import Blueprint, flash, redirect, render_template, request, url_for
from . import store
BP = Blueprint("raspored", __name__, template_folder="templates", url_prefix="/raspored")
NAV = {"endpoint": "raspored.index", "page": "raspored", "title": "Raspored časova", "icon": "📚"}
BLANK_ROWS = 8 # praznih redova za dodavanje novih časova
@BP.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
action = request.form.get("action")
if action == "add":
if store.add_child(request.form.get("ime", ""), request.form.get("razred", "")):
flash(f"Dodano dijete: {request.form.get('ime').strip()}.")
else:
flash("Dijete već postoji ili ime nije uneseno.")
elif action == "delete":
store.delete_child(request.form.get("ime", ""))
flash(f"Obrisano dijete: {request.form.get('ime')}.")
elif action == "csv":
text = ""
f = request.files.get("csvfile")
if f and f.filename:
text = f.read().decode("utf-8-sig", errors="replace")
elif request.form.get("csvtext", "").strip():
text = request.form["csvtext"]
if not text.strip():
flash("Nije priložen CSV (fajl ili tekst).")
else:
res = store.import_csv(text)
if "error" in res:
flash(f"CSV greška: {res['error']}")
else:
flash(f"Uvezeno: {res['casova']} časova za {', '.join(res['djeca'])}. "
f"(Ne zaboravi postaviti smjenu/datum za svako dijete.)")
return redirect(url_for("raspored.index"))
today = dt.date.today()
djeca = []
for name in store.list_children():
ch = store.get_child(name)
sm = store.shift_for(name, today)
djeca.append({
"ime": name,
"razred": ch.get("razred", ""),
"sidro": ch.get("sidro"),
"smjena_danas": sm,
"broj_casova": sum(len(v) for s in ch["raspored"].values() for v in s.values()),
})
return render_template("raspored.html", djeca=djeca, danas=today.isoformat(), active_page="raspored")
@BP.route("/dijete/<ime>", methods=["GET", "POST"])
def dijete(ime):
ch = store.get_child(ime)
if not ch:
flash("Nepoznato dijete.")
return redirect(url_for("raspored.index"))
if request.method == "POST":
action = request.form.get("action")
if action == "osnovno":
store.set_osnovno(ime, request.form.get("razred", ""),
request.form.get("sidro_datum", ""), request.form.get("sidro_smjena", ""))
flash("Osnovni podaci sačuvani.")
elif action == "raspored":
n = int(request.form.get("nrows", "0"))
rows = []
for i in range(n):
if request.form.get(f"r{i}_del"):
continue
rows.append({
"smjena": request.form.get(f"r{i}_smjena", ""),
"dan": request.form.get(f"r{i}_dan", ""),
"br": request.form.get(f"r{i}_br", ""),
"pocetak": request.form.get(f"r{i}_pocetak", ""),
"kraj": request.form.get(f"r{i}_kraj", ""),
"predmet": request.form.get(f"r{i}_predmet", ""),
})
saved = store.set_child_schedule(ime, rows)
flash(f"Raspored sačuvan: {saved} časova.")
return redirect(url_for("raspored.dijete", ime=ime))
grid = store.child_rows(ime) + [{} for _ in range(BLANK_ROWS)]
return render_template(
"raspored_dijete.html",
ime=ime, dijete=ch, grid=grid, nrows=len(grid),
dani=store.DANI, active_page="raspored",
)

View File

@@ -0,0 +1,135 @@
"""Mini-app 'Raspored časova': odgovara o rasporedu djece za dati dan.
Podržava više djece; smjena se računa iz sidra (v. store.py). Sva logika je lokalna
(bez API-ja), pa je alat inline. Tačan datum se oslanja na sistemski sat (NTP).
"""
import datetime as dt
import logging
import re
from ..base import AppContext, AppSpec, AppTool
from . import store
log = logging.getLogger("idriz.raspored")
INSTRUCTIONS = """RASPORED ČASOVA (alat: raspored_upit): odgovaraš o školskom rasporedu djece (koje časove imaju, kad počinju/završavaju, koja je smjena). Podržano je više djece.
- Kad dijete/roditelj pita o rasporedu ("koje časove Ahmed ima danas?", "kad Hatidža danas počinje/završava školu?", "koja je smjena za Ahmeda sutra?"), pozovi raspored_upit.
- Argumenti: `dijete` (ime kako je izgovoreno), `kada` (danas/sutra/prekosutra/juce/prekjuce, naziv dana, ili datum kao "15.09." ili "2026-09-15"), i `sta`: "casovi" (koji časovi), "pocetak" (kad mora biti u školi), "kraj" (kad završava), "smjena".
- Ako nije rečeno šta tačno, koristi sta="casovi".
- Alat vraća gotovu rečenicu na bosanskom — izgovori je prirodno. Ne izmišljaj časove ni vremena.
- Ako alat kaže da dijete nije poznato ili da smjena nije postavljena, prenesi to."""
SCHEMA = {
"type": "function",
"name": "raspored_upit",
"description": "Raspored časova za jedno dijete i jedan dan (koji časovi, početak, kraj, smjena).",
"parameters": {
"type": "object",
"properties": {
"dijete": {"type": "string", "description": "Ime djeteta kako je izgovoreno."},
"kada": {"type": "string", "description": "danas, sutra, prekosutra, juce, prekjuce, naziv dana, ili datum (npr. '15.09.' ili '2026-09-15'). Prazno = danas."},
"sta": {"type": "string", "enum": ["casovi", "pocetak", "kraj", "smjena"],
"description": "Šta se traži. Podrazumijevano 'casovi'."},
},
"required": ["dijete"],
},
}
_REL_FRAZA = {0: "danas", 1: "sutra", 2: "prekosutra", -1: "jučer", -2: "prekjučer"}
# akuzativ za "u <dan>" (srijeda/subota/nedjelja mijenjaju oblik)
_DAN_AKUZATIV = ["ponedjeljak", "utorak", "srijedu", "četvrtak", "petak", "subotu", "nedjelju"]
def _cas_rijec(n: int) -> str:
if 11 <= n % 100 <= 14:
return "časova"
d = n % 10
return "čas" if d == 1 else ("časa" if 2 <= d <= 4 else "časova")
def _date_fraza(d: dt.date) -> str:
return f"u {_DAN_AKUZATIV[d.weekday()]}, {d.day}.{d.month}."
def _parse_kada(s: str) -> tuple[dt.date, str]:
today = dt.date.today()
s = (s or "").strip()
if not s:
return today, "danas"
sn = store.norm(s)
rel = {"danas": 0, "sutra": 1, "prekosutra": 2, "juce": -1, "jucer": -1, "prekjuce": -2, "prekjucer": -2}
if sn in rel:
d = today + dt.timedelta(days=rel[sn])
return d, _REL_FRAZA[rel[sn]]
try:
d = dt.date.fromisoformat(s)
return d, _date_fraza(d)
except ValueError:
pass
m = re.match(r"^(\d{1,2})\.\s*(\d{1,2})\.?\s*(\d{2,4})?", s)
if m:
dd, mm = int(m.group(1)), int(m.group(2))
yy = m.group(3)
year = int(yy) if yy else today.year
if year < 100:
year += 2000
try:
d = dt.date(year, mm, dd)
return d, _date_fraza(d)
except ValueError:
pass
wd = {"ponedjeljak": 0, "utorak": 1, "srijeda": 2, "cetvrtak": 3, "petak": 4, "subota": 5, "nedjelja": 6, "nedelja": 6}
if sn in wd:
monday = today - dt.timedelta(days=today.weekday())
d = monday + dt.timedelta(days=wd[sn])
return d, _date_fraza(d)
return today, "danas"
async def raspored_upit(ctx: AppContext, args: dict) -> str:
ime_izgovoreno = (args.get("dijete") or "").strip()
name = store.match_child(ime_izgovoreno)
if not name:
djeca = store.list_children()
lista = ", ".join(djeca) if djeca else "nijedno dijete nije dodano"
return (f"Ne znam za dijete \"{ime_izgovoreno}\". Poznata djeca: {lista}. "
f"Reci to i pitaj za jedno od njih (ili dodaj dijete u admin panelu).")
date, fraza = _parse_kada(args.get("kada", ""))
sta = (args.get("sta") or "casovi").strip().lower()
res = store.lessons_for(name, date)
st = res["status"]
if st == "vikend":
return f'Reci djetetu: "{name} nema nastave {fraza} — vikend je."'
if st == "nema_sidra":
return (f"Za {name} nije postavljena smjena u admin panelu (treba jedan datum i koja je smjena tad bila), "
f"pa ne mogu izračunati raspored. Reci to.")
smjena = res["smjena"]
if st == "nema_casova":
return f'Reci djetetu: "{name} {fraza} nema časova ({smjena}. smjena)."'
casovi = res["casovi"]
prvi, zadnji = casovi[0], casovi[-1]
if sta == "smjena":
return f'Reci djetetu: "{name} je {fraza} u {smjena}. smjeni."'
if sta == "pocetak":
return f'Reci djetetu: "{name} {fraza} treba biti u školi u {prvi["pocetak"]} ({smjena}. smjena)."'
if sta == "kraj":
return f'Reci djetetu: "{name} {fraza} završava školu u {zadnji["kraj"]} ({smjena}. smjena)."'
# casovi — nabroji
stavke = "; ".join(f'{c["br"]}. {c["predmet"]} u {c["pocetak"]}' for c in casovi)
recenica = (f"{name} {fraza} ima {len(casovi)} {_cas_rijec(len(casovi))} u {smjena}. smjeni "
f"(od {prvi['pocetak']} do {zadnji['kraj']}): {stavke}.")
return f'Reci djetetu prirodno na bosanskom, možeš i sažeti ako je dugačko: "{recenica}"'
SPEC = AppSpec(
name="raspored",
title="Raspored časova",
instructions=INSTRUCTIONS,
greeting="reći raspored časova za djecu (kad počinju/završavaju, koji časovi)",
tools=[AppTool(SCHEMA, raspored_upit, "inline")],
)

View File

@@ -0,0 +1,282 @@
"""Skladište za app 'Raspored časova': djeca, smjene i raspored.
Smjena se mijenja sedmično (1,2,1,2...). U bazi se za dijete čuva "sidro":
jedan datum + koja je smjena tad bila. Iz toga se računa smjena za bilo koji datum.
Bez openai/requests (uvozi ga i admin).
"""
import csv
import datetime as dt
import io
import json
import os
import re
import tempfile
import threading
import unicodedata
from pathlib import Path
RASPORED_PATH = Path(os.environ.get("IDRIZ_RASPORED_PATH", str(Path.home() / ".local/share/idriz/raspored.json")))
DANI = ["Ponedjeljak", "Utorak", "Srijeda", "Četvrtak", "Petak"] # radni dani (redoslijed)
DAN_PO_WEEKDAY = {0: "Ponedjeljak", 1: "Utorak", 2: "Srijeda", 3: "Četvrtak", 4: "Petak"}
DAN_PUNI = ["ponedjeljak", "utorak", "srijeda", "četvrtak", "petak", "subota", "nedjelja"]
_lock = threading.Lock()
def norm(s: str) -> str:
s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "", s.lower())
_DAN_NORM = {norm(d): d for d in DANI}
def canonical_dan(s: str) -> str | None:
return _DAN_NORM.get(norm(s))
# ---------- učitavanje/snimanje ----------
def _load() -> dict:
try:
data = json.loads(RASPORED_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
data.setdefault("djeca", {})
return data
def _save(data: dict) -> None:
with _lock:
RASPORED_PATH.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(RASPORED_PATH.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, RASPORED_PATH)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def _empty_raspored() -> dict:
return {"1": {d: [] for d in DANI}, "2": {d: [] for d in DANI}}
# ---------- djeca ----------
def list_children() -> list[str]:
return sorted(_load()["djeca"].keys(), key=lambda s: s.lower())
def get_child(name: str) -> dict | None:
return _load()["djeca"].get(name)
def add_child(name: str, razred: str = "") -> bool:
name = (name or "").strip()
if not name:
return False
data = _load()
if name in data["djeca"]:
return False
data["djeca"][name] = {"razred": razred.strip(), "sidro": None, "raspored": _empty_raspored()}
_save(data)
return True
def delete_child(name: str) -> None:
data = _load()
if name in data["djeca"]:
del data["djeca"][name]
_save(data)
def set_osnovno(name: str, razred: str, sidro_datum: str, sidro_smjena) -> None:
"""Postavi razred i sidro (datum + smjena tad). Prazan datum/smjena -> obriši sidro."""
data = _load()
ch = data["djeca"].get(name)
if not ch:
return
ch["razred"] = (razred or "").strip()
try:
d = dt.date.fromisoformat((sidro_datum or "").strip())
sm = int(sidro_smjena)
ch["sidro"] = {"datum": d.isoformat(), "smjena": sm} if sm in (1, 2) else None
except (ValueError, TypeError):
ch["sidro"] = None
_save(data)
def match_child(query: str) -> str | None:
"""Toleranto uparivanje imena (padeži/vokativ): najduži zajednički prefiks."""
q = norm(query)
if not q:
return None
best, best_len = None, 0
for name in _load()["djeca"]:
n = norm(name)
cp = 0
for a, b in zip(q, n):
if a == b:
cp += 1
else:
break
if n == q:
return name
if cp > best_len:
best_len, best = cp, name
# zahtijevaj bar 4 zajednička znaka (ili cijelo kraće ime)
if best and best_len >= min(4, len(best)):
return best
return None
# ---------- raspored (uređivanje) ----------
def _clean_time(t: str) -> str:
t = (t or "").strip()
m = re.match(r"^(\d{1,2}):(\d{2})$", t)
if m:
return f"{int(m.group(1)):02d}:{m.group(2)}"
return t
def set_child_schedule(name: str, rows: list[dict]) -> int:
"""Zamijeni cijeli raspored djeteta listom časova {smjena,dan,br,pocetak,kraj,predmet}."""
data = _load()
ch = data["djeca"].get(name)
if not ch:
return 0
rasp = _empty_raspored()
n = 0
for r in rows:
try:
sm = int(r.get("smjena"))
except (ValueError, TypeError):
continue
dan = canonical_dan(r.get("dan", ""))
predmet = (r.get("predmet") or "").strip()
if sm not in (1, 2) or not dan or not predmet:
continue
try:
br = int(r.get("br"))
except (ValueError, TypeError):
br = len(rasp[str(sm)][dan]) + 1
rasp[str(sm)][dan].append({
"br": br,
"pocetak": _clean_time(r.get("pocetak", "")),
"kraj": _clean_time(r.get("kraj", "")),
"predmet": predmet,
})
n += 1
for sm in ("1", "2"):
for dan in DANI:
rasp[sm][dan].sort(key=lambda c: c["br"])
ch["raspored"] = rasp
_save(data)
return n
def child_rows(name: str) -> list[dict]:
"""Svi časovi djeteta kao ravna lista (za admin grid), sortirano smjena/dan/br."""
ch = get_child(name)
if not ch:
return []
out = []
for sm in ("1", "2"):
for dan in DANI:
for c in ch["raspored"].get(sm, {}).get(dan, []):
out.append({"smjena": int(sm), "dan": dan, "br": c["br"],
"pocetak": c["pocetak"], "kraj": c["kraj"], "predmet": c["predmet"]})
return out
# ---------- CSV import ----------
CSV_HEADER = ["dijete", "razred", "smjena", "dan", "redni_broj_casa", "pocetak", "kraj", "predmet"]
def import_csv(text: str) -> dict:
"""Uvezi CSV (kolone CSV_HEADER). Zamijeni raspored za svako dijete iz fajla.
Zadrži postojeće sidro. Vrati sažetak ili {'error':...}."""
try:
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
return {"error": "Prazan CSV."}
headers = [h.strip() for h in reader.fieldnames]
missing = [h for h in CSV_HEADER if h not in headers]
if missing:
return {"error": f"Nedostaju kolone: {', '.join(missing)}."}
po_djetetu: dict[str, dict] = {}
for raw in reader:
row = {k.strip(): (v or "").strip() for k, v in raw.items() if k}
ime = row.get("dijete", "").strip()
if not ime:
continue
entry = po_djetetu.setdefault(ime, {"razred": row.get("razred", ""), "rows": []})
if row.get("razred"):
entry["razred"] = row["razred"].strip()
entry["rows"].append({
"smjena": row.get("smjena"), "dan": row.get("dan"),
"br": row.get("redni_broj_casa"), "pocetak": row.get("pocetak"),
"kraj": row.get("kraj"), "predmet": row.get("predmet"),
})
except Exception as e: # noqa: BLE001
return {"error": f"Greška pri čitanju CSV-a: {type(e).__name__}"}
if not po_djetetu:
return {"error": "Nema redova u CSV-u."}
data = _load()
total = 0
for ime, entry in po_djetetu.items():
ch = data["djeca"].get(ime)
if not ch:
ch = {"razred": "", "sidro": None, "raspored": _empty_raspored()}
data["djeca"][ime] = ch
if entry["razred"]:
ch["razred"] = entry["razred"]
_save(data) # snimi novododanu djecu prije set_child_schedule (koji ponovo učitava)
for ime, entry in po_djetetu.items():
total += set_child_schedule(ime, entry["rows"])
return {"djeca": sorted(po_djetetu.keys()), "casova": total}
# ---------- smjena i raspored za datum ----------
def shift_for(name: str, date: dt.date) -> int | None:
ch = get_child(name)
if not ch or not ch.get("sidro"):
return None
try:
anchor = dt.date.fromisoformat(ch["sidro"]["datum"])
s = int(ch["sidro"]["smjena"])
except (ValueError, TypeError, KeyError):
return None
mon_a = anchor - dt.timedelta(days=anchor.weekday())
mon_d = date - dt.timedelta(days=date.weekday())
weeks = (mon_d - mon_a).days // 7
if weeks % 2 == 0:
return s
return 2 if s == 1 else 1
def lessons_for(name: str, date: dt.date) -> dict:
"""Vrati {'status', 'smjena', 'dan', 'casovi'} za dijete i datum.
status: 'ok' | 'nepoznato_dijete' | 'vikend' | 'nema_sidra' | 'nema_casova'."""
ch = get_child(name)
if not ch:
return {"status": "nepoznato_dijete"}
if date.weekday() >= 5:
return {"status": "vikend"}
smjena = shift_for(name, date)
if smjena is None:
return {"status": "nema_sidra"}
dan = DAN_PO_WEEKDAY[date.weekday()]
casovi = list(ch["raspored"].get(str(smjena), {}).get(dan, []))
casovi.sort(key=lambda c: c["br"])
if not casovi:
return {"status": "nema_casova", "smjena": smjena, "dan": dan}
return {"status": "ok", "smjena": smjena, "dan": dan, "casovi": casovi}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Raspored časova — Idriz Admin{% endblock %}
{% block extra_css %}
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; margin-bottom: 20px; max-width: 720px; }
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel p.hint { color: var(--muted); font-size: 12.5px; margin: 0 0 12px; }
table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; max-width: 720px; margin-bottom: 20px; }
th, td { text-align: left; padding: 10px 14px; font-size: 14px; }
th { background: #fafbfc; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--line); }
tr:not(:last-child) td { border-bottom: 1px solid var(--line); }
a.edit { color: var(--accent); text-decoration: none; font-weight: 600; }
input[type=text], textarea { width: 100%; padding: 9px 12px; font-size: 14px; border: 1px solid var(--line); border-radius: 8px; font-family: inherit; }
textarea { min-height: 120px; }
.row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 140px; }
label { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
button { padding: 9px 18px; font-size: 14px; font-weight: 600; color: #fff; background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
button:hover { filter: brightness(1.05); }
button.small { padding: 5px 10px; font-size: 12.5px; }
button.danger { background: var(--bad); }
.badge { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12.5px; font-weight:600; }
.b1 { background:#e6f0ff; color:#2f6df6; } .b2 { background:#fdf1dd; color:#c9820a; }
.muted { color: var(--muted); }
.empty { color: var(--muted); font-style: italic; }
{% endblock %}
{% block content %}
<h1>Raspored časova</h1>
<p class="sub">Djeca, smjene i raspored. Smjena se mijenja sedmično — postavi je jednim datumom po djetetu.</p>
{% if djeca %}
<table>
<thead><tr><th>Dijete</th><th>Razred</th><th>Smjena danas</th><th>Časova</th><th></th><th></th></tr></thead>
<tbody>
{% for d in djeca %}
<tr>
<td><strong>{{ d.ime }}</strong></td>
<td>{{ d.razred or "—" }}</td>
<td>
{% if d.smjena_danas %}<span class="badge b{{ d.smjena_danas }}">{{ d.smjena_danas }}. smjena</span>
{% else %}<span class="muted">nema smjene</span>{% endif %}
</td>
<td>{{ d.broj_casova }}</td>
<td><a class="edit" href="{{ url_for('raspored.dijete', ime=d.ime) }}">Uredi ›</a></td>
<td>
<form method="post" onsubmit="return confirm('Obrisati {{ d.ime }}?');" style="margin:0;">
<input type="hidden" name="action" value="delete"><input type="hidden" name="ime" value="{{ d.ime }}">
<button type="submit" class="small danger">Obriši</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Još nema dodane djece.</p>
{% endif %}
<div class="panel">
<h2>Dodaj dijete</h2>
<form method="post" class="row">
<input type="hidden" name="action" value="add">
<div><label>Ime</label><input type="text" name="ime" placeholder="npr. Ahmed"></div>
<div><label>Razred</label><input type="text" name="razred" placeholder="npr. VI-4"></div>
<div style="flex:0;"><button type="submit">Dodaj</button></div>
</form>
</div>
<div class="panel">
<h2>Uvoz CSV-a</h2>
<p class="hint">Kolone: dijete, razred, smjena, dan, redni_broj_casa, pocetak, kraj, predmet. Zamjenjuje raspored za djecu iz fajla; smjenu/datum postavi ručno po uvozu.</p>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="action" value="csv">
<input type="file" name="csvfile" accept=".csv,text/csv" style="margin-bottom:10px;">
<p class="hint">ili zalijepi CSV ovdje:</p>
<textarea name="csvtext" placeholder="dijete,razred,smjena,dan,redni_broj_casa,pocetak,kraj,predmet&#10;..."></textarea>
<div style="margin-top:12px;"><button type="submit">Uvezi</button></div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}{{ ime }} — Raspored — Idriz Admin{% endblock %}
{% block extra_css %}
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; margin-bottom: 20px; max-width: 900px; }
.panel h2 { margin: 0 0 12px; font-size: 15px; }
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.row > div { flex: 0 0 auto; }
label { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
input, select { padding: 8px 10px; font-size: 14px; border: 1px solid var(--line); border-radius: 7px; font-family: inherit; background:#fff; color:var(--ink); }
input[type=text], input[type=date] { min-width: 150px; }
button { padding: 9px 18px; font-size: 14px; font-weight: 600; color: #fff; background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
button:hover { filter: brightness(1.05); }
a.back { color: var(--muted); text-decoration: none; font-size: 13px; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th, td { padding: 5px 7px; text-align: left; }
th { color: var(--muted); font-weight: 600; font-size: 12px; border-bottom: 1px solid var(--line); }
td input[type=text], td input[type=time] { min-width: 0; width: 100%; }
.c-sm { width: 70px; } .c-dan { width: 130px; } .c-br { width: 55px; } .c-vr { width: 105px; } .c-del { width: 40px; text-align:center; }
.predmet input { width: 100%; }
.scroll { overflow-x: auto; }
{% endblock %}
{% block content %}
<a class="back" href="{{ url_for('raspored.index') }}">‹ Nazad na djecu</a>
<h1>{{ ime }}</h1>
<p class="sub">Uređivanje osnovnih podataka i rasporeda.</p>
<div class="panel">
<h2>Osnovno i smjena</h2>
<form method="post" class="row">
<input type="hidden" name="action" value="osnovno">
<div><label>Razred</label><input type="text" name="razred" value="{{ dijete.razred }}"></div>
<div><label>Datum (referentni)</label><input type="date" name="sidro_datum" value="{{ dijete.sidro.datum if dijete.sidro else '' }}"></div>
<div><label>Smjena tog datuma</label>
<select name="sidro_smjena">
<option value="1" {{ 'selected' if dijete.sidro and dijete.sidro.smjena == 1 else '' }}>1. smjena</option>
<option value="2" {{ 'selected' if dijete.sidro and dijete.sidro.smjena == 2 else '' }}>2. smjena</option>
</select>
</div>
<div><label>&nbsp;</label><button type="submit">Sačuvaj</button></div>
</form>
<p class="sub" style="margin-top:10px;">Iz tog jednog datuma se računa smjena za svaki dan (mijenja se sedmično).</p>
</div>
<div class="panel">
<h2>Raspored (uredi, dodaj, obriši)</h2>
<form method="post">
<input type="hidden" name="action" value="raspored">
<input type="hidden" name="nrows" value="{{ nrows }}">
<div class="scroll">
<table>
<thead><tr><th class="c-sm">Smjena</th><th class="c-dan">Dan</th><th class="c-br">Br.</th><th class="c-vr">Početak</th><th class="c-vr">Kraj</th><th class="predmet">Predmet</th><th class="c-del">Obriši</th></tr></thead>
<tbody>
{% for r in grid %}
{% set i = loop.index0 %}
<tr>
<td class="c-sm"><select name="r{{ i }}_smjena">
<option value="1" {{ 'selected' if r.smjena == 1 else '' }}>1</option>
<option value="2" {{ 'selected' if r.smjena == 2 else '' }}>2</option>
</select></td>
<td class="c-dan"><select name="r{{ i }}_dan">
<option value="" {{ 'selected' if not r.dan else '' }}>—</option>
{% for d in dani %}<option value="{{ d }}" {{ 'selected' if r.dan == d else '' }}>{{ d }}</option>{% endfor %}
</select></td>
<td class="c-br"><input type="text" name="r{{ i }}_br" value="{{ r.br if r.br is defined else '' }}"></td>
<td class="c-vr"><input type="time" name="r{{ i }}_pocetak" value="{{ r.pocetak if r.pocetak is defined else '' }}"></td>
<td class="c-vr"><input type="time" name="r{{ i }}_kraj" value="{{ r.kraj if r.kraj is defined else '' }}"></td>
<td class="predmet"><input type="text" name="r{{ i }}_predmet" value="{{ r.predmet if r.predmet is defined else '' }}"></td>
<td class="c-del"><input type="checkbox" name="r{{ i }}_del"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="margin-top:14px;"><button type="submit">Sačuvaj raspored</button></div>
</form>
<p class="sub" style="margin-top:10px;">Prazni redovi na dnu su za dodavanje novih časova. Za brisanje označi "Obriši" pa Sačuvaj.</p>
</div>
{% endblock %}