<template>
  <div class="shopfloor-dashboard">
    <div class="dash-fit" ref="fitEl">
    <header class="dashboard-header">
      <div class="logo-container">
        <img :src="siteLogo" alt="Site logo" class="logo" />
      </div>

      <div class="header-center">
        <div class="location-title">{{ facilityName }}</div>
        <h1>Shopfloor Dashboard</h1>
      </div>

      <div class="header-right">
        <div class="clock">{{ currentTime }}</div>
        <select v-model="businessUnit" class="filter-select" @change="loadData">
          <option value="">All Locations</option>
          <option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
            {{ bu.businessunit }}
          </option>
        </select>
      </div>
    </header>

    <main class="dashboard-content">
      <!--
        One section per TYPE, in style order (banner, carousel, grid). Grouping
        by display style alone merged every grid type into Recertification's
        row and every carousel type into Recognition's, under that type's
        heading. Each type now carries its own row titled by its own name.
      -->
      <template v-for="group in styledGroups" :key="group.key">

        <!-- Banner - full-width strips, one per message -->
        <section v-if="group.displaystyle === 'banner'" class="banner-section">
          <div
            v-for="n in group.items"
            :key="n.notificationid"
            class="banner-strip"
            :class="[stateKind(n) ? 'pending' : '', stateKind(n)]"
            :style="{ backgroundColor: getTypeColor(n.typecolor) }"
          >
            <span v-if="stateLabel(n)" class="state-chip" :class="stateKind(n)">{{ stateLabel(n) }}</span>
            {{ n.notification }}
          </div>
        </section>

        <!-- Carousel - one card at a time, rotating -->
        <section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
          <div
            class="section-title recognition"
            :style="headingStyle(group)"
          >{{ group.typename }}</div>
          <div class="recognition-carousel">
            <div
              v-for="(rec, idx) in group.items"
              :key="`${rec.notificationid}-${rec.employeesso}`"
              class="recognition-card"
              :class="[{ active: idx === carouselIndex(group) }, stateKind(rec) ? 'pending' : '', stateKind(rec)]"
              :style="{ '--accent': getTypeColor(rec.typecolor) }"
            >
              <!-- A type that names no employee is a message, not a person:
                   no photo frame, no empty name line. -->
              <div v-if="hasEmployee(rec)" class="recognition-photo-container">
                <img
                  v-if="rec.employeepicture"
                  :src="rec.employeepicture"
                  :alt="rec.employeename"
                  class="recognition-photo"
                  @error="handlePhotoError"
                />
                <img
                  v-else
                  :src="employeePhotoFallback"
                  alt="No photo"
                  class="recognition-photo ge-logo-fallback"
                />
              </div>
              <div class="recognition-content">
                <div v-if="hasEmployee(rec)" class="recognition-header">
                  <div class="recognition-name">{{ rec.employeename }}</div>
                </div>
                <!-- On a shared category row the heading names the category, so
                     the card has to say which type it is. Named, not just
                     coloured: colour alone is no use to a colourblind reader
                     across a 1920 board. -->
                <div class="chip-row">
                  <span
                    v-if="isMixed(group)"
                    class="type-chip"
                    :style="{ backgroundColor: getTypeColor(rec.typecolor), color: textOn(rec.typecolor) }"
                  >{{ rec.typename }}</span>
                  <span v-if="stateLabel(rec)" class="state-chip" :class="stateKind(rec)">{{ stateLabel(rec) }}</span>
                </div>
                <div class="recognition-message">{{ rec.notification }}</div>
              </div>
            </div>
          </div>
          <!-- One pip per card, so a reader knows how many are in the rotation
               and how long until theirs comes back round. -->
          <div v-if="group.items.length > 1" class="carousel-pips">
            <span
              v-for="(rec, idx) in group.items"
              :key="`pip-${rec.notificationid}-${rec.employeesso}`"
              class="pip"
              :class="{ on: idx === carouselIndex(group), scheduled: rec.upcoming }"
              :style="{ backgroundColor: getTypeColor(rec.typecolor) }"
            ></span>
          </div>
        </section>

        <!-- Grid - a row of tiles, cycling a page at a time -->
        <section v-else class="recert-section">
          <div
            class="section-title recert-title"
            :style="headingStyle(group)"
          >
            <span>{{ group.typename }} ({{ group.items.length }})</span>
            <span v-if="gridRangeLabel(group)" class="recert-range">{{ gridRangeLabel(group) }}</span>
          </div>
          <div
            v-for="msg in groupDescriptions(group)"
            :key="msg"
            class="recert-description"
          >
            {{ msg }}
          </div>
          <div class="recert-row">
            <div
              v-for="rec in gridPageItems(group)"
              :key="`grid-${rec.notificationid}-${rec.employeesso}`"
              class="recert-tile"
              :class="[{ 'message-tile': !hasEmployee(rec) }, stateKind(rec) ? 'pending' : '', stateKind(rec)]"
              :style="{ '--accent': getTypeColor(rec.typecolor) }"
            >
              <template v-if="hasEmployee(rec)">
                <img
                  v-if="rec.employeepicture"
                  :src="rec.employeepicture"
                  :alt="rec.employeename"
                  class="recert-photo"
                  @error="handlePhotoError"
                />
                <img
                  v-else
                  :src="employeePhotoFallback"
                  alt="No photo"
                  class="recert-photo ge-logo-fallback"
                />
                <div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
              </template>
              <!-- No employee: the message IS the tile, so a grid type like
                   Awareness does not render rows of blank placeholder faces. -->
              <div v-else class="recert-message">{{ rec.notification }}</div>
              <span v-if="stateLabel(rec)" class="state-chip tile-state" :class="stateKind(rec)">{{ stateLabel(rec) }}</span>
            </div>
          </div>
        </section>

      </template>

      <!-- Current Notifications -->
      <section v-if="currentNotifications.length" class="notifications-section">
        <div class="section-title" :style="itemsHeadingStyle(currentNotifications)">
          Current Notifications
        </div>
        <div class="events-list">
          <div
            v-for="n in currentNotifications"
            :key="n.notificationid"
            class="event-card"
            :class="{ resolved: n.resolved }"
          >
            <div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
            <div class="event-content">
              <div class="event-title">{{ n.notification }}</div>
              <div class="event-time">
                <template v-if="n.resolved">
                  <strong>RESOLVED</strong>
                </template>
                <template v-else>
                  <strong>{{ formatTime(n.starttime) }}</strong>
                  <span v-if="n.endtime"> - {{ formatTime(n.endtime) }}</span>
                </template>
              </div>
            </div>
            <template v-if="n.ticketnumber">
              <a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
                {{ n.ticketnumber }}
              </a>
              <span v-else class="event-ticket">{{ n.ticketnumber }}</span>
            </template>
          </div>
        </div>
      </section>

      <!-- Upcoming Notifications -->
      <section v-if="upcomingNotifications.length" class="notifications-section">
        <div class="section-title upcoming" :style="itemsHeadingStyle(upcomingNotifications)">Upcoming</div>
        <div class="events-list">
          <div
            v-for="n in upcomingNotifications"
            :key="n.notificationid"
            class="event-card"
          >
            <div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
            <div class="event-content">
              <div class="event-title">{{ n.notification }}</div>
              <div class="event-time">
                <strong>{{ formatDateTime(n.starttime) }}</strong>
              </div>
            </div>
            <template v-if="n.ticketnumber">
              <a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
                {{ n.ticketnumber }}
              </a>
              <span v-else class="event-ticket">{{ n.ticketnumber }}</span>
            </template>
          </div>
        </div>
      </section>

      <!-- No notifications -->
      <div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !styledGroups.length" class="no-events">
        No active notifications
      </div>

      <div v-if="loading" class="loading">Loading...</div>
    </main>

    <footer class="dashboard-footer">
      Auto-refreshes every 30 seconds
    </footer>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi, settingsApi } from '@/api'
import { formatInZone, zonedInputFromUtc, DEFAULT_TZ } from '@/utils/datetime'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'

const loading = ref(true)
const facilityName = ref('ShopDB')
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
// Employee photo placeholder: the shipped GE monogram (square avatar). Kept
// separate from siteLogo so a blank/broken site_logo setting never leaves an
// employee tile with no image.
const employeePhotoFallback = ref(withBase('/ge-monogram.svg'))
// ServiceNow ticket-link config; loaded on mount. Empty/disabled = plain text.
const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
const businessUnit = ref('')
const businessUnits = ref([])
const notifications = ref({ current: [], upcoming: [] })
// Layout-config fingerprint from the feed; when it changes the kiosk reloads.
const loadedConfigVersion = ref(null)

let refreshInterval = null
let recognitionInterval = null
let recertPageInterval = null

// Fit-to-viewport: the board is a fixed 1920-wide design; scale the whole thing
// to fill the screen so a TV shows everything with no scrolling, at any size.
const fitEl = ref(null)
let resizeObserver = null
function fitToScreen() {
  const el = fitEl.value
  if (!el) return
  const w = el.offsetWidth || 1
  const h = el.offsetHeight || 1
  const scale = Math.min(window.innerWidth / w, window.innerHeight / h)
  el.style.transform = `scale(${scale})`
  el.style.left = `${Math.max(0, (window.innerWidth - w * scale) / 2)}px`
}

// The board groups cards by each type's configured display style, so any custom
// type set to carousel/grid/banner renders that way - not just the built-ins.
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']

// Rows run in the order each type is given (boardorder, low first), so a site
// decides what leads the screen. Style is only the tie-break, for types left on
// the same number: strips, then rotating cards, then tile rows.
const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
const DEFAULT_BOARD_ORDER = 100

// One row per TYPE, unless types opt into a shared boardcategory - Change,
// Awareness and Incident under one "Alerts" heading, say, while Recognition and
// Recertification keep rows of their own. Grouping by display style alone
// folded every grid type into one row under someone else's name.
// The style is part of the key: a category cannot merge a banner with a tile
// row, so same-category types of different styles still render separately.
const styledGroups = computed(() => {
  const groups = new Map()
  // Upcoming cards belong here too. They used to be filtered into the standard
  // Upcoming list, which drops anything with a special style - so a scheduled
  // banner or carousel card rendered nowhere at all until it went live.
  const tagged = [
    ...notifications.value.current.map(n => ({ ...n, upcoming: false })),
    ...notifications.value.upcoming.map(n => ({ ...n, upcoming: true }))
  ]
  for (const n of tagged) {
    if (!SPECIAL_STYLES.includes(n.displaystyle)) continue
    const heading = (n.boardcategory || '').trim() || n.typename || n.displaystyle
    const key = `${n.displaystyle}|${heading}`
    if (!groups.has(key)) {
      groups.set(key, {
        key,
        typename: heading,
        displaystyle: n.displaystyle,
        // Heading colour comes from the first type in the row. A shared
        // category is one heading, so it can only carry one colour; the
        // individual cards below still wear their own type's colour.
        typecolor: n.typecolor,
        // A shared row sits wherever its earliest-ordered type puts it.
        boardorder: DEFAULT_BOARD_ORDER,
        items: []
      })
    }
    const group = groups.get(key)
    group.items.push(n)
    const order = Number.isFinite(n.boardorder) ? n.boardorder : DEFAULT_BOARD_ORDER
    group.boardorder = Math.min(group.boardorder, order)
  }
  // Live cards lead each row; what has not started yet follows.
  for (const group of groups.values()) {
    group.items.sort((a, b) => Number(a.upcoming) - Number(b.upcoming))
  }
  return [...groups.values()].sort((a, b) =>
    (a.boardorder - b.boardorder) ||
    (STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
    a.typename.localeCompare(b.typename)
  )
})

// A card belongs to a person only if it names one; a plain message type set to
// carousel or grid has no SSO and must not render a placeholder face.
function hasEmployee(n) {
  return !!(n.employeesso || n.employeename || n.employeepicture)
}

// Distinct messages shown once above a grid row (the per-person tiles carry
// only photo + name, so the message would otherwise have nowhere to go).
function groupDescriptions(group) {
  if (!group.items.some(hasEmployee)) return []
  const seen = new Set()
  const out = []
  for (const item of group.items) {
    const msg = (item.notification || '').trim()
    if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) }
  }
  return out
}

// Rotation state is per group, keyed by type: two grid rows page independently
// instead of sharing one index.
const GRID_PAGE_SIZE = 8
const carouselIndexes = ref({})
const gridPages = ref({})

function carouselIndex(group) {
  return (carouselIndexes.value[group.key] || 0) % Math.max(1, group.items.length)
}
function gridPageCount(group) {
  return Math.max(1, Math.ceil(group.items.length / GRID_PAGE_SIZE))
}
function gridPageItems(group) {
  const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
  const start = page * GRID_PAGE_SIZE
  return group.items.slice(start, start + GRID_PAGE_SIZE)
}
function gridRangeLabel(group) {
  if (gridPageCount(group) <= 1) return ''
  const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
  const start = page * GRID_PAGE_SIZE
  const end = Math.min(start + GRID_PAGE_SIZE, group.items.length)
  return `${start + 1}-${end} of ${group.items.length}`
}

const currentNotifications = computed(() =>
  notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
)

const upcomingNotifications = computed(() =>
  notifications.value.upcoming.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
)

// Clock
// Site timezone drives every time on the board. Defaults until the setting
// loads, so a slow settings call shows plausible time rather than blank.
const siteTimezone = ref(DEFAULT_TZ)

const currentTime = ref('')
function updateClock() {
  // The wall clock too: a board showing a different time to the cards beneath
  // it is worse than either being wrong on its own.
  currentTime.value = formatInZone(new Date(), siteTimezone.value, {
    year: undefined, month: undefined, day: undefined,
    hour: '2-digit', minute: '2-digit', second: '2-digit'
  })
}

onMounted(async () => {
  // Before the first clock tick, so the board never renders viewer-zone time.
  try {
    const response = await settingsApi.get('site_timezone')
    const value = response?.data?.data?.value
    if (value) siteTimezone.value = value
  } catch (e) {
    // Keep the default zone; the board must still come up.
  }

  updateClock()
  setInterval(updateClock, 1000)

  getFacilityName().then(name => { facilityName.value = name })
  getSiteLogo().then(logo => { siteLogo.value = logo })
  getServicenowUrls().then(config => { servicenowConfig.value = config })

  // Load business units
  try {
    const response = await businessUnitsApi.list()
    businessUnits.value = response.data.data || []
  } catch (err) {
    console.error('Error loading business units:', err)
  }

  // Auto-select this kiosk's business unit from its IP (visitor location),
  // unless one was already chosen. Falls back to "all" when the IP is unmapped.
  if (!businessUnit.value) {
    try {
      const response = await dashboardDefaultsApi.visitorLocation()
      if (response.data.data?.businessunitid) {
        businessUnit.value = response.data.data.businessunitid
      }
    } catch (err) {
      console.error('Error resolving visitor location:', err)
    }
  }

  await loadData()

  // Auto-refresh every 30 seconds
  refreshInterval = setInterval(loadData, 30000)

  // Advance every carousel row every 8 seconds. Each row keeps its own index,
  // so a row of three cards does not drag a row of ten along with it.
  recognitionInterval = setInterval(() => {
    for (const group of styledGroups.value) {
      if (group.displaystyle !== 'carousel' || group.items.length < 2) continue
      const next = (carouselIndexes.value[group.key] || 0) + 1
      carouselIndexes.value[group.key] = next % group.items.length
    }
  }, 8000)

  // Page every grid row every 7 seconds, each on its own page count.
  recertPageInterval = setInterval(() => {
    for (const group of styledGroups.value) {
      if (group.displaystyle !== 'grid') continue
      const pages = gridPageCount(group)
      if (pages < 2) continue
      gridPages.value[group.key] = ((gridPages.value[group.key] || 0) + 1) % pages
    }
  }, 7000)

  // Scale to fit once rendered, and re-fit whenever the content or window
  // changes size (new data, banner cycling, resolution change).
  await nextTick()
  fitToScreen()
  resizeObserver = new ResizeObserver(() => fitToScreen())
  if (fitEl.value) resizeObserver.observe(fitEl.value)
  window.addEventListener('resize', fitToScreen)
})

onUnmounted(() => {
  if (refreshInterval) clearInterval(refreshInterval)
  if (recognitionInterval) clearInterval(recognitionInterval)
  if (recertPageInterval) clearInterval(recertPageInterval)
  resizeObserver?.disconnect()
  window.removeEventListener('resize', fitToScreen)
})

async function loadData() {
  try {
    const params = {}
    if (businessUnit.value) {
      params.businessunit = businessUnit.value
    }
    const response = await notificationsApi.getShopfloor(params)
    const data = response.data.data || { current: [], upcoming: [] }

    // Reload the kiosk when the board's layout config changes, so type/style
    // edits (and deploys, via SHOPFLOOR_BUILD) reach already-open pages without
    // anyone touching the machine.
    const version = data.configversion
    if (version) {
      if (loadedConfigVersion.value && loadedConfigVersion.value !== version) {
        window.location.reload()
        return
      }
      loadedConfigVersion.value = version
    }

    notifications.value = { current: data.current || [], upcoming: data.upcoming || [] }
  } catch (err) {
    console.error('Error loading shopfloor data:', err)
  } finally {
    loading.value = false
  }
}

function getTypeColor(typecolor) {
  // Types store a hex color, used directly. Only legacy Bootstrap color names
  // still need aliasing; anything else (a hex) passes straight through.
  const aliases = {
    success: '#04b962',
    warning: '#ff8800',
    danger: '#f5365c',
    info: '#14abef',
    primary: '#7934f3',
    secondary: '#94614f'
  }
  return aliases[typecolor] || typecolor || '#14abef'
}

// What a card's state chip says, or '' while it is simply live. Resolved is the
// tail a type's grace window buys it; upcoming is scheduled and not yet showing.
function stateLabel(item) {
  if (item.upcoming) return `STARTS ${startsWhen(item.starttime)}`
  return item.resolved ? 'RESOLVED' : ''
}

// '' while live, else which chip treatment the card wears.
function stateKind(item) {
  if (item.upcoming) return 'upcoming'
  return item.resolved ? 'resolved' : ''
}

// When an upcoming card starts, phrased for someone walking past: today and
// tomorrow by name, anything further out by weekday and date. An absolute
// timestamp alone makes a reader do the arithmetic.
function startsWhen(dateStr) {
  if (!dateStr) return 'SOON'
  const time = formatInZone(dateStr, siteTimezone.value, {
    year: undefined, month: undefined, day: undefined,
    hour: 'numeric', minute: '2-digit'
  })
  // TODAY/TOMORROW has to be decided on the SITE's calendar day. Doing the
  // arithmetic on browser-local days makes the wording itself wrong for a
  // viewer in another zone - a card can read TOMORROW to a remote admin and
  // TODAY on the board, from the same data.
  const startday = zonedInputFromUtc(dateStr, siteTimezone.value).slice(0, 10)
  const todayday = zonedInputFromUtc(new Date(), siteTimezone.value).slice(0, 10)
  const days = Math.round(
    (new Date(`${startday}T00:00:00`) - new Date(`${todayday}T00:00:00`)) / 86400000)
  if (days <= 0) return `TODAY ${time}`
  if (days === 1) return `TOMORROW ${time}`
  const when = formatInZone(dateStr, siteTimezone.value, {
    weekday: 'short', month: 'short', day: 'numeric',
    hour: undefined, minute: undefined
  })
  return `${when} ${time}`
}

// A row holding more than one type: the heading names a category, so it cannot
// speak for any single type's colour or name.
function isMixed(group) {
  return new Set(group.items.map(item => item.typename)).size > 1
}

// The colour a set of cards weighs most heavily towards: whichever type has
// the most cards, ties going to the type that sorts first on the board. A blend
// of several type colours just makes mud, and picking the first arbitrarily
// mislabels the rest - a plurality is at least true of the row.
//
// Live cards decide it. Only when nothing on the row has started yet does the
// upcoming set get a say, so a heading always describes what is happening now
// rather than what is scheduled.
function dominantColor(items) {
  const live = items.filter(item => !item.upcoming && !item.resolved)
  const scheduled = items.filter(item => item.upcoming)
  const pool = live.length ? live : (scheduled.length ? scheduled : items)
  const weights = new Map()
  for (const item of pool) {
    const key = item.typecolor || ''
    const seen = weights.get(key) || { count: 0, order: Number.MAX_SAFE_INTEGER }
    seen.count += 1
    seen.order = Math.min(seen.order, Number.isFinite(item.boardorder)
      ? item.boardorder : DEFAULT_BOARD_ORDER)
    weights.set(key, seen)
  }
  let winner = null
  for (const [typecolor, seen] of weights) {
    if (!winner || seen.count > winner.count ||
        (seen.count === winner.count && seen.order < winner.order)) {
      winner = { typecolor, ...seen }
    }
  }
  return winner ? winner.typecolor : null
}

// Every heading on the board takes its colour from the types beneath it; none
// is hardcoded per row or per display style.
function headingStyle(group) {
  return itemsHeadingStyle(group.items)
}

function itemsHeadingStyle(items) {
  const typecolor = dominantColor(items)
  return {
    backgroundColor: getTypeColor(typecolor),
    color: textOn(typecolor)
  }
}

// Text colour for a heading painted an arbitrary type colour. Gold needs dark
// text, navy needs white, and a site picks its own hexes - so this is computed
// rather than a per-colour rule.
function textOn(backgroundcolor) {
  const hex = getTypeColor(backgroundcolor).replace('#', '')
  if (hex.length < 6) return '#fff'
  const r = parseInt(hex.slice(0, 2), 16)
  const g = parseInt(hex.slice(2, 4), 16)
  const b = parseInt(hex.slice(4, 6), 16)
  // Rec. 709 luma, the usual quick contrast test.
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 150 ? '#231b00' : '#fff'
}

// SITE zone, not the viewer's. A board hangs on a wall in the plant: it must
// read plant time whatever the machine driving it is set to, and a kiosk with a
// wrong system timezone would otherwise silently show wrong times to the floor.
function formatTime(dateStr) {
  if (!dateStr) return ''
  return formatInZone(dateStr, siteTimezone.value, {
    year: undefined, month: undefined, day: undefined,
    hour: '2-digit', minute: '2-digit'
  })
}

function formatDateTime(dateStr) {
  if (!dateStr) return ''
  return formatInZone(dateStr, siteTimezone.value, {
    weekday: 'short',
    month: 'short',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  })
}

function getTicketUrl(ticketnumber) {
  // Return a URL only when ServiceNow is enabled and a matching template is set.
  // Null means "render the ticket number as plain text, no link".
  if (!ticketnumber) return null
  const config = servicenowConfig.value
  if (!config.enabled) return null
  if (ticketnumber.startsWith('GEINC') && config.incidentUrl) {
    return config.incidentUrl.replace('{ticket}', ticketnumber)
  }
  if (ticketnumber.startsWith('GECHG') && config.changeUrl) {
    return config.changeUrl.replace('{ticket}', ticketnumber)
  }
  return null
}

function handlePhotoError(e) {
  // Photo URL 404'd (e.g. no photo file for this SSO): fall back to the GE
  // monogram. Guard against a loop if the fallback itself fails to load.
  if (e.target.dataset.fellback) { return }
  e.target.dataset.fellback = '1'
  e.target.src = employeePhotoFallback.value
  e.target.classList.add('ge-logo-fallback')
}
</script>

<style scoped>
.shopfloor-dashboard {
  width: 100vw;
  height: 100vh;
  position: relative;
  background: #00003d;
  color: #fff;
  font-family: Verdana, Geneva, Tahoma, 'Inter Variable', sans-serif;
  overflow: hidden;
}

/* Fixed 1920-wide design surface; JS scales it to fill the viewport (TV) so
   the whole board is visible with no scrolling. */
.dash-fit {
  position: absolute;
  top: 0;
  left: 0;
  width: 1600px;
  min-height: 900px;
  display: flex;
  flex-direction: column;
  transform-origin: top left;
}

.dashboard-header {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 30px;
  padding: 20px 40px;
  border-bottom: 3px solid #4181ff;
}

.logo {
  height: 90px;
  width: auto;
}

.header-center {
  text-align: center;
}

.location-title {
  font-size: 18px;
  font-weight: 600;
  color: #b8c4d8;
  text-transform: uppercase;
  letter-spacing: 2px;
}

