Sprint 8: Events + Hundesitting
Events: - Backend events.py: CRUD, Typen (ausstellung/training/treffen/markt/wettkampf/sonstiges) Haversine-Filter, Monats-Gruppierung in der Liste - Frontend events.js: Liste/Karte-Toggle, Typ-Filter-Chips, farbige Marker, Detail-Modal, Erstellen/Bearbeiten-Formular mit GPS-Button Hundesitting: - Backend sitting.py: Sitter-Profile (create/update/me), Anfragen (send/accept/decline/cancel), Inbox für Sitter, Haversine-Sortierung, Service-Filter - Frontend sitting.js: 3 Tabs (Suchen/Profil/Anfragen), Sitter-Karten mit Distanz, Detail-Modal + Anfrage-Formular, Profil-Verwaltung DB: events, sitters, sitting_requests Tabellen hinzugefügt SW-Cache: by-v21 → by-v22
This commit is contained in:
parent
ec17dfb029
commit
5f8fd3bd51
9 changed files with 1680 additions and 2 deletions
|
|
@ -249,6 +249,52 @@ def init_db():
|
|||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- EVENTS (Hundeveranstaltungen)
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
titel TEXT NOT NULL,
|
||||
datum TEXT NOT NULL, -- YYYY-MM-DD
|
||||
uhrzeit TEXT,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
ort_name TEXT,
|
||||
typ TEXT NOT NULL DEFAULT 'sonstiges',
|
||||
beschreibung TEXT,
|
||||
link TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'aktiv',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_datum ON events(datum ASC);
|
||||
|
||||
-- SITTING — Sitter-Profile
|
||||
CREATE TABLE IF NOT EXISTS sitters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
beschreibung TEXT,
|
||||
preis_pro_tag REAL DEFAULT 0,
|
||||
max_hunde INTEGER DEFAULT 1,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
radius_km INTEGER DEFAULT 20,
|
||||
services TEXT DEFAULT '[]', -- JSON: ['tagesbetreuung','uebernachtung','gassi','hausbesuch']
|
||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- SITTING — Anfragen
|
||||
CREATE TABLE IF NOT EXISTS sitting_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id), -- Anfragender
|
||||
sitter_id INTEGER NOT NULL REFERENCES sitters(id),
|
||||
dog_ids TEXT DEFAULT '[]', -- JSON Array
|
||||
von TEXT NOT NULL, -- YYYY-MM-DD
|
||||
bis TEXT NOT NULL,
|
||||
nachricht TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offen', -- offen|angenommen|abgelehnt|abgebrochen
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- TIERÄRZTE (user-level, nie löschen — Historien-Erhalt bei Umzug)
|
||||
CREATE TABLE IF NOT EXISTS tieraerzte (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ from routes.tieraerzte import router as tieraerzte_router
|
|||
from routes.places import router as places_router
|
||||
from routes.routen import router as routen_router
|
||||
from routes.walks import router as walks_router
|
||||
from routes.events import router as events_router
|
||||
from routes.sitting import router as sitting_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -72,6 +74,8 @@ app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzt
|
|||
app.include_router(places_router, prefix="/api/places", tags=["Orte"])
|
||||
app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
|
||||
app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"])
|
||||
app.include_router(events_router, prefix="/api/events", tags=["Events"])
|
||||
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
153
backend/routes/events.py
Normal file
153
backend/routes/events.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""BAN YARO — Events (Hundeveranstaltungen)"""
|
||||
|
||||
import math
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'}
|
||||
|
||||
|
||||
def _haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6_371_000
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class EventCreate(BaseModel):
|
||||
titel: str
|
||||
datum: str # YYYY-MM-DD
|
||||
uhrzeit: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
ort_name: Optional[str] = None
|
||||
typ: str = 'sonstiges'
|
||||
beschreibung: Optional[str] = None
|
||||
link: Optional[str] = None
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
datum: Optional[str] = None
|
||||
uhrzeit: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
ort_name: Optional[str] = None
|
||||
typ: Optional[str] = None
|
||||
beschreibung: Optional[str] = None
|
||||
link: Optional[str] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/events
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("")
|
||||
async def list_events(
|
||||
lat: Optional[float] = None,
|
||||
lon: Optional[float] = None,
|
||||
radius: int = 50000,
|
||||
typ: Optional[str] = None,
|
||||
alle: bool = False,
|
||||
):
|
||||
today = date.today().isoformat()
|
||||
with db() as conn:
|
||||
q = "SELECT e.*, u.name AS veranstalter_name FROM events e LEFT JOIN users u ON u.id = e.user_id WHERE e.status = 'aktiv'"
|
||||
if not alle:
|
||||
q += f" AND e.datum >= '{today}'"
|
||||
if typ and typ in TYPEN:
|
||||
q += f" AND e.typ = '{typ}'"
|
||||
q += " ORDER BY e.datum ASC, e.uhrzeit ASC"
|
||||
rows = conn.execute(q).fetchall()
|
||||
|
||||
result = [dict(r) for r in rows]
|
||||
if lat is not None and lon is not None:
|
||||
result = [r for r in result
|
||||
if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius]
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/events
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("", status_code=201)
|
||||
async def create_event(data: EventCreate, user=Depends(get_current_user)):
|
||||
if data.typ not in TYPEN:
|
||||
raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(TYPEN)}")
|
||||
with db() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (user['id'], data.titel, data.datum, data.uhrzeit,
|
||||
data.lat, data.lon, data.ort_name,
|
||||
data.typ, data.beschreibung, data.link))
|
||||
row = conn.execute(
|
||||
"SELECT e.*, u.name AS veranstalter_name FROM events e "
|
||||
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
|
||||
(cur.lastrowid,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/events/{id}
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/{event_id}")
|
||||
async def get_event(event_id: int):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT e.*, u.name AS veranstalter_name FROM events e "
|
||||
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
|
||||
(event_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Event nicht gefunden.")
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/events/{id}
|
||||
# ------------------------------------------------------------------
|
||||
@router.patch("/{event_id}")
|
||||
async def update_event(event_id: int, data: EventUpdate, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
|
||||
if not ev:
|
||||
raise HTTPException(404, "Event nicht gefunden.")
|
||||
if ev['user_id'] != user['id']:
|
||||
raise HTTPException(403, "Nur der Veranstalter kann das Event bearbeiten.")
|
||||
updates = data.model_dump(exclude_none=True)
|
||||
if updates:
|
||||
if 'typ' in updates and updates['typ'] not in TYPEN:
|
||||
raise HTTPException(400, "Ungültiger Typ.")
|
||||
cols = ', '.join(f"{k} = ?" for k in updates)
|
||||
conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id])
|
||||
row = conn.execute(
|
||||
"SELECT e.*, u.name AS veranstalter_name FROM events e "
|
||||
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
|
||||
(event_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/events/{id}
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete("/{event_id}", status_code=204)
|
||||
async def delete_event(event_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
|
||||
if not ev:
|
||||
raise HTTPException(404, "Event nicht gefunden.")
|
||||
if ev['user_id'] != user['id']:
|
||||
raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.")
|
||||
conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,))
|
||||
259
backend/routes/sitting.py
Normal file
259
backend/routes/sitting.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""BAN YARO — Hundesitting"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'}
|
||||
|
||||
|
||||
def _haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6_371_000
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class SitterCreate(BaseModel):
|
||||
beschreibung: Optional[str] = None
|
||||
preis_pro_tag: float = 0
|
||||
max_hunde: int = 1
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
radius_km: int = 20
|
||||
services: List[str] = []
|
||||
|
||||
class SitterUpdate(BaseModel):
|
||||
beschreibung: Optional[str] = None
|
||||
preis_pro_tag: Optional[float] = None
|
||||
max_hunde: Optional[int] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
radius_km: Optional[int] = None
|
||||
services: Optional[List[str]] = None
|
||||
aktiv: Optional[int] = None
|
||||
|
||||
class RequestCreate(BaseModel):
|
||||
sitter_id: int
|
||||
dog_ids: List[int] = []
|
||||
von: str # YYYY-MM-DD
|
||||
bis: str
|
||||
nachricht: Optional[str] = None
|
||||
|
||||
class RequestUpdate(BaseModel):
|
||||
status: str # angenommen | abgelehnt | abgebrochen
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/sitting — Sitter-Liste
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("")
|
||||
async def list_sitters(
|
||||
lat: Optional[float] = None,
|
||||
lon: Optional[float] = None,
|
||||
radius: int = 30000,
|
||||
service: Optional[str] = None,
|
||||
):
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT s.*, u.name AS sitter_name
|
||||
FROM sitters s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.aktiv = 1
|
||||
""").fetchall()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d['services'] = json.loads(d['services'] or '[]')
|
||||
if service and service not in d['services']:
|
||||
continue
|
||||
if lat is not None and lon is not None and d['lat'] and d['lon']:
|
||||
dist = _haversine(lat, lon, d['lat'], d['lon'])
|
||||
if dist > radius:
|
||||
continue
|
||||
d['distanz_m'] = round(dist)
|
||||
result.append(d)
|
||||
|
||||
if lat is not None:
|
||||
result.sort(key=lambda x: x.get('distanz_m', 999999))
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/sitting/me — eigenes Sitter-Profil
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/me")
|
||||
async def get_my_sitter_profile(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sitters WHERE user_id = ?", (user['id'],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d['services'] = json.loads(d['services'] or '[]')
|
||||
return d
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/sitting — Sitter-Profil erstellen oder aktualisieren
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("", status_code=201)
|
||||
async def create_sitter(data: SitterCreate, user=Depends(get_current_user)):
|
||||
services_json = json.dumps([s for s in data.services if s in SERVICES])
|
||||
with db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM sitters WHERE user_id = ?", (user['id'],)
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(409, "Du hast bereits ein Sitter-Profil. Nutze PATCH zum Aktualisieren.")
|
||||
cur = conn.execute("""
|
||||
INSERT INTO sitters (user_id, beschreibung, preis_pro_tag, max_hunde, lat, lon, radius_km, services)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (user['id'], data.beschreibung, data.preis_pro_tag,
|
||||
data.max_hunde, data.lat, data.lon, data.radius_km, services_json))
|
||||
row = conn.execute("SELECT * FROM sitters WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
d = dict(row)
|
||||
d['services'] = json.loads(d['services'] or '[]')
|
||||
return d
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/sitting/me — eigenes Profil bearbeiten
|
||||
# ------------------------------------------------------------------
|
||||
@router.patch("/me")
|
||||
async def update_sitter(data: SitterUpdate, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
sitter = conn.execute(
|
||||
"SELECT * FROM sitters WHERE user_id = ?", (user['id'],)
|
||||
).fetchone()
|
||||
if not sitter:
|
||||
raise HTTPException(404, "Kein Sitter-Profil gefunden.")
|
||||
updates = data.model_dump(exclude_none=True)
|
||||
if 'services' in updates:
|
||||
updates['services'] = json.dumps([s for s in updates['services'] if s in SERVICES])
|
||||
if updates:
|
||||
cols = ', '.join(f"{k} = ?" for k in updates)
|
||||
conn.execute(f"UPDATE sitters SET {cols} WHERE user_id = ?",
|
||||
[*updates.values(), user['id']])
|
||||
row = conn.execute("SELECT * FROM sitters WHERE user_id = ?", (user['id'],)).fetchone()
|
||||
d = dict(row)
|
||||
d['services'] = json.loads(d['services'] or '[]')
|
||||
return d
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/sitting/requests — meine Anfragen (als Anfragender)
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/requests")
|
||||
async def list_my_requests(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT sr.*, u.name AS sitter_name
|
||||
FROM sitting_requests sr
|
||||
JOIN sitters s ON s.id = sr.sitter_id
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE sr.user_id = ?
|
||||
ORDER BY sr.created_at DESC
|
||||
""", (user['id'],)).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d['dog_ids'] = json.loads(d['dog_ids'] or '[]')
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/sitting/inbox — eingehende Anfragen (als Sitter)
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/inbox")
|
||||
async def sitter_inbox(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
sitter = conn.execute(
|
||||
"SELECT id FROM sitters WHERE user_id = ?", (user['id'],)
|
||||
).fetchone()
|
||||
if not sitter:
|
||||
return []
|
||||
rows = conn.execute("""
|
||||
SELECT sr.*, u.name AS anfragender_name
|
||||
FROM sitting_requests sr
|
||||
JOIN users u ON u.id = sr.user_id
|
||||
WHERE sr.sitter_id = ?
|
||||
ORDER BY sr.created_at DESC
|
||||
""", (sitter['id'],)).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d['dog_ids'] = json.loads(d['dog_ids'] or '[]')
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/sitting/requests — Anfrage senden
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/requests", status_code=201)
|
||||
async def create_request(data: RequestCreate, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
sitter = conn.execute(
|
||||
"SELECT * FROM sitters WHERE id = ?", (data.sitter_id,)
|
||||
).fetchone()
|
||||
if not sitter or not sitter['aktiv']:
|
||||
raise HTTPException(404, "Sitter nicht gefunden oder nicht aktiv.")
|
||||
if sitter['user_id'] == user['id']:
|
||||
raise HTTPException(400, "Du kannst keine Anfrage an dich selbst schicken.")
|
||||
cur = conn.execute("""
|
||||
INSERT INTO sitting_requests (user_id, sitter_id, dog_ids, von, bis, nachricht)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (user['id'], data.sitter_id, json.dumps(data.dog_ids),
|
||||
data.von, data.bis, data.nachricht))
|
||||
row = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
d = dict(row)
|
||||
d['dog_ids'] = json.loads(d['dog_ids'] or '[]')
|
||||
return d
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/sitting/requests/{id} — Status ändern
|
||||
# ------------------------------------------------------------------
|
||||
@router.patch("/requests/{req_id}")
|
||||
async def update_request(req_id: int, data: RequestUpdate, user=Depends(get_current_user)):
|
||||
allowed = {'angenommen', 'abgelehnt', 'abgebrochen'}
|
||||
if data.status not in allowed:
|
||||
raise HTTPException(400, f"Status muss einer von {allowed} sein.")
|
||||
with db() as conn:
|
||||
req = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (req_id,)).fetchone()
|
||||
if not req:
|
||||
raise HTTPException(404, "Anfrage nicht gefunden.")
|
||||
# Anfragender kann nur abbrechen; Sitter kann annehmen/ablehnen
|
||||
sitter = conn.execute(
|
||||
"SELECT * FROM sitters WHERE id = ?", (req['sitter_id'],)
|
||||
).fetchone()
|
||||
is_requester = req['user_id'] == user['id']
|
||||
is_sitter = sitter['user_id'] == user['id']
|
||||
if not is_requester and not is_sitter:
|
||||
raise HTTPException(403, "Kein Zugriff.")
|
||||
if is_requester and data.status != 'abgebrochen':
|
||||
raise HTTPException(403, "Du kannst die Anfrage nur abbrechen.")
|
||||
if is_sitter and data.status == 'abgebrochen':
|
||||
raise HTTPException(403, "Als Sitter kannst du nur annehmen oder ablehnen.")
|
||||
conn.execute(
|
||||
"UPDATE sitting_requests SET status = ? WHERE id = ?", (data.status, req_id)
|
||||
)
|
||||
row = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (req_id,)).fetchone()
|
||||
d = dict(row)
|
||||
d['dog_ids'] = json.loads(d['dog_ids'] or '[]')
|
||||
return d
|
||||
|
|
@ -1740,3 +1740,285 @@ textarea.form-control {
|
|||
.walks-participant:last-child { border-bottom: none; }
|
||||
.walks-participant-name { font-weight: var(--weight-semibold); }
|
||||
.walks-participant-dogs { color: var(--c-text-secondary); }
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
EVENTS (events.js)
|
||||
------------------------------------------------------------ */
|
||||
.events-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.events-view-toggle {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
background: var(--c-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
border: 1px solid var(--c-border);
|
||||
}
|
||||
.events-view-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.events-view-btn.active {
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.events-filter-bar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
overflow-x: auto;
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
flex-shrink: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.events-filter-bar::-webkit-scrollbar { display: none; }
|
||||
.events-filter-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1.5px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.events-filter-btn.active {
|
||||
background: var(--c-primary);
|
||||
color: #fff;
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
.events-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.events-map {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.events-month-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-text-secondary);
|
||||
padding: var(--space-2) 0 var(--space-1);
|
||||
}
|
||||
.events-card {
|
||||
background: var(--c-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--c-border);
|
||||
border-left: 3px solid var(--c-primary);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr auto;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.events-card:hover { box-shadow: var(--shadow-md); }
|
||||
.events-date-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--c-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
.events-date-badge .day { font-size: var(--text-xs); color: var(--c-text-secondary); }
|
||||
.events-date-badge .num { font-size: 1.5rem; font-weight: var(--weight-bold); line-height: 1.1; }
|
||||
.events-date-badge .month { font-size: var(--text-xs); color: var(--c-text-secondary); }
|
||||
.events-card-body { min-width: 0; }
|
||||
.events-card-title {
|
||||
font-weight: var(--weight-semibold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.events-card-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.events-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.events-detail-row {
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.events-detail-desc {
|
||||
background: var(--c-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
margin: var(--space-3) 0;
|
||||
color: var(--c-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
SITTING (sitting.js)
|
||||
------------------------------------------------------------ */
|
||||
.sitting-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sitting-tab {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.sitting-tab.active {
|
||||
color: var(--c-primary);
|
||||
border-bottom-color: var(--c-primary);
|
||||
}
|
||||
.sitting-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.sitting-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.sitting-card {
|
||||
background: var(--c-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--c-border);
|
||||
padding: var(--space-4);
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr auto;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.sitting-card:hover { box-shadow: var(--shadow-md); }
|
||||
.sitting-card-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--c-bg);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.sitting-card-name { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); }
|
||||
.sitting-card-dist { font-size: var(--text-xs); color: var(--c-text-muted); margin-bottom: var(--space-1); }
|
||||
.sitting-card-desc { font-size: var(--text-sm); color: var(--c-text-secondary); margin-bottom: var(--space-2); }
|
||||
.sitting-price { font-weight: var(--weight-bold); color: var(--c-primary); }
|
||||
.sitting-dogs { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: var(--space-1); }
|
||||
.sitting-services {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.sit-service-badge {
|
||||
background: var(--c-primary-light, #dbeafe);
|
||||
color: var(--c-primary);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.sitting-empty-profil {
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.sitting-my-profil { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.sitting-profil-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sitting-profil-status {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.sitting-profil-status.active { background: #d1fae5; color: #065f46; }
|
||||
.sitting-profil-status.inactive { background: #f3f4f6; color: #6b7280; }
|
||||
.sitting-profil-facts {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.sitting-profil-fact { font-size: var(--text-sm); color: var(--c-text-secondary); }
|
||||
.sitting-section-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.sitting-request-card {
|
||||
background: var(--c-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--c-border);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-2);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.sitting-req-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.sitting-req-name { font-weight: var(--weight-semibold); }
|
||||
.sitting-req-status { font-size: var(--text-sm); font-weight: var(--weight-semibold); text-transform: capitalize; }
|
||||
.sitting-req-dates { font-size: var(--text-sm); color: var(--c-text-secondary); }
|
||||
.sitting-req-msg { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); }
|
||||
.sitting-req-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.sitting-detail-avatar {
|
||||
font-size: 4rem;
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,39 @@ const API = (() => {
|
|||
leave(id) { return del(`/walks/${id}/join`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// EVENTS
|
||||
// ----------------------------------------------------------
|
||||
const events = {
|
||||
list(params = {}) {
|
||||
const q = new URLSearchParams(params).toString();
|
||||
return get(`/events${q ? '?' + q : ''}`);
|
||||
},
|
||||
get(id) { return get(`/events/${id}`); },
|
||||
create(data) { return post('/events', data); },
|
||||
update(id, data) { return patch(`/events/${id}`, data); },
|
||||
delete(id) { return del(`/events/${id}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SITTING
|
||||
// ----------------------------------------------------------
|
||||
const sitting = {
|
||||
list(lat = null, lon = null, radius = 30000, service = null) {
|
||||
const p = new URLSearchParams({ radius });
|
||||
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
|
||||
if (service) { p.set('service', service); }
|
||||
return get(`/sitting?${p}`);
|
||||
},
|
||||
me() { return get('/sitting/me'); },
|
||||
create(data) { return post('/sitting', data); },
|
||||
updateMe(data) { return patch('/sitting/me', data); },
|
||||
requests() { return get('/sitting/requests'); },
|
||||
inbox() { return get('/sitting/inbox'); },
|
||||
sendRequest(data){ return post('/sitting/requests', data); },
|
||||
updateRequest(id, status) { return patch(`/sitting/requests/${id}`, { status }); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// WETTER
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -284,7 +317,7 @@ const API = (() => {
|
|||
return {
|
||||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
places, routes, walks, weather, push,
|
||||
places, routes, walks, events, sitting, weather, push,
|
||||
subscribeToPush, getLocation,
|
||||
APIError,
|
||||
};
|
||||
|
|
|
|||
419
backend/static/js/pages/events.js
Normal file
419
backend/static/js/pages/events.js
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Events (Hundeveranstaltungen)
|
||||
Liste/Karte · Filter · Erstellen/Bearbeiten
|
||||
============================================================ */
|
||||
|
||||
const Page_events = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Konstanten
|
||||
// ----------------------------------------------------------
|
||||
const TYPEN = [
|
||||
{ id: 'alle', label: 'Alle', icon: '🎪' },
|
||||
{ id: 'ausstellung', label: 'Ausstellung', icon: '🏆' },
|
||||
{ id: 'training', label: 'Training', icon: '🎓' },
|
||||
{ id: 'treffen', label: 'Treffen', icon: '🐕' },
|
||||
{ id: 'markt', label: 'Markt', icon: '🛍️' },
|
||||
{ id: 'wettkampf', label: 'Wettkampf', icon: '🥇' },
|
||||
{ id: 'sonstiges', label: 'Sonstiges', icon: '📌' },
|
||||
];
|
||||
|
||||
const TYP_COLOR = {
|
||||
ausstellung: '#8b5cf6',
|
||||
training: '#3b82f6',
|
||||
treffen: '#10b981',
|
||||
markt: '#f59e0b',
|
||||
wettkampf: '#ef4444',
|
||||
sonstiges: '#6b7280',
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// State
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _state = null;
|
||||
let _events = [];
|
||||
let _filter = 'alle';
|
||||
let _view = 'liste'; // liste | karte
|
||||
let _map = null;
|
||||
let _markers = [];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// init
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_state = appState;
|
||||
_render();
|
||||
await _load();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await _load();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Render Grundstruktur
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="events-toolbar">
|
||||
<div class="events-view-toggle">
|
||||
<button class="events-view-btn active" data-ev-view="liste">☰ Liste</button>
|
||||
<button class="events-view-btn" data-ev-view="karte">🗺️ Karte</button>
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">+ Event</button>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="events-filter-bar" id="ev-filter-bar">
|
||||
${TYPEN.map(t => `
|
||||
<button class="events-filter-btn ${t.id === 'alle' ? 'active' : ''}" data-ev-typ="${t.id}">
|
||||
${t.icon} ${t.label}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="events-list" id="ev-list"></div>
|
||||
<div class="events-map" id="ev-map" style="display:none"></div>
|
||||
`;
|
||||
|
||||
_container.addEventListener('click', _onClick);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Daten laden
|
||||
// ----------------------------------------------------------
|
||||
async function _load() {
|
||||
const listEl = document.getElementById('ev-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = UI.skeleton(3);
|
||||
try {
|
||||
_events = await API.events.list();
|
||||
_renderList();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Liste rendern
|
||||
// ----------------------------------------------------------
|
||||
function _renderList() {
|
||||
const listEl = document.getElementById('ev-list');
|
||||
if (!listEl) return;
|
||||
|
||||
const filtered = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Monats-Gruppierung
|
||||
const groups = {};
|
||||
for (const ev of filtered) {
|
||||
const d = new Date(ev.datum + 'T00:00:00');
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
const label = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
if (!groups[key]) groups[key] = { label, items: [] };
|
||||
groups[key].items.push(ev);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const key of Object.keys(groups).sort()) {
|
||||
const g = groups[key];
|
||||
html += `<div class="events-month-label">${g.label}</div>`;
|
||||
for (const ev of g.items) {
|
||||
html += _cardHTML(ev);
|
||||
}
|
||||
}
|
||||
listEl.innerHTML = html;
|
||||
|
||||
if (_view === 'karte') _renderMap(filtered);
|
||||
}
|
||||
|
||||
function _cardHTML(ev) {
|
||||
const d = new Date(ev.datum + 'T00:00:00');
|
||||
const dow = d.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||
const day = d.getDate();
|
||||
const mon = d.toLocaleDateString('de-DE', { month: 'short' });
|
||||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||
const isOwn = _state.user?.id === ev.user_id;
|
||||
return `
|
||||
<div class="events-card" data-ev-id="${ev.id}" style="border-left-color:${color}">
|
||||
<div class="events-date-badge">
|
||||
<span class="day">${dow}</span>
|
||||
<span class="num">${day}</span>
|
||||
<span class="month">${mon}</span>
|
||||
</div>
|
||||
<div class="events-card-body">
|
||||
<div class="events-card-title">${UI.escHtml(ev.titel)}</div>
|
||||
<div class="events-card-meta">
|
||||
<span class="events-badge" style="background:${color}20;color:${color}">${typ.icon} ${typ.label}</span>
|
||||
${ev.uhrzeit ? `· ${ev.uhrzeit} Uhr` : ''}
|
||||
${ev.ort_name ? `· 📍 ${UI.escHtml(ev.ort_name)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">✏️</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Karte
|
||||
// ----------------------------------------------------------
|
||||
async function _renderMap(filtered) {
|
||||
const mapEl = document.getElementById('ev-map');
|
||||
if (!mapEl) return;
|
||||
await _loadLeaflet();
|
||||
if (!_map) {
|
||||
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
|
||||
} else {
|
||||
_markers.forEach(m => m.remove());
|
||||
_markers = [];
|
||||
}
|
||||
|
||||
const bounds = [];
|
||||
for (const ev of filtered) {
|
||||
if (!ev.lat || !ev.lon) continue;
|
||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg)"><span style="transform:rotate(45deg)">${typ.icon}</span></div>`,
|
||||
iconSize: [32, 32], iconAnchor: [16, 32],
|
||||
});
|
||||
const m = L.marker([ev.lat, ev.lon], { icon })
|
||||
.addTo(_map)
|
||||
.on('click', () => _showDetail(ev.id));
|
||||
_markers.push(m);
|
||||
bounds.push([ev.lat, ev.lon]);
|
||||
}
|
||||
|
||||
if (bounds.length) _map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
setTimeout(() => _map.invalidateSize(), 50);
|
||||
}
|
||||
|
||||
function _loadLeaflet() {
|
||||
if (window.L) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/js/lib/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
const s = document.createElement('script');
|
||||
s.src = '/js/lib/leaflet.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Detail-Modal
|
||||
// ----------------------------------------------------------
|
||||
async function _showDetail(id) {
|
||||
let ev;
|
||||
try { ev = await API.events.get(id); } catch { return; }
|
||||
|
||||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||
const d = new Date(ev.datum + 'T00:00:00');
|
||||
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const isOwn = _state.user?.id === ev.user_id;
|
||||
|
||||
const body = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||||
<span class="events-badge" style="background:${color}20;color:${color};font-size:var(--text-sm)">${typ.icon} ${typ.label}</span>
|
||||
</div>
|
||||
<div class="events-detail-row">📅 ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
|
||||
${ev.ort_name ? `<div class="events-detail-row">📍 ${UI.escHtml(ev.ort_name)}</div>` : ''}
|
||||
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escHtml(ev.beschreibung)}</div>` : ''}
|
||||
${ev.link ? `<div class="events-detail-row">🔗 <a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a></div>` : ''}
|
||||
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}</div>
|
||||
`;
|
||||
|
||||
const footer = isOwn ? `
|
||||
<button class="btn btn-secondary" id="ev-detail-edit">✏️ Bearbeiten</button>
|
||||
<button class="btn btn-danger" id="ev-detail-del">🗑️ Löschen</button>
|
||||
` : '';
|
||||
|
||||
UI.modal.open({ title: UI.escHtml(ev.titel), body, footer });
|
||||
|
||||
document.getElementById('ev-detail-edit')?.addEventListener('click', () => {
|
||||
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
|
||||
});
|
||||
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
|
||||
}
|
||||
|
||||
async function _deleteEvent(ev) {
|
||||
if (!confirm(`"${ev.titel}" wirklich löschen?`)) return;
|
||||
try {
|
||||
await API.events.delete(ev.id);
|
||||
UI.modal.close();
|
||||
UI.toast('Event gelöscht.');
|
||||
await _load();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Erstellen / Bearbeiten
|
||||
// ----------------------------------------------------------
|
||||
function openNew() { _openForm(null); }
|
||||
|
||||
function _openForm(ev) {
|
||||
const isEdit = !!ev;
|
||||
const id = 'ev-form';
|
||||
const body = `
|
||||
<form id="${id}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Titel *</label>
|
||||
<input class="form-control" name="titel" required value="${ev ? UI.escHtml(ev.titel) : ''}">
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Datum *</label>
|
||||
<input class="form-control" type="date" name="datum" required value="${ev?.datum || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Uhrzeit</label>
|
||||
<input class="form-control" type="time" name="uhrzeit" value="${ev?.uhrzeit || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Typ *</label>
|
||||
<select class="form-control" name="typ">
|
||||
${TYPEN.filter(t => t.id !== 'alle').map(t =>
|
||||
`<option value="${t.id}" ${ev?.typ === t.id ? 'selected' : ''}>${t.icon} ${t.label}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Ort / Veranstaltungsort</label>
|
||||
<input class="form-control" name="ort_name" placeholder="z.B. Stadtpark München" value="${ev?.ort_name || ''}">
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Breitengrad</label>
|
||||
<input class="form-control" type="number" step="any" name="lat" id="ev-lat" placeholder="48.1234" value="${ev?.lat || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Längengrad</label>
|
||||
<input class="form-control" type="number" step="any" name="lon" id="ev-lon" placeholder="11.5678" value="${ev?.lon || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="ev-gps-btn">📍 GPS-Position</button>
|
||||
<div class="form-group" style="margin-top:var(--space-3)">
|
||||
<label class="form-label">Beschreibung</label>
|
||||
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Link / Website</label>
|
||||
<input class="form-control" type="url" name="link" placeholder="https://..." value="${ev?.link || ''}">
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn">
|
||||
${isEdit ? 'Speichern' : 'Event erstellen'}
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: isEdit ? 'Event bearbeiten' : 'Neues Event', body, footer });
|
||||
|
||||
document.getElementById('ev-gps-btn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
document.getElementById('ev-lat').value = pos.lat.toFixed(6);
|
||||
document.getElementById('ev-lon').value = pos.lon.toFixed(6);
|
||||
} catch { UI.toast('GPS nicht verfügbar.', 'error'); }
|
||||
});
|
||||
|
||||
const form = document.getElementById(id);
|
||||
const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]');
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const data = {
|
||||
titel: fd.get('titel'),
|
||||
datum: fd.get('datum'),
|
||||
uhrzeit: fd.get('uhrzeit') || null,
|
||||
typ: fd.get('typ'),
|
||||
ort_name: fd.get('ort_name') || null,
|
||||
lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null,
|
||||
lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null,
|
||||
beschreibung: fd.get('beschreibung') || null,
|
||||
link: fd.get('link') || null,
|
||||
};
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
|
||||
try {
|
||||
isEdit ? await API.events.update(ev.id, data) : await API.events.create(data);
|
||||
UI.modal.close();
|
||||
UI.toast(isEdit ? 'Event aktualisiert.' : 'Event erstellt!');
|
||||
await _load();
|
||||
} catch (err) {
|
||||
UI.toast(err.message, 'error');
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = isEdit ? 'Speichern' : 'Event erstellen'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Click-Handler
|
||||
// ----------------------------------------------------------
|
||||
function _onClick(e) {
|
||||
// Filter
|
||||
const filterBtn = e.target.closest('[data-ev-typ]');
|
||||
if (filterBtn) {
|
||||
_filter = filterBtn.dataset.evTyp;
|
||||
document.querySelectorAll('[data-ev-typ]').forEach(b => b.classList.toggle('active', b.dataset.evTyp === _filter));
|
||||
_renderList();
|
||||
return;
|
||||
}
|
||||
|
||||
// View-Toggle
|
||||
const viewBtn = e.target.closest('[data-ev-view]');
|
||||
if (viewBtn) {
|
||||
_view = viewBtn.dataset.evView;
|
||||
document.querySelectorAll('[data-ev-view]').forEach(b => b.classList.toggle('active', b.dataset.evView === _view));
|
||||
const listEl = document.getElementById('ev-list');
|
||||
const mapEl = document.getElementById('ev-map');
|
||||
if (_view === 'karte') {
|
||||
listEl.style.display = 'none';
|
||||
mapEl.style.display = 'block';
|
||||
const filtered = _filter === 'alle' ? _events : _events.filter(ev => ev.typ === _filter);
|
||||
_renderMap(filtered);
|
||||
} else {
|
||||
listEl.style.display = '';
|
||||
mapEl.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Neu-Button
|
||||
if (e.target.closest('#ev-new-btn')) { openNew(); return; }
|
||||
|
||||
// Bearbeiten-Icon auf Karte
|
||||
const editBtn = e.target.closest('[data-ev-edit]');
|
||||
if (editBtn) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt(editBtn.dataset.evEdit);
|
||||
const ev = _events.find(x => x.id === id);
|
||||
if (ev) _openForm(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Karten-Klick → Detail
|
||||
const card = e.target.closest('[data-ev-id]');
|
||||
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
||||
}
|
||||
|
||||
return { init, refresh, openNew };
|
||||
|
||||
})();
|
||||
482
backend/static/js/pages/sitting.js
Normal file
482
backend/static/js/pages/sitting.js
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Hundesitting
|
||||
Sitter suchen · Profil anbieten · Anfragen verwalten
|
||||
============================================================ */
|
||||
|
||||
const Page_sitting = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Konstanten
|
||||
// ----------------------------------------------------------
|
||||
const SERVICES = [
|
||||
{ id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '☀️' },
|
||||
{ id: 'uebernachtung', label: 'Übernachtung', icon: '🌙' },
|
||||
{ id: 'gassi', label: 'Gassi gehen', icon: '🦮' },
|
||||
{ id: 'hausbesuch', label: 'Hausbesuch', icon: '🏠' },
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// State
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _state = null;
|
||||
let _tab = 'suchen'; // suchen | profil | anfragen
|
||||
let _sitters = [];
|
||||
let _mySitter = null;
|
||||
let _myRequests = [];
|
||||
let _inbox = [];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// init
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_state = appState;
|
||||
_render();
|
||||
await _load();
|
||||
}
|
||||
|
||||
async function refresh() { await _load(); }
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Render Grundstruktur
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="sitting-tabs" id="sit-tabs">
|
||||
<button class="sitting-tab active" data-sit-tab="suchen">🔍 Sitter finden</button>
|
||||
${_state.user ? `
|
||||
<button class="sitting-tab" data-sit-tab="profil">👤 Mein Profil</button>
|
||||
<button class="sitting-tab" data-sit-tab="anfragen">📬 Anfragen</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div id="sit-content" class="sitting-content"></div>
|
||||
`;
|
||||
_container.addEventListener('click', _onClick);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Daten laden
|
||||
// ----------------------------------------------------------
|
||||
async function _load() {
|
||||
const content = document.getElementById('sit-content');
|
||||
if (!content) return;
|
||||
content.innerHTML = UI.skeleton(3);
|
||||
|
||||
const tasks = [API.sitting.list()];
|
||||
if (_state.user) {
|
||||
tasks.push(API.sitting.me());
|
||||
tasks.push(API.sitting.requests());
|
||||
tasks.push(API.sitting.inbox());
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(tasks);
|
||||
_sitters = results[0].status === 'fulfilled' ? results[0].value : [];
|
||||
_mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null;
|
||||
_myRequests = results[2]?.status === 'fulfilled' ? results[2].value : [];
|
||||
_inbox = results[3]?.status === 'fulfilled' ? results[3].value : [];
|
||||
} catch {}
|
||||
|
||||
_renderTab();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Tab-Inhalte
|
||||
// ----------------------------------------------------------
|
||||
function _renderTab() {
|
||||
const content = document.getElementById('sit-content');
|
||||
if (!content) return;
|
||||
if (_tab === 'suchen') _renderSuchen(content);
|
||||
if (_tab === 'profil') _renderProfil(content);
|
||||
if (_tab === 'anfragen') _renderAnfragen(content);
|
||||
}
|
||||
|
||||
// ---- Tab: Sitter suchen ----
|
||||
function _renderSuchen(el) {
|
||||
if (!_sitters.length) {
|
||||
el.innerHTML = UI.emptyState({ icon: '🐕', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' });
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="sitting-list">
|
||||
${_sitters.map(s => _sitterCardHTML(s)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _sitterCardHTML(s) {
|
||||
const svcs = (s.services || []).map(id => {
|
||||
const svc = SERVICES.find(x => x.id === id);
|
||||
return svc ? `<span class="sit-service-badge">${svc.icon} ${svc.label}</span>` : '';
|
||||
}).join('');
|
||||
const dist = s.distanz_m != null
|
||||
? (s.distanz_m >= 1000 ? (s.distanz_m / 1000).toFixed(1) + ' km' : s.distanz_m + ' m')
|
||||
: '';
|
||||
return `
|
||||
<div class="sitting-card" data-sit-id="${s.id}">
|
||||
<div class="sitting-card-avatar">🐾</div>
|
||||
<div class="sitting-card-body">
|
||||
<div class="sitting-card-name">${UI.escHtml(s.sitter_name)}</div>
|
||||
${dist ? `<div class="sitting-card-dist">📍 ${dist} entfernt</div>` : ''}
|
||||
${s.beschreibung ? `<div class="sitting-card-desc">${UI.escHtml(s.beschreibung)}</div>` : ''}
|
||||
<div class="sitting-services">${svcs}</div>
|
||||
</div>
|
||||
<div class="sitting-card-side">
|
||||
<div class="sitting-price">${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}</div>
|
||||
<div class="sitting-dogs">max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---- Tab: Mein Profil ----
|
||||
function _renderProfil(el) {
|
||||
if (!_mySitter) {
|
||||
el.innerHTML = `
|
||||
<div class="sitting-empty-profil">
|
||||
<div style="font-size:3rem">🐾</div>
|
||||
<h3>Werde Hundesitter</h3>
|
||||
<p>Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.</p>
|
||||
<button class="btn btn-primary" id="sit-create-profil-btn">Profil erstellen</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const s = _mySitter;
|
||||
const svcs = (s.services || []).map(id => SERVICES.find(x => x.id === id)?.label || id).join(', ');
|
||||
el.innerHTML = `
|
||||
<div class="sitting-my-profil">
|
||||
<div class="sitting-profil-header">
|
||||
<div class="sitting-profil-status ${s.aktiv ? 'active' : 'inactive'}">
|
||||
${s.aktiv ? '✅ Aktiv' : '⏸️ Pausiert'}
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn">✏️ Bearbeiten</button>
|
||||
</div>
|
||||
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
|
||||
<div class="sitting-profil-facts">
|
||||
<div class="sitting-profil-fact"><strong>${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €' : '–'}</strong> pro Tag</div>
|
||||
<div class="sitting-profil-fact"><strong>${s.max_hunde}</strong> Hund${s.max_hunde !== 1 ? 'e' : ''} max.</div>
|
||||
<div class="sitting-profil-fact"><strong>${s.radius_km} km</strong> Umkreis</div>
|
||||
</div>
|
||||
<div class="sitting-services" style="margin-top:var(--space-3)">${svcs || '(keine Services angegeben)'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---- Tab: Anfragen ----
|
||||
function _renderAnfragen(el) {
|
||||
const inbox = _inbox;
|
||||
const myReqs = _myRequests;
|
||||
|
||||
let html = '';
|
||||
|
||||
if (inbox.length) {
|
||||
html += `<div class="sitting-section-label">📬 Eingehende Anfragen (als Sitter)</div>`;
|
||||
html += inbox.map(r => _requestCardHTML(r, 'inbox')).join('');
|
||||
}
|
||||
|
||||
if (myReqs.length) {
|
||||
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">📤 Meine Anfragen</div>`;
|
||||
html += myReqs.map(r => _requestCardHTML(r, 'sent')).join('');
|
||||
}
|
||||
|
||||
if (!inbox.length && !myReqs.length) {
|
||||
html = UI.emptyState({ icon: '📬', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' });
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function _requestCardHTML(r, mode) {
|
||||
const STATUS_COLOR = { offen: '#f59e0b', angenommen: '#10b981', abgelehnt: '#ef4444', abgebrochen: '#6b7280' };
|
||||
const color = STATUS_COLOR[r.status] || '#6b7280';
|
||||
const name = mode === 'inbox' ? r.anfragender_name : r.sitter_name;
|
||||
return `
|
||||
<div class="sitting-request-card" data-sit-req="${r.id}" data-sit-req-mode="${mode}">
|
||||
<div class="sitting-req-header">
|
||||
<span class="sitting-req-name">${UI.escHtml(name || '?')}</span>
|
||||
<span class="sitting-req-status" style="color:${color}">${r.status}</span>
|
||||
</div>
|
||||
<div class="sitting-req-dates">📅 ${r.von} – ${r.bis}</div>
|
||||
${r.nachricht ? `<div class="sitting-req-msg">${UI.escHtml(r.nachricht)}</div>` : ''}
|
||||
${r.status === 'offen' ? _requestActions(r.id, mode) : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _requestActions(id, mode) {
|
||||
if (mode === 'inbox') {
|
||||
return `
|
||||
<div class="sitting-req-actions">
|
||||
<button class="btn btn-primary btn-sm" data-sit-accept="${id}">✅ Annehmen</button>
|
||||
<button class="btn btn-danger btn-sm" data-sit-decline="${id}">❌ Ablehnen</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="sitting-req-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">🚫 Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Sitter Detail + Anfrage senden
|
||||
// ----------------------------------------------------------
|
||||
function _showSitterDetail(id) {
|
||||
const s = _sitters.find(x => x.id === id);
|
||||
if (!s) return;
|
||||
const svcs = (s.services || []).map(sv => {
|
||||
const f = SERVICES.find(x => x.id === sv);
|
||||
return f ? `<span class="sit-service-badge">${f.icon} ${f.label}</span>` : '';
|
||||
}).join('');
|
||||
const dist = s.distanz_m != null
|
||||
? (s.distanz_m >= 1000 ? (s.distanz_m / 1000).toFixed(1) + ' km' : s.distanz_m + ' m')
|
||||
: null;
|
||||
|
||||
const body = `
|
||||
<div class="sitting-detail-avatar">🐾</div>
|
||||
<h3 style="margin:var(--space-2) 0">${UI.escHtml(s.sitter_name)}</h3>
|
||||
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${dist} entfernt</div>` : ''}
|
||||
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
|
||||
<div class="sitting-services" style="margin:var(--space-3) 0">${svcs}</div>
|
||||
<div class="sitting-profil-facts">
|
||||
<div class="sitting-profil-fact"><strong>${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €' : '–'}</strong> pro Tag</div>
|
||||
<div class="sitting-profil-fact"><strong>${s.max_hunde}</strong> max. Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
|
||||
<div class="sitting-profil-fact"><strong>${s.radius_km} km</strong> Umkreis</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = _state.user && _mySitter?.user_id !== s.user_id ? `
|
||||
<button class="btn btn-primary" id="sit-anfrage-btn">📬 Anfrage senden</button>
|
||||
` : (!_state.user ? `<span style="color:var(--c-text-secondary)">Zum Anfragen bitte einloggen.</span>` : '');
|
||||
|
||||
UI.modal.open({ title: 'Sitter-Profil', body, footer });
|
||||
|
||||
document.getElementById('sit-anfrage-btn')?.addEventListener('click', () => {
|
||||
UI.modal.close();
|
||||
setTimeout(() => _openAnfrageForm(s), 50);
|
||||
});
|
||||
}
|
||||
|
||||
function _openAnfrageForm(s) {
|
||||
const dogs = _state.dogs || [];
|
||||
const id = 'sit-anfrage-form';
|
||||
const body = `
|
||||
<form id="${id}">
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">Anfrage an <strong>${UI.escHtml(s.sitter_name)}</strong></p>
|
||||
<div class="form-row-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Von *</label>
|
||||
<input class="form-control" type="date" name="von" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bis *</label>
|
||||
<input class="form-control" type="date" name="bis" required>
|
||||
</div>
|
||||
</div>
|
||||
${dogs.length ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Welche Hunde?</label>
|
||||
${dogs.map(d => `
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="dog_ids" value="${d.id}" checked>
|
||||
${UI.escHtml(d.name)}
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nachricht</label>
|
||||
<textarea class="form-control" name="nachricht" rows="3" placeholder="z.B. Besonderheiten deines Hundes…"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="sit-anfrage-submit">Anfrage senden</button>
|
||||
`;
|
||||
UI.modal.open({ title: 'Anfrage senden', body, footer });
|
||||
|
||||
const form = document.getElementById(id);
|
||||
const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]');
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const dogIds = [...form.querySelectorAll('[name="dog_ids"]:checked')].map(cb => parseInt(cb.value));
|
||||
const data = {
|
||||
sitter_id: s.id,
|
||||
von: fd.get('von'),
|
||||
bis: fd.get('bis'),
|
||||
dog_ids: dogIds,
|
||||
nachricht: fd.get('nachricht') || null,
|
||||
};
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
|
||||
try {
|
||||
await API.sitting.sendRequest(data);
|
||||
UI.modal.close();
|
||||
UI.toast('Anfrage gesendet!');
|
||||
await _load();
|
||||
} catch (err) {
|
||||
UI.toast(err.message, 'error');
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Anfrage senden'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Sitter-Profil Formular
|
||||
// ----------------------------------------------------------
|
||||
function _openProfilForm() {
|
||||
const s = _mySitter;
|
||||
const id = 'sit-profil-form';
|
||||
const body = `
|
||||
<form id="${id}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Über mich / Beschreibung</label>
|
||||
<textarea class="form-control" name="beschreibung" rows="3">${s?.beschreibung || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Preis pro Tag (€)</label>
|
||||
<input class="form-control" type="number" step="1" min="0" name="preis_pro_tag" value="${s?.preis_pro_tag ?? 0}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Max. Hunde</label>
|
||||
<input class="form-control" type="number" min="1" max="10" name="max_hunde" value="${s?.max_hunde ?? 1}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Services</label>
|
||||
${SERVICES.map(svc => `
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="services" value="${svc.id}" ${s?.services?.includes(svc.id) ? 'checked' : ''}>
|
||||
${svc.icon} ${svc.label}
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Breitengrad</label>
|
||||
<input class="form-control" type="number" step="any" name="lat" id="sit-lat" value="${s?.lat || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Längengrad</label>
|
||||
<input class="form-control" type="number" step="any" name="lon" id="sit-lon" value="${s?.lon || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">📍 Meine Position</button>
|
||||
<div class="form-group" style="margin-top:var(--space-3)">
|
||||
<label class="form-label">Umkreis (km)</label>
|
||||
<input class="form-control" type="number" min="1" max="100" name="radius_km" value="${s?.radius_km ?? 20}">
|
||||
</div>
|
||||
${s ? `
|
||||
<div class="form-group">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="aktiv" value="1" ${s.aktiv ? 'checked' : ''}> Profil aktiv (sichtbar für andere)
|
||||
</label>
|
||||
</div>
|
||||
` : ''}
|
||||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="sit-profil-submit">
|
||||
${s ? 'Speichern' : 'Profil erstellen'}
|
||||
</button>
|
||||
`;
|
||||
UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer });
|
||||
|
||||
document.getElementById('sit-gps-btn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
document.getElementById('sit-lat').value = pos.lat.toFixed(6);
|
||||
document.getElementById('sit-lon').value = pos.lon.toFixed(6);
|
||||
} catch { UI.toast('GPS nicht verfügbar.', 'error'); }
|
||||
});
|
||||
|
||||
const form = document.getElementById(id);
|
||||
const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]');
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const svcs = [...form.querySelectorAll('[name="services"]:checked')].map(cb => cb.value);
|
||||
const data = {
|
||||
beschreibung: fd.get('beschreibung') || null,
|
||||
preis_pro_tag: parseFloat(fd.get('preis_pro_tag')) || 0,
|
||||
max_hunde: parseInt(fd.get('max_hunde')) || 1,
|
||||
services: svcs,
|
||||
lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null,
|
||||
lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null,
|
||||
radius_km: parseInt(fd.get('radius_km')) || 20,
|
||||
};
|
||||
if (s) data.aktiv = form.querySelector('[name="aktiv"]')?.checked ? 1 : 0;
|
||||
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
|
||||
try {
|
||||
if (s) {
|
||||
await API.sitting.updateMe(data);
|
||||
} else {
|
||||
await API.sitting.create(data);
|
||||
}
|
||||
UI.modal.close();
|
||||
UI.toast(s ? 'Profil aktualisiert.' : 'Profil erstellt!');
|
||||
await _load();
|
||||
} catch (err) {
|
||||
UI.toast(err.message, 'error');
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = s ? 'Speichern' : 'Profil erstellen'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Click-Handler
|
||||
// ----------------------------------------------------------
|
||||
function _onClick(e) {
|
||||
// Tab-Wechsel
|
||||
const tabBtn = e.target.closest('[data-sit-tab]');
|
||||
if (tabBtn) {
|
||||
_tab = tabBtn.dataset.sitTab;
|
||||
document.querySelectorAll('[data-sit-tab]').forEach(b => b.classList.toggle('active', b.dataset.sitTab === _tab));
|
||||
_renderTab();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sitter-Karte
|
||||
const sitterCard = e.target.closest('[data-sit-id]');
|
||||
if (sitterCard && !e.target.closest('button')) {
|
||||
_showSitterDetail(parseInt(sitterCard.dataset.sitId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Profil erstellen
|
||||
if (e.target.closest('#sit-create-profil-btn') || e.target.closest('#sit-edit-profil-btn')) {
|
||||
_openProfilForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Anfragen-Aktionen
|
||||
const acceptBtn = e.target.closest('[data-sit-accept]');
|
||||
const declineBtn = e.target.closest('[data-sit-decline]');
|
||||
const cancelBtn = e.target.closest('[data-sit-cancel]');
|
||||
if (acceptBtn) { _changeReqStatus(parseInt(acceptBtn.dataset.sitAccept), 'angenommen'); return; }
|
||||
if (declineBtn) { _changeReqStatus(parseInt(declineBtn.dataset.sitDecline), 'abgelehnt'); return; }
|
||||
if (cancelBtn) { _changeReqStatus(parseInt(cancelBtn.dataset.sitCancel), 'abgebrochen'); return; }
|
||||
}
|
||||
|
||||
async function _changeReqStatus(id, status) {
|
||||
try {
|
||||
await API.sitting.updateRequest(id, status);
|
||||
UI.toast(`Anfrage ${status}.`);
|
||||
await _load();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v21';
|
||||
const CACHE_VERSION = 'by-v22';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
|
||||
// Diese Dateien werden beim Install gecacht (App Shell)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue