Før galleri og videogalleri
This commit is contained in:
parent
0270855436
commit
8a8690ed92
4 changed files with 990 additions and 422 deletions
652
backend/main.py
652
backend/main.py
|
|
@ -907,6 +907,7 @@ LEGACY_FACILITY_FIELD_ALIASES = {
|
|||
'vtg_presentasjon': 'vtg_beskrivelse',
|
||||
'vtg_kursdatoer': 'vtg_datoer',
|
||||
}
|
||||
LEGACY_TEE_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']
|
||||
|
||||
FACILITY_ALLOWED_FIELDS = [
|
||||
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
||||
|
|
@ -1822,6 +1823,18 @@ async def ensure_public_query_indexes(conn) -> None:
|
|||
CREATE INDEX IF NOT EXISTS holes_course_id_idx
|
||||
ON holes (course_id)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS tees_course_sort_idx
|
||||
ON tees (course_id, sort_order ASC, id ASC)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS hole_lengths_hole_idx
|
||||
ON hole_lengths (hole_id)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS hole_lengths_tee_idx
|
||||
ON hole_lengths (tee_id)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS facility_weather_forecast_facility_day_idx
|
||||
ON facility_weather_forecast (facility_id, day_offset)
|
||||
|
|
@ -1841,6 +1854,148 @@ async def get_table_columns(conn, table_name: str, schema_name: str = "public")
|
|||
return {str(row["column_name"]) for row in rows}
|
||||
|
||||
|
||||
async def ensure_scorecard_tables(conn) -> None:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tees (
|
||||
id SERIAL PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
cr_men NUMERIC(4, 1),
|
||||
slope_men INTEGER,
|
||||
cr_women NUMERIC(4, 1),
|
||||
slope_women INTEGER
|
||||
)
|
||||
""")
|
||||
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0")
|
||||
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS cr_men NUMERIC(4, 1)")
|
||||
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS slope_men INTEGER")
|
||||
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS cr_women NUMERIC(4, 1)")
|
||||
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS slope_women INTEGER")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS hole_lengths (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hole_id INTEGER NOT NULL REFERENCES holes(id) ON DELETE CASCADE,
|
||||
tee_id INTEGER NOT NULL REFERENCES tees(id) ON DELETE CASCADE,
|
||||
length_meters INTEGER
|
||||
)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS hole_lengths_hole_tee_uidx
|
||||
ON hole_lengths (hole_id, tee_id)
|
||||
""")
|
||||
|
||||
course_columns = await get_table_columns(conn, "courses")
|
||||
hole_columns = await get_table_columns(conn, "holes")
|
||||
if "tee_boxes" not in course_columns or "lengths" not in hole_columns:
|
||||
return
|
||||
|
||||
legacy_courses = await conn.fetch(
|
||||
"""
|
||||
SELECT c.id, c.tee_boxes
|
||||
FROM courses c
|
||||
WHERE c.tee_boxes IS NOT NULL
|
||||
ORDER BY c.id ASC
|
||||
"""
|
||||
)
|
||||
|
||||
for course_row in legacy_courses:
|
||||
course_id = int(course_row["id"])
|
||||
tee_boxes = coerce_json_dict(course_row["tee_boxes"])
|
||||
holes = [
|
||||
{
|
||||
"id": int(hole_row["id"]),
|
||||
"lengths": coerce_json_dict(hole_row["lengths"]),
|
||||
}
|
||||
for hole_row in await conn.fetch(
|
||||
"SELECT id, lengths FROM holes WHERE course_id = $1 ORDER BY hole_number ASC, id ASC",
|
||||
course_id,
|
||||
)
|
||||
]
|
||||
|
||||
legacy_keys = resolve_legacy_course_keys(tee_boxes, holes)
|
||||
if not legacy_keys:
|
||||
continue
|
||||
|
||||
existing_tee_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, name, cr_men, slope_men, cr_women, slope_women
|
||||
FROM tees
|
||||
WHERE course_id = $1
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
""",
|
||||
course_id,
|
||||
)
|
||||
|
||||
compact_length = max(len(men_entries := coerce_json_list(tee_boxes.get("herrer"))), len(women_entries := coerce_json_list(tee_boxes.get("damer"))))
|
||||
fallback_names = {key.replace("_", " ").title().lower() for key in LEGACY_TEE_KEYS}
|
||||
has_suspicious_fallback_tees = any(
|
||||
str(tee_row["name"] or "").strip().lower() in fallback_names
|
||||
and tee_row["cr_men"] is None
|
||||
and tee_row["slope_men"] is None
|
||||
and tee_row["cr_women"] is None
|
||||
and tee_row["slope_women"] is None
|
||||
for tee_row in existing_tee_rows
|
||||
)
|
||||
should_repair_existing_tees = (
|
||||
bool(existing_tee_rows)
|
||||
and 0 < compact_length < len(LEGACY_TEE_KEYS)
|
||||
and len(existing_tee_rows) != len(legacy_keys)
|
||||
and has_suspicious_fallback_tees
|
||||
)
|
||||
|
||||
if existing_tee_rows and not should_repair_existing_tees:
|
||||
continue
|
||||
|
||||
if should_repair_existing_tees:
|
||||
await conn.execute("DELETE FROM tees WHERE course_id = $1", course_id)
|
||||
|
||||
created_tee_ids: dict[str, int] = {}
|
||||
|
||||
for sort_order, key in enumerate(legacy_keys):
|
||||
men_entry = extract_legacy_tee_entry(men_entries, key, legacy_keys)
|
||||
women_entry = extract_legacy_tee_entry(women_entries, key, legacy_keys)
|
||||
|
||||
tee_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO tees (
|
||||
course_id, name, sort_order, cr_men, slope_men, cr_women, slope_women
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
""",
|
||||
course_id,
|
||||
build_legacy_tee_name(men_entry, women_entry, key, sort_order),
|
||||
sort_order,
|
||||
parse_optional_float(men_entry.get("baneverdi")),
|
||||
parse_optional_int(men_entry.get("slopeverdi")),
|
||||
parse_optional_float(women_entry.get("baneverdi_damer")),
|
||||
parse_optional_int(women_entry.get("slopeverdi_damer")),
|
||||
)
|
||||
created_tee_ids[key] = int(tee_id)
|
||||
|
||||
if not created_tee_ids:
|
||||
continue
|
||||
|
||||
for hole in holes:
|
||||
length_map = coerce_json_dict(hole.get("lengths"))
|
||||
for key, tee_id in created_tee_ids.items():
|
||||
length_meters = parse_optional_int(length_map.get(key))
|
||||
if length_meters is None:
|
||||
continue
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO hole_lengths (hole_id, tee_id, length_meters)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (hole_id, tee_id)
|
||||
DO UPDATE SET length_meters = EXCLUDED.length_meters
|
||||
""",
|
||||
int(hole["id"]),
|
||||
tee_id,
|
||||
length_meters,
|
||||
)
|
||||
|
||||
|
||||
def generate_totp_qr_svg(provisioning_uri: str) -> str:
|
||||
image = qrcode.make(
|
||||
provisioning_uri,
|
||||
|
|
@ -1964,6 +2119,175 @@ def parse_optional_int(value: Any) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def parse_optional_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return float(int(value))
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
|
||||
trimmed = str(value).strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(trimmed.replace(",", "."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def coerce_json_list(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
return []
|
||||
|
||||
|
||||
def coerce_json_dict(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def build_submitted_tee_key(tee: dict[str, Any], fallback_index: int) -> str:
|
||||
parsed_id = parse_optional_int(tee.get("id"))
|
||||
if parsed_id is not None:
|
||||
return str(parsed_id)
|
||||
|
||||
for field_name in ("client_key", "_clientId", "temp_id"):
|
||||
text = str(tee.get(field_name) or "").strip()
|
||||
if text:
|
||||
return text
|
||||
|
||||
return f"new-tee-{fallback_index}"
|
||||
|
||||
|
||||
def normalize_tee_name(value: Any, fallback_index: int) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or f"Utslag {fallback_index + 1}"
|
||||
|
||||
|
||||
def resolve_compact_legacy_keys(active_keys: list[str], compact_length: int) -> list[str]:
|
||||
if compact_length <= 0:
|
||||
return []
|
||||
|
||||
if active_keys:
|
||||
compact_active_keys = [key for key in active_keys if key in LEGACY_TEE_KEYS][:compact_length]
|
||||
if len(compact_active_keys) == compact_length:
|
||||
return compact_active_keys
|
||||
|
||||
return LEGACY_TEE_KEYS[:compact_length]
|
||||
|
||||
|
||||
def extract_legacy_tee_entry(entries: list[Any], key: str, active_keys: list[str]) -> dict[str, Any]:
|
||||
normalized_entries = [entry if isinstance(entry, dict) else {} for entry in (entries or [])]
|
||||
if not normalized_entries:
|
||||
return {}
|
||||
|
||||
compact_length = len(normalized_entries)
|
||||
if 0 < compact_length < len(LEGACY_TEE_KEYS):
|
||||
compact_keys = resolve_compact_legacy_keys(active_keys, compact_length)
|
||||
compact_index = compact_keys.index(key) if key in compact_keys else -1
|
||||
if compact_index >= 0 and compact_index < compact_length:
|
||||
return dict(normalized_entries[compact_index] or {})
|
||||
return {}
|
||||
|
||||
key_index = LEGACY_TEE_KEYS.index(key)
|
||||
if key_index < compact_length:
|
||||
return dict(normalized_entries[key_index] or {})
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def legacy_tee_entry_has_content(entry: dict[str, Any]) -> bool:
|
||||
return any(
|
||||
str(entry.get(field_name) or "").strip()
|
||||
for field_name in (
|
||||
"navn_utslag",
|
||||
"baneverdi",
|
||||
"slopeverdi",
|
||||
"navn_utslag_damer",
|
||||
"baneverdi_damer",
|
||||
"slopeverdi_damer",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_legacy_course_keys(tee_boxes: dict[str, Any], holes: list[dict[str, Any]]) -> list[str]:
|
||||
active_keys = [
|
||||
key
|
||||
for key in LEGACY_TEE_KEYS
|
||||
if any(parse_optional_int(coerce_json_dict(hole.get("lengths")).get(key)) is not None for hole in holes)
|
||||
]
|
||||
|
||||
men_entries = coerce_json_list(tee_boxes.get("herrer"))
|
||||
women_entries = coerce_json_list(tee_boxes.get("damer"))
|
||||
compact_length = max(len(men_entries), len(women_entries))
|
||||
compact_keys = (
|
||||
resolve_compact_legacy_keys(active_keys, compact_length)
|
||||
if 0 < compact_length < len(LEGACY_TEE_KEYS)
|
||||
else []
|
||||
)
|
||||
|
||||
if compact_keys:
|
||||
candidate_keys = [key for key in compact_keys if key in active_keys]
|
||||
keys_to_inspect = list(compact_keys)
|
||||
else:
|
||||
candidate_keys = list(active_keys) if active_keys else []
|
||||
keys_to_inspect = list(LEGACY_TEE_KEYS)
|
||||
|
||||
for key in keys_to_inspect:
|
||||
men_entry = extract_legacy_tee_entry(men_entries, key, active_keys)
|
||||
women_entry = extract_legacy_tee_entry(women_entries, key, active_keys)
|
||||
if legacy_tee_entry_has_content(men_entry) or legacy_tee_entry_has_content(women_entry):
|
||||
if key not in candidate_keys:
|
||||
candidate_keys.append(key)
|
||||
|
||||
if candidate_keys:
|
||||
return [key for key in LEGACY_TEE_KEYS if key in candidate_keys]
|
||||
|
||||
if compact_keys:
|
||||
return compact_keys
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def build_legacy_tee_name(men_entry: dict[str, Any], women_entry: dict[str, Any], key: str, fallback_index: int) -> str:
|
||||
for candidate in (
|
||||
men_entry.get("navn_utslag"),
|
||||
women_entry.get("navn_utslag_damer"),
|
||||
women_entry.get("navn_utslag"),
|
||||
men_entry.get("navn_utslag_damer"),
|
||||
):
|
||||
normalized = str(candidate or "").strip()
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
return normalize_tee_name(key.replace("_", " ").title(), fallback_index)
|
||||
|
||||
|
||||
def coerce_length_mapping(hole: dict[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(hole.get("lengths_by_tee"), dict):
|
||||
return dict(hole.get("lengths_by_tee") or {})
|
||||
return coerce_json_dict(hole.get("lengths"))
|
||||
|
||||
|
||||
def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
|
@ -2117,6 +2441,130 @@ def schedule_facility_indexnow_submission_for_fields(
|
|||
)
|
||||
|
||||
|
||||
def format_course_payload_row(
|
||||
course_row: Any,
|
||||
tees_by_course_id: dict[int, list[dict[str, Any]]],
|
||||
holes_by_course_id: dict[int, list[dict[str, Any]]],
|
||||
length_rows_by_hole_id: dict[int, list[dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
data = dict(course_row)
|
||||
course_id = int(data["id"])
|
||||
|
||||
if isinstance(data.get("slope_valid_until"), (date, datetime)):
|
||||
data["slope_valid_until"] = data["slope_valid_until"].isoformat()
|
||||
|
||||
data.pop("tee_boxes", None)
|
||||
data["tees"] = tees_by_course_id.get(course_id, [])
|
||||
|
||||
holes_payload: list[dict[str, Any]] = []
|
||||
for hole_row in holes_by_course_id.get(course_id, []):
|
||||
hole_data = dict(hole_row)
|
||||
hole_id = int(hole_data["id"])
|
||||
hole_data.pop("lengths", None)
|
||||
hole_data["lengths_by_tee"] = {
|
||||
str(length_row["tee_id"]): int(length_row["length_meters"])
|
||||
for length_row in length_rows_by_hole_id.get(hole_id, [])
|
||||
if length_row.get("length_meters") is not None
|
||||
}
|
||||
holes_payload.append(hole_data)
|
||||
|
||||
data["holes"] = holes_payload
|
||||
return data
|
||||
|
||||
|
||||
async def build_facility_course_payloads(
|
||||
conn,
|
||||
facility_id: int,
|
||||
*,
|
||||
include_unpublished_courses: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
if include_unpublished_courses:
|
||||
course_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM courses
|
||||
WHERE facility_id = $1
|
||||
ORDER BY is_main_course DESC, id ASC
|
||||
""",
|
||||
facility_id,
|
||||
)
|
||||
else:
|
||||
course_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM courses
|
||||
WHERE facility_id = $1
|
||||
AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
||||
ORDER BY is_main_course DESC, id ASC
|
||||
""",
|
||||
facility_id,
|
||||
)
|
||||
|
||||
if not course_rows:
|
||||
return []
|
||||
|
||||
course_ids = [int(row["id"]) for row in course_rows]
|
||||
tee_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM tees
|
||||
WHERE course_id = ANY($1::int[])
|
||||
ORDER BY course_id ASC, sort_order ASC, id ASC
|
||||
""",
|
||||
course_ids,
|
||||
)
|
||||
hole_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM holes
|
||||
WHERE course_id = ANY($1::int[])
|
||||
ORDER BY course_id ASC, hole_number ASC, id ASC
|
||||
""",
|
||||
course_ids,
|
||||
)
|
||||
|
||||
hole_ids = [int(row["id"]) for row in hole_rows]
|
||||
length_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT hl.hole_id, hl.tee_id, hl.length_meters
|
||||
FROM hole_lengths hl
|
||||
JOIN tees t ON t.id = hl.tee_id
|
||||
WHERE hl.hole_id = ANY($1::int[])
|
||||
AND t.course_id = ANY($2::int[])
|
||||
ORDER BY hl.hole_id ASC, t.sort_order ASC, hl.tee_id ASC
|
||||
""",
|
||||
hole_ids or [0],
|
||||
course_ids,
|
||||
)
|
||||
|
||||
tees_by_course_id: dict[int, list[dict[str, Any]]] = {}
|
||||
for tee_row in tee_rows:
|
||||
tee_data = dict(tee_row)
|
||||
tee_id = int(tee_data["id"])
|
||||
course_id = int(tee_data["course_id"])
|
||||
if tee_data.get("cr_men") is not None:
|
||||
tee_data["cr_men"] = float(tee_data["cr_men"])
|
||||
if tee_data.get("cr_women") is not None:
|
||||
tee_data["cr_women"] = float(tee_data["cr_women"])
|
||||
tee_data["client_key"] = str(tee_id)
|
||||
tees_by_course_id.setdefault(course_id, []).append(tee_data)
|
||||
|
||||
holes_by_course_id: dict[int, list[dict[str, Any]]] = {}
|
||||
for hole_row in hole_rows:
|
||||
course_id = int(hole_row["course_id"])
|
||||
holes_by_course_id.setdefault(course_id, []).append(dict(hole_row))
|
||||
|
||||
length_rows_by_hole_id: dict[int, list[dict[str, Any]]] = {}
|
||||
for length_row in length_rows:
|
||||
hole_id = int(length_row["hole_id"])
|
||||
length_rows_by_hole_id.setdefault(hole_id, []).append(dict(length_row))
|
||||
|
||||
return [
|
||||
format_course_payload_row(course_row, tees_by_course_id, holes_by_course_id, length_rows_by_hole_id)
|
||||
for course_row in course_rows
|
||||
]
|
||||
|
||||
|
||||
async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tuple[str, set[str]]:
|
||||
normalized_data = apply_legacy_facility_field_aliases(data)
|
||||
update_data = {k: v for k, v in normalized_data.items() if k in FACILITY_ALLOWED_FIELDS}
|
||||
|
|
@ -2129,6 +2577,8 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu
|
|||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||
|
||||
facility_columns = await get_table_columns(conn, "facilities")
|
||||
course_columns = await get_table_columns(conn, "courses")
|
||||
hole_columns = await get_table_columns(conn, "holes")
|
||||
update_data = {k: v for k, v in update_data.items() if k in facility_columns}
|
||||
|
||||
if update_data:
|
||||
|
|
@ -2216,11 +2666,12 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu
|
|||
retained_course_ids: list[int] = []
|
||||
|
||||
for course in normalized_courses:
|
||||
course_id = course.get('id')
|
||||
course_id = parse_optional_int(course.get('id'))
|
||||
holes = [hole for hole in (course.get('holes') or []) if hole]
|
||||
tees = [tee for tee in (course.get('tees') or []) if tee]
|
||||
hole_count = len(holes) or None
|
||||
course_par = parse_optional_int(course.get('par'))
|
||||
course_length_meters = parse_optional_int(course.get('length_meters'))
|
||||
submitted_course_length_meters = parse_optional_int(course.get('length_meters'))
|
||||
|
||||
valid_until_str = course.get('slope_valid_until')
|
||||
if valid_until_str == "" or valid_until_str is None:
|
||||
|
|
@ -2232,60 +2683,140 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu
|
|||
except ValueError:
|
||||
valid_until = None
|
||||
|
||||
tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
|
||||
|
||||
if course_id:
|
||||
await conn.execute("""
|
||||
UPDATE courses
|
||||
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
|
||||
status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
|
||||
slope_valid_until=$9
|
||||
WHERE id=$10 AND facility_id=$11
|
||||
status=$6, is_main_course=$7, slope_valid_until=$8
|
||||
WHERE id=$9 AND facility_id=$10
|
||||
""",
|
||||
course.get('name'), hole_count, course_par, course_length_meters,
|
||||
course.get('name'), hole_count, course_par, submitted_course_length_meters,
|
||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||
tee_boxes_json, valid_until, course_id, facility_id)
|
||||
valid_until, course_id, facility_id)
|
||||
else:
|
||||
course_id = await conn.fetchval("""
|
||||
INSERT INTO courses (
|
||||
facility_id, name, holes, par, length_meters, architect,
|
||||
status, is_main_course, tee_boxes, slope_valid_until
|
||||
status, is_main_course, slope_valid_until
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
""",
|
||||
facility_id, course.get('name'), hole_count, course_par, course_length_meters,
|
||||
facility_id, course.get('name'), hole_count, course_par, submitted_course_length_meters,
|
||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||
tee_boxes_json, valid_until)
|
||||
valid_until)
|
||||
|
||||
retained_course_ids.append(int(course_id))
|
||||
|
||||
retained_tee_ids: list[int] = []
|
||||
submitted_tee_key_to_db_id: dict[str, int] = {}
|
||||
|
||||
for tee_index, tee in enumerate(tees):
|
||||
tee_data = dict(tee)
|
||||
tee_id = parse_optional_int(tee_data.get("id"))
|
||||
submitted_key = build_submitted_tee_key(tee_data, tee_index)
|
||||
tee_name = normalize_tee_name(tee_data.get("name"), tee_index)
|
||||
tee_sort_order = parse_optional_int(tee_data.get("sort_order"))
|
||||
if tee_sort_order is None:
|
||||
tee_sort_order = tee_index
|
||||
|
||||
if tee_id:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE tees
|
||||
SET name = $1,
|
||||
sort_order = $2,
|
||||
cr_men = $3,
|
||||
slope_men = $4,
|
||||
cr_women = $5,
|
||||
slope_women = $6
|
||||
WHERE id = $7 AND course_id = $8
|
||||
""",
|
||||
tee_name,
|
||||
tee_sort_order,
|
||||
parse_optional_float(tee_data.get("cr_men")),
|
||||
parse_optional_int(tee_data.get("slope_men")),
|
||||
parse_optional_float(tee_data.get("cr_women")),
|
||||
parse_optional_int(tee_data.get("slope_women")),
|
||||
tee_id,
|
||||
course_id,
|
||||
)
|
||||
persisted_tee_id = tee_id
|
||||
else:
|
||||
persisted_tee_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO tees (
|
||||
course_id, name, sort_order, cr_men, slope_men, cr_women, slope_women
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
""",
|
||||
course_id,
|
||||
tee_name,
|
||||
tee_sort_order,
|
||||
parse_optional_float(tee_data.get("cr_men")),
|
||||
parse_optional_int(tee_data.get("slope_men")),
|
||||
parse_optional_float(tee_data.get("cr_women")),
|
||||
parse_optional_int(tee_data.get("slope_women")),
|
||||
)
|
||||
|
||||
retained_tee_ids.append(int(persisted_tee_id))
|
||||
submitted_tee_key_to_db_id[submitted_key] = int(persisted_tee_id)
|
||||
submitted_tee_key_to_db_id[str(int(persisted_tee_id))] = int(persisted_tee_id)
|
||||
|
||||
retained_hole_ids: list[int] = []
|
||||
course_length_totals: dict[int, int] = {}
|
||||
for hole in holes:
|
||||
hole_id = hole.get('id')
|
||||
hole_id = parse_optional_int(hole.get('id'))
|
||||
hole_number = parse_optional_int(hole.get('hole_number'))
|
||||
hole_par = parse_optional_int(hole.get('par'))
|
||||
hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
|
||||
lengths_json = json.dumps(hole.get('lengths') or {})
|
||||
if hole_id:
|
||||
await conn.execute("""
|
||||
UPDATE holes
|
||||
SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
|
||||
WHERE id=$5 AND course_id=$6
|
||||
SET hole_number=$1, par=$2, hcp_index=$3
|
||||
WHERE id=$4 AND course_id=$5
|
||||
""",
|
||||
hole_number, hole_par, hole_hcp_index,
|
||||
lengths_json, hole_id, course_id)
|
||||
hole_number, hole_par, hole_hcp_index, hole_id, course_id)
|
||||
else:
|
||||
hole_id = await conn.fetchval("""
|
||||
INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
INSERT INTO holes (course_id, hole_number, par, hcp_index)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
""",
|
||||
course_id, hole_number, hole_par, hole_hcp_index,
|
||||
lengths_json)
|
||||
course_id, hole_number, hole_par, hole_hcp_index)
|
||||
|
||||
retained_hole_ids.append(int(hole_id))
|
||||
|
||||
await conn.execute("DELETE FROM hole_lengths WHERE hole_id = $1", hole_id)
|
||||
for submitted_tee_key, raw_length in coerce_length_mapping(dict(hole)).items():
|
||||
normalized_submitted_key = str(submitted_tee_key or "").strip()
|
||||
if not normalized_submitted_key:
|
||||
continue
|
||||
|
||||
tee_id = submitted_tee_key_to_db_id.get(normalized_submitted_key)
|
||||
if tee_id is None:
|
||||
parsed_submitted_tee_id = parse_optional_int(normalized_submitted_key)
|
||||
if parsed_submitted_tee_id is not None and parsed_submitted_tee_id in retained_tee_ids:
|
||||
tee_id = parsed_submitted_tee_id
|
||||
if tee_id is None:
|
||||
continue
|
||||
|
||||
length_meters = parse_optional_int(raw_length)
|
||||
if length_meters is None:
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO hole_lengths (hole_id, tee_id, length_meters)
|
||||
VALUES ($1, $2, $3)
|
||||
""",
|
||||
hole_id,
|
||||
tee_id,
|
||||
length_meters,
|
||||
)
|
||||
course_length_totals[tee_id] = course_length_totals.get(tee_id, 0) + length_meters
|
||||
|
||||
if retained_hole_ids:
|
||||
await conn.execute(
|
||||
"DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
|
||||
|
|
@ -2295,6 +2826,31 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu
|
|||
else:
|
||||
await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
|
||||
|
||||
if retained_tee_ids:
|
||||
await conn.execute(
|
||||
"DELETE FROM tees WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
|
||||
course_id,
|
||||
retained_tee_ids,
|
||||
)
|
||||
else:
|
||||
await conn.execute("DELETE FROM tees WHERE course_id = $1", course_id)
|
||||
|
||||
if submitted_course_length_meters is None and course_length_totals:
|
||||
await conn.execute(
|
||||
"UPDATE courses SET length_meters = $1 WHERE id = $2",
|
||||
max(course_length_totals.values()),
|
||||
course_id,
|
||||
)
|
||||
|
||||
if "tee_boxes" in course_columns:
|
||||
await conn.execute("UPDATE courses SET tee_boxes = NULL WHERE id = $1", course_id)
|
||||
|
||||
if "lengths" in hole_columns and retained_hole_ids:
|
||||
await conn.execute(
|
||||
"UPDATE holes SET lengths = NULL WHERE id = ANY($1::int[])",
|
||||
retained_hole_ids,
|
||||
)
|
||||
|
||||
if retained_course_ids:
|
||||
await conn.execute(
|
||||
"DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
|
||||
|
|
@ -3141,6 +3697,7 @@ async def lifespan(app: FastAPI):
|
|||
)
|
||||
async with app.state.pool.acquire() as conn:
|
||||
await ensure_facility_columns(conn)
|
||||
await ensure_scorecard_tables(conn)
|
||||
await ensure_vtg_course_tables(conn)
|
||||
await ensure_place_pages_table(conn)
|
||||
await ensure_site_page_seo_table(conn)
|
||||
|
|
@ -3960,15 +4517,14 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
|||
hole_lengths AS (
|
||||
SELECT
|
||||
c.facility_id,
|
||||
MIN((length_value.value)::int) AS shortest_hole_meters,
|
||||
MAX((length_value.value)::int) AS longest_hole_meters
|
||||
FROM courses c
|
||||
JOIN holes h ON h.course_id = c.id
|
||||
MIN(hl.length_meters) AS shortest_hole_meters,
|
||||
MAX(hl.length_meters) AS longest_hole_meters
|
||||
FROM hole_lengths hl
|
||||
JOIN holes h ON h.id = hl.hole_id
|
||||
JOIN tees t ON t.id = hl.tee_id
|
||||
JOIN courses c ON c.id = t.course_id AND c.id = h.course_id
|
||||
JOIN published_facilities pf ON pf.id = c.facility_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
WHERE hl.length_meters BETWEEN 30 AND 900
|
||||
GROUP BY c.facility_id
|
||||
)
|
||||
"""
|
||||
|
|
@ -4249,17 +4805,6 @@ async def get_facility(slug: str, response: Response):
|
|||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT f.*, (
|
||||
SELECT jsonb_agg(c_data) FROM (
|
||||
SELECT c.*, (
|
||||
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
|
||||
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
|
||||
) as holes
|
||||
FROM courses c
|
||||
WHERE c.facility_id = f.id
|
||||
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
||||
ORDER BY c.is_main_course DESC, c.id ASC
|
||||
) c_data
|
||||
) as courses, (
|
||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||
SELECT
|
||||
forecast_date,
|
||||
|
|
@ -4288,6 +4833,11 @@ async def get_facility(slug: str, response: Response):
|
|||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||
|
||||
payload = sanitize_public_facility_row(row)
|
||||
payload["courses"] = await build_facility_course_payloads(
|
||||
conn,
|
||||
int(row["id"]),
|
||||
include_unpublished_courses=False,
|
||||
)
|
||||
write_public_cache_entry(
|
||||
detail_cache,
|
||||
normalized_slug,
|
||||
|
|
@ -4439,16 +4989,6 @@ async def get_admin_facility(slug: str):
|
|||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT f.*, (
|
||||
SELECT jsonb_agg(c_data) FROM (
|
||||
SELECT c.*, (
|
||||
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
|
||||
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
|
||||
) as holes
|
||||
FROM courses c
|
||||
WHERE c.facility_id = f.id
|
||||
ORDER BY c.is_main_course DESC, c.id ASC
|
||||
) c_data
|
||||
) as courses, (
|
||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||
SELECT
|
||||
forecast_date,
|
||||
|
|
@ -4475,7 +5015,13 @@ async def get_admin_facility(slug: str):
|
|||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||
|
||||
return format_row(row)
|
||||
payload = format_row(row)
|
||||
payload["courses"] = await build_facility_course_payloads(
|
||||
conn,
|
||||
int(row["id"]),
|
||||
include_unpublished_courses=True,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/api/admin/facilities")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { adminFetch } from "@/config/adminFetch";
|
||||
|
|
@ -220,249 +220,292 @@ const ScrollToTopButton = () => {
|
|||
);
|
||||
};
|
||||
|
||||
// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN
|
||||
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
|
||||
const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
||||
const createTeeClientKey = () => `tee-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const [holes, setHoles] = useState<any[]>(() => {
|
||||
const h = course.holes || [];
|
||||
if (h.length === 0) {
|
||||
return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} }));
|
||||
}
|
||||
return h.sort((a: any, b: any) => a.hole_number - b.hole_number);
|
||||
});
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(() => {
|
||||
const keys = new Set<string>();
|
||||
holes.forEach(h => {
|
||||
if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k));
|
||||
});
|
||||
return ALL_KEYS.filter(k => keys.has(k));
|
||||
});
|
||||
|
||||
const [tees, setTees] = useState<any>(() => {
|
||||
const herrer = course.tee_boxes?.herrer || [];
|
||||
const damer = course.tee_boxes?.damer || [];
|
||||
const initialTees = { herrer: {} as any, damer: {} as any };
|
||||
const herrerAreCompact = herrer.length > 0 && herrer.length < ALL_KEYS.length;
|
||||
const damerAreCompact = damer.length > 0 && damer.length < ALL_KEYS.length;
|
||||
|
||||
ALL_KEYS.forEach((key, idx) => {
|
||||
const activeIdx = activeKeys.indexOf(key);
|
||||
initialTees.herrer[key] = (herrerAreCompact && activeIdx >= 0 ? herrer[activeIdx] : herrer[idx]) || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
||||
initialTees.damer[key] = (damerAreCompact && activeIdx >= 0 ? damer[activeIdx] : damer[idx]) || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
||||
});
|
||||
return initialTees;
|
||||
});
|
||||
|
||||
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
|
||||
const updatedTeeBoxes = {
|
||||
herrer: ALL_KEYS.map(k => newTees.herrer[k] || {}),
|
||||
damer: ALL_KEYS.map(k => newTees.damer[k] || {})
|
||||
const normalizeCourseTees = (rawTees: any[]) => {
|
||||
const tees = Array.isArray(rawTees) ? rawTees : [];
|
||||
return tees.map((tee: any, index: number) => ({
|
||||
...tee,
|
||||
client_key: String(tee?.client_key || tee?.id || createTeeClientKey()),
|
||||
name: tee?.name || "",
|
||||
sort_order: typeof tee?.sort_order === "number" ? tee.sort_order : index,
|
||||
cr_men: tee?.cr_men ?? "",
|
||||
slope_men: tee?.slope_men ?? "",
|
||||
cr_women: tee?.cr_women ?? "",
|
||||
slope_women: tee?.slope_women ?? "",
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizeCourseHoles = (rawHoles: any[]) => {
|
||||
const holes = Array.isArray(rawHoles) ? rawHoles : [];
|
||||
if (holes.length === 0) {
|
||||
return Array.from({ length: 18 }, (_, i) => ({
|
||||
hole_number: i + 1,
|
||||
par: "",
|
||||
hcp_index: "",
|
||||
lengths_by_tee: {},
|
||||
}));
|
||||
}
|
||||
return holes
|
||||
.map((hole: any) => ({
|
||||
...hole,
|
||||
lengths_by_tee:
|
||||
hole?.lengths_by_tee && typeof hole.lengths_by_tee === "object"
|
||||
? { ...hole.lengths_by_tee }
|
||||
: {},
|
||||
}))
|
||||
.sort((a: any, b: any) => a.hole_number - b.hole_number);
|
||||
};
|
||||
|
||||
// KOMPONENT 4: SCOREKORT-BYGGER
|
||||
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
|
||||
const [tees, setTees] = useState<any[]>(() => normalizeCourseTees(course.tees));
|
||||
const [holes, setHoles] = useState<any[]>(() => normalizeCourseHoles(course.holes));
|
||||
|
||||
useEffect(() => {
|
||||
setTees(normalizeCourseTees(course.tees));
|
||||
setHoles(normalizeCourseHoles(course.holes));
|
||||
}, [course.id, course.tees, course.holes]);
|
||||
|
||||
const syncToParent = (nextTees: any[], nextHoles: any[]) => {
|
||||
const normalizedTees = nextTees.map((tee, index) => ({
|
||||
...tee,
|
||||
sort_order: index,
|
||||
client_key: String(tee.client_key || tee.id || createTeeClientKey()),
|
||||
}));
|
||||
const validTeeKeys = new Set(normalizedTees.map((tee) => String(tee.client_key)));
|
||||
const normalizedHoles = nextHoles.map((hole) => {
|
||||
const nextLengths = Object.fromEntries(
|
||||
Object.entries(hole.lengths_by_tee || {}).filter(([teeKey]) => validTeeKeys.has(String(teeKey)))
|
||||
);
|
||||
return {
|
||||
...hole,
|
||||
lengths_by_tee: nextLengths,
|
||||
};
|
||||
});
|
||||
|
||||
onChange({
|
||||
...course,
|
||||
holes: newHoles,
|
||||
tee_boxes: updatedTeeBoxes
|
||||
tees: normalizedTees,
|
||||
holes: normalizedHoles,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKey = (key: string) => {
|
||||
const newKeys = activeKeys.includes(key)
|
||||
? activeKeys.filter(k => k !== key)
|
||||
: ALL_KEYS.filter(k => activeKeys.includes(k) || k === key);
|
||||
setActiveKeys(newKeys);
|
||||
|
||||
const newTees = { ...tees };
|
||||
if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
||||
if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
||||
setTees(newTees);
|
||||
syncToParent(holes, newKeys, newTees);
|
||||
const updateTees = (updater: (currentTees: any[]) => any[]) => {
|
||||
setTees((currentTees) => {
|
||||
const nextTees = updater(currentTees);
|
||||
syncToParent(nextTees, holes);
|
||||
return nextTees;
|
||||
});
|
||||
};
|
||||
|
||||
const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => {
|
||||
const newTees = { ...tees };
|
||||
newTees[gender][key] = { ...newTees[gender][key], [field]: value };
|
||||
setTees(newTees);
|
||||
syncToParent(holes, activeKeys, newTees);
|
||||
const updateHoles = (updater: (currentHoles: any[]) => any[]) => {
|
||||
setHoles((currentHoles) => {
|
||||
const nextHoles = updater(currentHoles);
|
||||
syncToParent(tees, nextHoles);
|
||||
return nextHoles;
|
||||
});
|
||||
};
|
||||
|
||||
const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => {
|
||||
const newHoles = [...holes];
|
||||
if (lengthKey) {
|
||||
newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) };
|
||||
} else {
|
||||
newHoles[index][field] = value === '' ? '' : Number(value);
|
||||
const addTee = () => {
|
||||
updateTees((currentTees) => [
|
||||
...currentTees,
|
||||
{
|
||||
client_key: createTeeClientKey(),
|
||||
name: "",
|
||||
sort_order: currentTees.length,
|
||||
cr_men: "",
|
||||
slope_men: "",
|
||||
cr_women: "",
|
||||
slope_women: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeTee = (teeKey: string) => {
|
||||
const nextTees = tees.filter((tee) => String(tee.client_key) !== teeKey);
|
||||
const nextHoles = holes.map((hole) => {
|
||||
const nextLengths = { ...(hole.lengths_by_tee || {}) };
|
||||
delete nextLengths[teeKey];
|
||||
return { ...hole, lengths_by_tee: nextLengths };
|
||||
});
|
||||
setTees(nextTees);
|
||||
setHoles(nextHoles);
|
||||
syncToParent(nextTees, nextHoles);
|
||||
};
|
||||
|
||||
const moveTee = (teeKey: string, direction: -1 | 1) => {
|
||||
updateTees((currentTees) => {
|
||||
const index = currentTees.findIndex((tee) => String(tee.client_key) === teeKey);
|
||||
const nextIndex = index + direction;
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= currentTees.length) {
|
||||
return currentTees;
|
||||
}
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
const nextTees = [...currentTees];
|
||||
const [item] = nextTees.splice(index, 1);
|
||||
nextTees.splice(nextIndex, 0, item);
|
||||
return nextTees;
|
||||
});
|
||||
};
|
||||
|
||||
const updateTeeField = (teeKey: string, field: string, value: string) => {
|
||||
updateTees((currentTees) =>
|
||||
currentTees.map((tee) =>
|
||||
String(tee.client_key) === teeKey
|
||||
? { ...tee, [field]: value }
|
||||
: tee
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const updateHoleField = (index: number, field: string, value: string) => {
|
||||
updateHoles((currentHoles) =>
|
||||
currentHoles.map((hole, holeIndex) =>
|
||||
holeIndex === index
|
||||
? { ...hole, [field]: value === "" ? "" : Number(value) }
|
||||
: hole
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const updateHoleLength = (index: number, teeKey: string, value: string) => {
|
||||
updateHoles((currentHoles) =>
|
||||
currentHoles.map((hole, holeIndex) =>
|
||||
holeIndex === index
|
||||
? {
|
||||
...hole,
|
||||
lengths_by_tee: {
|
||||
...(hole.lengths_by_tee || {}),
|
||||
[teeKey]: value === "" ? "" : Number(value),
|
||||
},
|
||||
}
|
||||
: hole
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const addHole = () => {
|
||||
const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }];
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
updateHoles((currentHoles) => [
|
||||
...currentHoles,
|
||||
{
|
||||
hole_number: currentHoles.length + 1,
|
||||
par: "",
|
||||
hcp_index: "",
|
||||
lengths_by_tee: {},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeLastHole = () => {
|
||||
const newHoles = holes.slice(0, -1);
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
updateHoles((currentHoles) => currentHoles.slice(0, -1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-6">
|
||||
<div className="flex flex-wrap gap-4 items-center bg-gray-100 p-4 rounded-xl border-2 border-gray-200">
|
||||
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Aktive Utslagskolonner:</span>
|
||||
{ALL_KEYS.map(k => (
|
||||
<label key={k} className="flex items-center gap-2 text-sm font-bold cursor-pointer text-black">
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<section className="rounded-[1.75rem] border-2 border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Utslag</p>
|
||||
<p className="mt-1 text-sm text-gray-500">Hvert utslag har navn, rating og slope for både herrer og damer.</p>
|
||||
</div>
|
||||
<span className="rounded-xl bg-[#f1f7ed] px-3 py-2 text-[10px] font-black uppercase tracking-widest text-[#11280f]">{tees.length} utslag</span>
|
||||
</div>
|
||||
<div className="mt-5 space-y-4">
|
||||
{tees.map((tee, index) => {
|
||||
const teeKey = String(tee.client_key);
|
||||
return (
|
||||
<div key={teeKey} className="rounded-[1.5rem] border border-gray-200 bg-[#fbfdf8] p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Utslag {index + 1}</p>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeKeys.includes(k)}
|
||||
onChange={() => toggleKey(k)}
|
||||
className="w-5 h-5 accent-[#8bc34a]"
|
||||
placeholder="Navn, f.eks. Gul eller 57"
|
||||
className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-base font-bold text-black outline-none focus:border-[#8bc34a]"
|
||||
value={tee.name || ""}
|
||||
onChange={(e) => updateTeeField(teeKey, "name", e.target.value)}
|
||||
/>
|
||||
{k.toUpperCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="rounded-[1.75rem] border-2 border-blue-100 bg-blue-50/60 p-5 shadow-sm">
|
||||
<p className="text-xs font-black uppercase tracking-widest text-blue-900">Herrer</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{activeKeys.map(k => (
|
||||
<div key={k} className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">{k}</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<input placeholder="Eks: Gul" className="w-full rounded-xl border border-blue-200 bg-white p-3 text-sm font-bold text-black outline-none focus:border-blue-500" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
|
||||
<input placeholder="CR" className="w-full rounded-xl border border-blue-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-blue-500" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-full rounded-xl border border-blue-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-blue-500" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => moveTee(teeKey, -1)} className="btn btn-sm btn-secondary">Flytt opp</button>
|
||||
<button type="button" onClick={() => moveTee(teeKey, 1)} className="btn btn-sm btn-secondary">Flytt ned</button>
|
||||
<button type="button" onClick={() => removeTee(teeKey)} className="btn btn-sm btn-danger">Slett</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-2xl border border-blue-100 bg-blue-50/60 p-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-blue-900">Herrer</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Course rating" className="rounded-xl border border-blue-200 bg-white p-3 text-sm text-black outline-none focus:border-blue-500" value={tee.cr_men ?? ""} onChange={(e) => updateTeeField(teeKey, "cr_men", e.target.value)} />
|
||||
<input placeholder="Slope" className="rounded-xl border border-blue-200 bg-white p-3 text-sm text-black outline-none focus:border-blue-500" value={tee.slope_men ?? ""} onChange={(e) => updateTeeField(teeKey, "slope_men", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.75rem] border-2 border-red-100 bg-red-50/60 p-5 shadow-sm">
|
||||
<p className="text-xs font-black uppercase tracking-widest text-red-900">Damer</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{activeKeys.map(k => (
|
||||
<div key={k} className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">{k}</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<input placeholder="Eks: Rod" className="w-full rounded-xl border border-red-200 bg-white p-3 text-sm font-bold text-black outline-none focus:border-red-500" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
|
||||
<input placeholder="CR" className="w-full rounded-xl border border-red-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-red-500" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-full rounded-xl border border-red-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-red-500" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<section className="rounded-2xl border border-red-100 bg-red-50/60 p-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-red-900">Damer</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Course rating" className="rounded-xl border border-red-200 bg-white p-3 text-sm text-black outline-none focus:border-red-500" value={tee.cr_women ?? ""} onChange={(e) => updateTeeField(teeKey, "cr_women", e.target.value)} />
|
||||
<input placeholder="Slope" className="rounded-xl border border-red-200 bg-white p-3 text-sm text-black outline-none focus:border-red-500" value={tee.slope_women ?? ""} onChange={(e) => updateTeeField(teeKey, "slope_women", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button type="button" onClick={addTee} className="btn btn-md btn-secondary">+ Legg til utslag</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.75rem] border-2 border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Hull for hull</p>
|
||||
<p className="mt-1 text-sm text-gray-500">Hvert hull er et eget kort med par, hcp og lengder per aktiv utslagskolonne.</p>
|
||||
<p className="mt-1 text-sm text-gray-500">Hvert hull lagrer par, hcp og lengde per utslag.</p>
|
||||
</div>
|
||||
<span className="rounded-xl bg-[#f1f7ed] px-3 py-2 text-[10px] font-black uppercase tracking-widest text-[#11280f]">{holes.length} hull</span>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
{holes.map((h, idx) => (
|
||||
<div key={idx} className="rounded-[1.5rem] border border-gray-200 bg-[#fbfdf8] p-4 shadow-sm">
|
||||
{holes.map((hole, holeIndex) => (
|
||||
<div key={hole.id || holeIndex} className="rounded-[1.5rem] border border-gray-200 bg-[#fbfdf8] p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-lg font-black text-[#11280f]">Hull {h.hole_number}</p>
|
||||
<span className="rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-400 shadow-sm">{activeKeys.length} utslag</span>
|
||||
<p className="text-lg font-black text-[#11280f]">Hull {hole.hole_number}</p>
|
||||
<span className="rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-400 shadow-sm">{tees.length} utslag</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Par</label>
|
||||
<input type="number" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-bold text-black outline-none focus:border-[#8bc34a]" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} />
|
||||
<input type="number" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-bold text-black outline-none focus:border-[#8bc34a]" value={hole.par || ""} onChange={(e) => updateHoleField(holeIndex, "par", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">HCP</label>
|
||||
<input type="number" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-bold text-black outline-none focus:border-[#8bc34a]" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} />
|
||||
<input type="number" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-bold text-black outline-none focus:border-[#8bc34a]" value={hole.hcp_index || ""} onChange={(e) => updateHoleField(holeIndex, "hcp_index", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{activeKeys.map(k => (
|
||||
<div key={k} className="rounded-2xl bg-white p-3 shadow-sm">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">{k}</label>
|
||||
<input type="number" placeholder="Lengde" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-mono font-bold text-black outline-none focus:border-[#8bc34a]" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
|
||||
{tees.map((tee) => {
|
||||
const teeKey = String(tee.client_key);
|
||||
return (
|
||||
<div key={teeKey} className="rounded-2xl bg-white p-3 shadow-sm">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">{tee.name || "Nytt utslag"}</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Lengde"
|
||||
className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-mono font-bold text-black outline-none focus:border-[#8bc34a]"
|
||||
value={hole.lengths_by_tee?.[teeKey] || ""}
|
||||
onChange={(e) => updateHoleLength(holeIndex, teeKey, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="hidden overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
|
||||
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
|
||||
<th className="p-3 border-r border-gray-200">Hull</th>
|
||||
<th className="p-3 border-r border-gray-200">Par</th>
|
||||
<th className="p-3 border-r border-gray-300">HCP</th>
|
||||
{activeKeys.map(k => <th key={k} className="p-3 border-r border-gray-300 w-32">{k}</th>)}
|
||||
</tr>
|
||||
{/* Herrer */}
|
||||
<tr className="bg-blue-50 border-b border-gray-300">
|
||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-blue-900 uppercase tracking-widest border-r border-gray-300">
|
||||
Herrer (Navn / CR / Slope)
|
||||
</th>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input placeholder="Eks: Gul" className="w-full p-2 text-xs font-bold text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
|
||||
<div className="flex gap-1">
|
||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* Damer */}
|
||||
<tr className="bg-red-50 border-b-4 border-gray-400">
|
||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-red-900 uppercase tracking-widest border-r border-gray-300">
|
||||
Damer (Navn / CR / Slope)
|
||||
</th>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input placeholder="Eks: Rød" className="w-full p-2 text-xs font-bold text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
|
||||
<div className="flex gap-1">
|
||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holes.map((h, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-200 hover:bg-gray-50">
|
||||
<td className="p-2 font-black text-lg text-gray-800 border-r border-gray-200">{h.hole_number}</td>
|
||||
<td className="p-2 border-r border-gray-200"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} /></td>
|
||||
<td className="p-2 border-r border-gray-300"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} /></td>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 bg-gray-50/50">
|
||||
<input type="number" placeholder="Lengde" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-mono font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-2 sm:flex-row">
|
||||
<button onClick={addHole} className="btn btn-md btn-secondary">+ Legg til hull</button>
|
||||
<button onClick={removeLastHole} className="btn btn-md btn-danger">- Slett siste hull</button>
|
||||
<button type="button" onClick={addHole} className="btn btn-md btn-secondary">+ Legg til hull</button>
|
||||
<button type="button" onClick={removeLastHole} className="btn btn-md btn-danger">- Slett siste hull</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -574,12 +617,12 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
|||
architect: '',
|
||||
is_main_course: existingCourses.length === 0,
|
||||
slope_valid_until: '',
|
||||
tee_boxes: { herrer: [], damer: [] },
|
||||
tees: [],
|
||||
holes: Array.from({ length: 18 }, (_, index) => ({
|
||||
hole_number: index + 1,
|
||||
par: '',
|
||||
hcp_index: '',
|
||||
lengths: {},
|
||||
lengths_by_tee: {},
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
"use client";
|
||||
import { useState } from 'react';
|
||||
import { STATUS_MAP } from "@/config/constants";
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Gender = 'herrer' | 'damer';
|
||||
|
||||
// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene
|
||||
const getTeeTheme = (label: string) => {
|
||||
const name = label.toLowerCase();
|
||||
if (name.includes("svart") || name.includes("black")) {
|
||||
|
|
@ -25,67 +23,78 @@ const getTeeTheme = (label: string) => {
|
|||
if (name.includes("grønn") || name.includes("gronn") || name.includes("green")) {
|
||||
return { header: "bg-emerald-500 text-white", col: "bg-emerald-50", text: "text-emerald-900" };
|
||||
}
|
||||
|
||||
// DEFAULT: Nøytral grå for utslag med tall (f.eks "52", "45")
|
||||
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
|
||||
};
|
||||
|
||||
const getTeeKey = (tee: any) => String(tee?.id || tee?.client_key || "");
|
||||
const getTeeLabel = (tee: any, index: number) => String(tee?.name || `Utslag ${index + 1}`).trim();
|
||||
|
||||
const getHoleLength = (hole: any, teeKey: string) => {
|
||||
const value = hole?.lengths_by_tee?.[teeKey];
|
||||
return typeof value === "number" || typeof value === "string" ? value : null;
|
||||
};
|
||||
|
||||
export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) {
|
||||
const [hcp, setHcp] = useState("15.0");
|
||||
const [gender, setGender] = useState<Gender>('herrer');
|
||||
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
|
||||
|
||||
const allHoles = course.holes || [];
|
||||
const allHoles = Array.isArray(course.holes) ? course.holes : [];
|
||||
const holesOut = allHoles.filter((h: any) => h.hole_number <= 9);
|
||||
const holesIn = allHoles.filter((h: any) => h.hole_number > 9);
|
||||
const hasInHoles = holesIn.length > 0;
|
||||
|
||||
const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
||||
const activeLengthKeys = lengthKeys.filter((key) => allHoles.some((h: any) => h.lengths?.[key]));
|
||||
const availableTees = course.tee_boxes?.[gender] || [];
|
||||
const fallbackTees = course.tee_boxes?.[gender === 'damer' ? 'herrer' : 'damer'] || [];
|
||||
|
||||
const getTeeForColumn = (tees: any[], teeIndex: number, activePosition: number) => {
|
||||
if (!Array.isArray(tees) || tees.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Older/broken saves may store only active tees in sequence instead of the full 6-slot layout.
|
||||
if (tees.length < lengthKeys.length) {
|
||||
return tees[activePosition];
|
||||
}
|
||||
|
||||
return tees[teeIndex];
|
||||
};
|
||||
|
||||
const getTeeLabel = (tee: any, fallbackTee: any, fallback: string) => {
|
||||
const primaryLabel = gender === 'damer' ? tee?.navn_utslag_damer : tee?.navn_utslag;
|
||||
const secondaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag : fallbackTee?.navn_utslag_damer;
|
||||
const tertiaryLabel = gender === 'damer' ? tee?.navn_utslag : tee?.navn_utslag_damer;
|
||||
const quaternaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag_damer : fallbackTee?.navn_utslag;
|
||||
return String(primaryLabel || secondaryLabel || tertiaryLabel || quaternaryLabel || fallback).trim();
|
||||
};
|
||||
|
||||
const activeColumns = activeLengthKeys.map((key, activePosition) => {
|
||||
const teeIndex = lengthKeys.indexOf(key);
|
||||
const tee = getTeeForColumn(availableTees, teeIndex, activePosition);
|
||||
const fallbackTee = getTeeForColumn(fallbackTees, teeIndex, activePosition);
|
||||
const label = getTeeLabel(tee, fallbackTee, key[0].toUpperCase() + key.slice(1));
|
||||
return { key, teeIndex, activePosition, label, theme: getTeeTheme(label) };
|
||||
const availableTees = (Array.isArray(course.tees) ? [...course.tees] : [])
|
||||
.sort((a: any, b: any) => {
|
||||
const orderA = typeof a?.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER;
|
||||
const orderB = typeof b?.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return Number(a?.id || 0) - Number(b?.id || 0);
|
||||
})
|
||||
.filter((tee: any) => {
|
||||
const teeKey = getTeeKey(tee);
|
||||
return teeKey && allHoles.some((hole: any) => getHoleLength(hole, teeKey) !== null);
|
||||
});
|
||||
|
||||
// Kalkulering av SpH
|
||||
const selectedColumn = activeColumns.find((column) => column.teeIndex === selectedTeeIndex) || activeColumns[0] || null;
|
||||
const activeTee = selectedColumn
|
||||
? getTeeForColumn(availableTees, selectedColumn.teeIndex, selectedColumn.activePosition)
|
||||
: undefined;
|
||||
let playingHandicap = 0;
|
||||
const [selectedTeeKey, setSelectedTeeKey] = useState(() => getTeeKey(availableTees[0]));
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTees.length) {
|
||||
setSelectedTeeKey("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!availableTees.some((tee: any) => getTeeKey(tee) === selectedTeeKey)) {
|
||||
setSelectedTeeKey(getTeeKey(availableTees[0]));
|
||||
}
|
||||
}, [availableTees, selectedTeeKey]);
|
||||
|
||||
const activeColumns = availableTees.map((tee: any, index: number) => ({
|
||||
tee,
|
||||
teeKey: getTeeKey(tee),
|
||||
label: getTeeLabel(tee, index),
|
||||
theme: getTeeTheme(getTeeLabel(tee, index)),
|
||||
}));
|
||||
|
||||
const selectedColumn = activeColumns.find((column) => column.teeKey === selectedTeeKey) || activeColumns[0] || null;
|
||||
const activeTee = selectedColumn?.tee || null;
|
||||
const selectedTeeLabel = selectedColumn?.label || "Valgt utslag";
|
||||
|
||||
let playingHandicap = 0;
|
||||
if (activeTee && hcp) {
|
||||
const exactHcp = Number(hcp.replace(',', '.'));
|
||||
const slope = Number(activeTee.slopeverdi || activeTee.slopeverdi_damer || 113);
|
||||
const cr = Number(String(activeTee.baneverdi || activeTee.baneverdi_damer || course.par).replace(',', '.'));
|
||||
playingHandicap = Math.round((exactHcp * (slope / 113)) + (cr - course.par));
|
||||
const slope = Number(
|
||||
gender === 'damer'
|
||||
? activeTee.slope_women || activeTee.slope_men || 113
|
||||
: activeTee.slope_men || activeTee.slope_women || 113
|
||||
);
|
||||
const courseRating = Number(
|
||||
String(
|
||||
gender === 'damer'
|
||||
? activeTee.cr_women || activeTee.cr_men || course.par
|
||||
: activeTee.cr_men || activeTee.cr_women || course.par
|
||||
).replace(',', '.')
|
||||
);
|
||||
playingHandicap = Math.round((exactHcp * (slope / 113)) + (courseRating - course.par));
|
||||
}
|
||||
|
||||
const getExtraStrokes = (hcpIndex: number) => {
|
||||
|
|
@ -96,23 +105,16 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
|||
};
|
||||
|
||||
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
|
||||
const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0);
|
||||
const selectedTeeLabel = activeTee
|
||||
? getTeeLabel(
|
||||
activeTee,
|
||||
selectedColumn ? getTeeForColumn(fallbackTees, selectedColumn.teeIndex, selectedColumn.activePosition) : undefined,
|
||||
selectedColumn?.label || 'Valgt utslag'
|
||||
)
|
||||
: 'Valgt utslag';
|
||||
const sumLen = (holes: any[], teeKey: string) =>
|
||||
holes.reduce((acc, h) => acc + (Number(getHoleLength(h, teeKey)) || 0), 0);
|
||||
|
||||
// Formater utløpsdato
|
||||
const slopeExpiry = course.slope_valid_until
|
||||
? new Date(course.slope_valid_until).toLocaleDateString('nb-NO', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: 'Ukjent';
|
||||
|
||||
const renderMobileHoleCard = (hole: any) => {
|
||||
const extra = getExtraStrokes(hole.hcp_index);
|
||||
const selectedLength = selectedColumn ? hole.lengths?.[selectedColumn.key] : null;
|
||||
const selectedLength = selectedColumn ? getHoleLength(hole, selectedColumn.teeKey) : null;
|
||||
|
||||
return (
|
||||
<div key={hole.id} className="rounded-[1.6rem] border border-gray-100 bg-[#fbfcf8] p-4 shadow-sm">
|
||||
|
|
@ -155,7 +157,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
|||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-3">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-white/60">{selectedTeeLabel}</p>
|
||||
<p className="mt-1 text-xl font-black">{selectedColumn ? sumLen(holes, selectedColumn.key) : '--'}</p>
|
||||
<p className="mt-1 text-xl font-black">{selectedColumn ? sumLen(holes, selectedColumn.teeKey) : '--'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -163,66 +165,46 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
|||
|
||||
return (
|
||||
<div className="mb-12 w-full min-w-0 max-w-[100vw] overflow-x-clip rounded-[2rem] border border-gray-200 bg-white shadow-sm md:rounded-[3rem]">
|
||||
|
||||
{/* HEADER / KALKULATOR */}
|
||||
<div className="flex min-w-0 flex-col items-stretch justify-between gap-5 border-b border-gray-100 bg-white p-4 sm:p-5 md:flex-row md:items-center md:gap-8 md:p-12">
|
||||
<div className="min-w-0 text-left">
|
||||
{courseDisplayName ? (
|
||||
<h2 className="break-words text-4xl font-black tracking-tighter text-[#11280f] sm:text-5xl">{courseDisplayName}</h2>
|
||||
) : null}
|
||||
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2 mb-1">
|
||||
<p className="mt-2 mb-1 text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">
|
||||
Par {course.par} • {course.length_meters || '--'} meter
|
||||
</p>
|
||||
<p className="text-gray-400 text-[10px] font-bold uppercase tracking-widest">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
||||
Rating utløper: {slopeExpiry}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full min-w-0 max-w-full grid-cols-1 gap-3 rounded-[1.6rem] border border-gray-100 bg-gray-50 p-4 sm:grid-cols-2 md:flex md:w-auto md:max-w-none md:items-center md:gap-6 md:rounded-[2rem] md:p-6">
|
||||
<div className="flex min-w-0 flex-col"><span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Kjønn</span>
|
||||
<select value={gender} onChange={e => {
|
||||
const nextGender = e.target.value as Gender;
|
||||
const nextTees = course.tee_boxes?.[nextGender] || [];
|
||||
const nextFallbackTees = course.tee_boxes?.[nextGender === 'damer' ? 'herrer' : 'damer'] || [];
|
||||
const nextFirstTeeIndex = activeLengthKeys.findIndex((key, activePosition) =>
|
||||
Boolean(
|
||||
String(
|
||||
nextGender === 'damer'
|
||||
? getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
|
||||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
|
||||
getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
|
||||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
|
||||
""
|
||||
: getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
|
||||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
|
||||
getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
|
||||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
|
||||
""
|
||||
).trim()
|
||||
)
|
||||
);
|
||||
setGender(nextGender);
|
||||
setSelectedTeeIndex(nextFirstTeeIndex >= 0 ? lengthKeys.indexOf(activeLengthKeys[nextFirstTeeIndex]) : 0);
|
||||
}} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 text-[#11280f] font-black outline-none cursor-pointer">
|
||||
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Kjønn</span>
|
||||
<select value={gender} onChange={(e) => setGender(e.target.value as Gender)} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 font-black text-[#11280f] outline-none cursor-pointer">
|
||||
<option value="herrer">HERRER</option>
|
||||
<option value="damer">DAMER</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col"><span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Utslag</span>
|
||||
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 text-[#11280f] font-black outline-none cursor-pointer">
|
||||
{activeColumns.map((col) => (<option key={col.teeIndex} value={col.teeIndex}>{col.label}</option>))}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Utslag</span>
|
||||
<select value={selectedColumn?.teeKey || ""} onChange={(e) => setSelectedTeeKey(e.target.value)} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 font-black text-[#11280f] outline-none cursor-pointer">
|
||||
{activeColumns.map((column) => (
|
||||
<option key={column.teeKey} value={column.teeKey}>{column.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col"><span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Ditt HCP</span>
|
||||
<input type="text" value={hcp} onChange={e => setHcp(e.target.value)} className="w-full min-w-0 border-b-2 border-[#7ca982]/30 bg-transparent px-1 text-left font-black text-[#11280f] outline-none md:w-16 md:text-center" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Ditt HCP</span>
|
||||
<input type="text" value={hcp} onChange={(e) => setHcp(e.target.value)} className="w-full min-w-0 border-b-2 border-[#7ca982]/30 bg-transparent px-1 text-left font-black text-[#11280f] outline-none md:w-16 md:text-center" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-[1.4rem] bg-white px-4 py-3 md:col-span-1 md:justify-center md:border-l md:border-gray-200 md:bg-transparent md:px-0 md:py-0 md:pl-6">
|
||||
<p className="mb-1 text-[9px] font-black uppercase text-[#7ca982]">SpH</p>
|
||||
<p className="text-4xl font-black text-[#11280f] leading-none">{playingHandicap || 0}</p>
|
||||
<p className="text-4xl font-black leading-none text-[#11280f]">{playingHandicap || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MOBIL SCOREKORT */}
|
||||
<div className="space-y-4 p-4 md:hidden">
|
||||
<div className="rounded-[1.8rem] bg-[#f5f8ef] p-4 text-sm text-[#617063] shadow-sm">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Mobilscorekort</p>
|
||||
|
|
@ -246,90 +228,84 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
|||
{renderMobileSummaryCard("Totalt", allHoles)}
|
||||
</div>
|
||||
|
||||
{/* SCOREKORT TABELL */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-center border-collapse table-fixed min-w-[850px]">
|
||||
<table className="w-full min-w-[850px] table-fixed border-collapse text-center">
|
||||
<thead>
|
||||
<tr className="bg-white text-[10px] text-gray-400 font-black uppercase">
|
||||
<th className="w-20 p-5 text-left pl-10 border-b border-gray-100">Hull</th>
|
||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">Par</th>
|
||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">HCP</th>
|
||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/10 text-[#7ca982]">Mottatt</th>
|
||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/20 text-[#11280f]">Din Par</th>
|
||||
{activeColumns.map((col, i) => (
|
||||
<th key={i} className={`p-5 border-l border-white font-black ${col.theme.header}`}>{col.label}</th>
|
||||
<tr className="bg-white text-[10px] font-black uppercase text-gray-400">
|
||||
<th className="w-20 border-b border-gray-100 p-5 pl-10 text-left">Hull</th>
|
||||
<th className="w-16 border-l border-b border-gray-100 p-5">Par</th>
|
||||
<th className="w-16 border-l border-b border-gray-100 p-5">HCP</th>
|
||||
<th className="w-24 border-l border-b border-gray-100 bg-[#7ca982]/10 p-5 text-[#7ca982]">Mottatt</th>
|
||||
<th className="w-24 border-l border-b border-gray-100 bg-[#7ca982]/20 p-5 text-[#11280f]">Din Par</th>
|
||||
{activeColumns.map((column) => (
|
||||
<th key={column.teeKey} className={`border-l border-white p-5 font-black ${column.theme.header}`}>{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-bold text-[#11280f]">
|
||||
{/* UT-RUNDE */}
|
||||
{holesOut.map((h: any) => {
|
||||
const extra = getExtraStrokes(h.hcp_index);
|
||||
{holesOut.map((hole: any) => {
|
||||
const extra = getExtraStrokes(hole.hcp_index);
|
||||
return (
|
||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
||||
{h.lengths?.[col.key] || '--'}
|
||||
<tr key={hole.id} className="group border-t border-gray-100 transition-colors hover:bg-white">
|
||||
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
|
||||
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
|
||||
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
|
||||
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
|
||||
{activeColumns.map((column) => (
|
||||
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>
|
||||
{getHoleLength(hole, column.teeKey) || '--'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* UT RAD */}
|
||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Ut</td>
|
||||
<td className="p-4 border-l border-gray-100">{sumPar(holesOut)}</td>
|
||||
<tr className="border-y border-gray-200 bg-[#f1f7ed]/50 font-black text-[#11280f]">
|
||||
<td className="p-4 pl-10 text-left text-[10px] uppercase tracking-widest text-gray-400">Ut</td>
|
||||
<td className="border-l border-gray-100 p-4">{sumPar(holesOut)}</td>
|
||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesOut, col.key)}</td>
|
||||
{activeColumns.map((column) => (
|
||||
<td key={column.teeKey} className={`border-l border-white p-4 font-mono text-gray-900 ${column.theme.col}`}>{sumLen(holesOut, column.teeKey)}</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* INN-RUNDE */}
|
||||
{hasInHoles && holesIn.map((h: any) => {
|
||||
const extra = getExtraStrokes(h.hcp_index);
|
||||
{hasInHoles && holesIn.map((hole: any) => {
|
||||
const extra = getExtraStrokes(hole.hcp_index);
|
||||
return (
|
||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
||||
{h.lengths?.[col.key] || '--'}
|
||||
<tr key={hole.id} className="group border-t border-gray-100 transition-colors hover:bg-white">
|
||||
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
|
||||
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
|
||||
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
|
||||
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
|
||||
{activeColumns.map((column) => (
|
||||
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>
|
||||
{getHoleLength(hole, column.teeKey) || '--'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* INN RAD */}
|
||||
{hasInHoles && (
|
||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Inn</td>
|
||||
<td className="p-4 border-l border-gray-100">{sumPar(holesIn)}</td>
|
||||
<tr className="border-y border-gray-200 bg-[#f1f7ed]/50 font-black text-[#11280f]">
|
||||
<td className="p-4 pl-10 text-left text-[10px] uppercase tracking-widest text-gray-400">Inn</td>
|
||||
<td className="border-l border-gray-100 p-4">{sumPar(holesIn)}</td>
|
||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesIn, col.key)}</td>
|
||||
{activeColumns.map((column) => (
|
||||
<td key={column.teeKey} className={`border-l border-white p-4 font-mono text-gray-900 ${column.theme.col}`}>{sumLen(holesIn, column.teeKey)}</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* TOTAL RAD */}
|
||||
<tr className="bg-[#11280f] text-white text-xl font-black">
|
||||
<td className="p-8 text-left pl-10 uppercase tracking-tighter">Totalt</td>
|
||||
<td className="p-8 border-l border-white/10">{sumPar(allHoles)}</td>
|
||||
<tr className="bg-[#11280f] text-xl font-black text-white">
|
||||
<td className="p-8 pl-10 text-left uppercase tracking-tighter">Totalt</td>
|
||||
<td className="border-l border-white/10 p-8">{sumPar(allHoles)}</td>
|
||||
<td colSpan={3} className="border-l border-white/10 bg-[#1a3a17]"></td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-8 border-l border-white/10 font-mono ${col.theme.header.split(' ')[0]}`}>
|
||||
{sumLen(allHoles, col.key)}
|
||||
{activeColumns.map((column) => (
|
||||
<td key={column.teeKey} className={`border-l border-white/10 p-8 font-mono ${column.theme.header.split(' ')[0]}`}>
|
||||
{sumLen(allHoles, column.teeKey)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ CREATE TABLE tees (
|
|||
id SERIAL PRIMARY KEY,
|
||||
course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL, -- F.eks. '64', 'Gul', 'Rød'
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
cr_men NUMERIC(4, 1),
|
||||
slope_men INTEGER,
|
||||
cr_women NUMERIC(4, 1),
|
||||
|
|
@ -83,3 +84,5 @@ CREATE TABLE hole_lengths (
|
|||
tee_id INTEGER REFERENCES tees(id) ON DELETE CASCADE,
|
||||
length_meters INTEGER
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX hole_lengths_hole_tee_uidx ON hole_lengths (hole_id, tee_id);
|
||||
|
|
|
|||
Loading…
Reference in a new issue