.header-center h1 {
  font-size: 28px;
  font-weight: 700;
  color: #ffffff;
  text-transform: uppercase;
  letter-spacing: 2px;
  margin: 0;
}

.header-right {
  text-align: right;
}

.clock {
  font-size: 28px;
  font-weight: 600;
  color: #4181ff;
  font-variant-numeric: tabular-nums;
  margin-bottom: 10px;
}

.filter-select {
  padding: 10px 16px;
  font-size: 16px;
  font-weight: 600;
  background: #1a1a5e;
  color: #fff;
  border: 2px solid #4181ff;
  border-radius: 6px;
  cursor: pointer;
  min-width: 200px;
}

.dashboard-content {
  padding: 20px 40px;
}

.section-title {
  font-size: 22px;
  font-weight: 700;
  padding: 12px 20px;
  border-radius: 8px;
  text-transform: uppercase;
  letter-spacing: 2px;
  margin-bottom: 15px;
  /* Neutral only until the inline, type-derived colour lands - every heading
     on this board is coloured by the types beneath it. */
  background: #4181ff;
}

.section-title.recognition {
}

/* Recertification grid - blue, compact tiles so 20-30 people are all visible
   at once (no carousel to wait through) */
.recert-section {
  margin-bottom: 25px;
}
.section-title.recert-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.recert-range {
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 1px;
  opacity: 0.85;
}
.recert-description {
  font-size: 22px;
  font-weight: 600;
  color: #cbd5e1;
  margin: -4px 0 14px;
  line-height: 1.35;
}
/* Single row that cycles through pages of employees */
.recert-row {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: 14px;
}
.recert-tile {
  position: relative;
  overflow: hidden;
  background: linear-gradient(135deg, #16233b 0%, #0d2137 100%);
  border: 2px solid var(--accent, #0d6efd);
  border-radius: 10px;
  padding: 14px 10px 14px 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  text-align: center;
}
.recert-photo {
  width: 90px;
  height: 90px;
  border-radius: 50%;
  object-fit: cover;
  border: 3px solid var(--accent, #0d6efd);
  background: #1a1a2e;
}
.recert-photo.ge-logo-fallback {
  object-fit: contain;
  padding: 12px;
  background: #fff;
}
.recert-name {
  font-size: 20px;
  font-weight: 700;
  line-height: 1.15;
  /* Always reserve two lines so a name that wraps does not make its tile
     taller and shift the whole grid. Longer names clamp with an ellipsis. */
  height: 2.3em;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.section-title.upcoming {
}

/* Recognition carousel */
.banner-section {
  margin-bottom: 25px;
}

.banner-strip {
  padding: 22px 30px;
  border-radius: 10px;
  margin-bottom: 12px;
  color: #fff;
  font-size: 2rem;
  font-weight: 700;
  text-align: center;
  text-wrap: balance;
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}

.recognition-section {
  margin-bottom: 25px;
}

.recognition-carousel {
  position: relative;
  /* Enough for a photo card; a message-only card is shorter and leaves the
     rest as breathing room rather than resizing the board every 8 seconds. */
  min-height: 170px;
}

.carousel-pips {
  display: flex;
  gap: 8px;
  justify-content: center;
  margin-top: 10px;
}
.pip {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  opacity: 0.35;
  transition: opacity 0.4s ease, transform 0.4s ease;
}
.pip.on {
  opacity: 1;
  transform: scale(1.25);
}

.chip-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
}

/* Scheduled or just-ended cards stay legible but visibly quieter than live
   ones, so a glance at the board never mistakes one for the other. Carousel
   cards are dimmed only while ACTIVE: the inactive ones are held at opacity 0
   by .recognition-card, and a plain .pending rule outranks it, which brought
   the whole stack back into view on top of each other. */
.recognition-card.active.pending,
.recert-tile.pending,
.banner-strip.pending {
  opacity: 0.72;
}

/* An upcoming card is dashed, not solid: shape carries the "not yet" even for
   a reader who cannot pick the amber chip out at distance, or who is looking
   at the board side-on. */
.recognition-card.upcoming,
.recert-tile.upcoming,
.banner-strip.upcoming {
  border-style: dashed;
}

.state-chip.upcoming {
  background: #ffc107;
  border-color: #ffc107;
  color: #231b00;
}
.state-chip.resolved {
  background: rgba(255, 255, 255, 0.12);
  border-color: rgba(255, 255, 255, 0.4);
  color: #fff;
}
.pip.scheduled {
  background: transparent !important;
  box-shadow: inset 0 0 0 3px currentcolor;
}

.state-chip {
  padding: 4px 14px;
  border-radius: 999px;
  font-size: 18px;
  font-weight: 700;
  letter-spacing: 1px;
  text-transform: uppercase;
  background: rgba(255, 255, 255, 0.16);
  border: 2px solid rgba(255, 255, 255, 0.45);
  color: #fff;
  white-space: nowrap;
}
.state-chip.tile-state {
  font-size: 14px;
  padding: 2px 10px;
  margin-top: 8px;
}

.type-chip {
  padding: 4px 14px;
  border-radius: 999px;
  font-size: 20px;
  font-weight: 700;
  letter-spacing: 1px;
  text-transform: uppercase;
  /* No margin-bottom. .chip-row centres each item's MARGIN box, so a bottom
     margin here lifted this chip ~4px and left the state chip beside it
     looking low. The row already provides the spacing underneath. */
}

/* Type colour as a bar down the left edge, the way the legacy board marked a
   card. The border alone reads as chrome at distance; a solid edge reads as
   the type. Same idea as .event-indicator on the standard cards. */
.recognition-card::before,
.recert-tile::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 14px;
  background: var(--accent, #ffc107);
}
.recert-tile::before {
  width: 10px;
}

.recognition-card {
  overflow: hidden;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  background: linear-gradient(135deg, #2b2718 0%, #1d1a10 100%);
  border: 3px solid var(--accent, #ffc107);
  border-radius: 12px;
  padding: 20px 25px 20px 39px;
  display: flex;
  align-items: center;
  gap: 25px;
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.8s ease, transform 0.8s ease;
}

.recognition-card.active {
  opacity: 1;
  transform: translateY(0);
  position: relative;
}

.recognition-photo {
  width: 140px;
  height: 140px;
  border-radius: 50%;
  object-fit: cover;
  border: 4px solid var(--accent, #ffc107);
  background: #1a1a2e;
}

.recognition-photo.ge-logo-fallback {
  object-fit: contain;
  padding: 20px;
  background: #fff;
}

.recognition-content {
  display: flex;
  flex-direction: column;
  flex: 1;
}

.recognition-header {
  display: flex;
  align-items: center;
  margin-bottom: 10px;
}


.recognition-name {
  font-size: 36px;
  font-weight: 700;
}

.recognition-message {
  font-size: 24px;
  color: #ccc;
  line-height: 1.4;
}

/* Event cards */
.notifications-section {
  margin-bottom: 25px;
}

.events-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.event-card {
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  background: #fff;
  color: #00003d;
  border-radius: 8px;
  padding: 15px 20px 15px 30px;
  gap: 15px;
}

.event-card.resolved {
  opacity: 0.6;
  background: #e9ecef;
}

.event-indicator {
  /* Type color spanning the whole left edge of the card (old-site style). */
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 12px;
  flex-shrink: 0;
}

.event-content {
  flex: 1;
}

.event-title {
  font-size: 24px;
  font-weight: 700;
  line-height: 1.3;
}

.event-time {
  font-size: 18px;
  color: #666;
  margin-top: 5px;
}

.event-time strong {
  color: #00003d;
}

.event-ticket {
  font-size: 18px;
  font-weight: 700;
  background: #00003d;
  color: #fff;
  padding: 8px 16px;
  border-radius: 6px;
  text-decoration: none;
  text-transform: uppercase;
}

.event-ticket:hover {
  background: #4181ff;
}

.no-events, .loading {
  text-align: center;
  font-size: 28px;
  font-weight: 600;
  padding: 40px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 8px;
}

.dashboard-footer {
  margin-top: auto;
  background: rgba(0, 0, 0, 0.8);
  text-align: center;
  padding: 12px;
  font-size: 18px;
}
</style>
