const { useState, useEffect, useMemo, useRef, useCallback, useContext, createContext } = React;

/* ============================ API ============================ */
let onUnauthorized = null; // ρυθμίζεται από το App — γυρνάει στο login αν λήξει το session

function qs(params) {
  const p = new URLSearchParams();
  for (const [k, v] of Object.entries(params || {})) {
    if (v !== '' && v !== null && v !== undefined) p.set(k, v);
  }
  const s = p.toString();
  return s ? '?' + s : '';
}

const api = {
  async get(url) {
    const r = await fetch(url);
    if (r.status === 401 && onUnauthorized) onUnauthorized();
    if (!r.ok) throw new Error('GET ' + url + ' → ' + r.status);
    return r.json();
  },
  async send(method, url, body) {
    const r = await fetch(url, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: body ? JSON.stringify(body) : undefined,
    });
    // /api/auth/login εξαιρείται ρητά — ένα 401 εκεί σημαίνει "λάθος στοιχεία εισόδου", μια
    // ΦΥΣΙΟΛΟΓΙΚΗ, τοπικά χειριζόμενη απάντηση (βλ. LoginPage catch), ΟΧΙ λήξη session. Πριν, το
    // onUnauthorized() πυροδοτούνταν και ΕΔΩ, ξαναδείχνοντας το LockScreen ΠΑΝΩ από το μήνυμα
    // σφάλματος της LoginPage — ο χρήστης έβλεπε την οθόνη κλειδώματος αντί για "λάθος στοιχεία
    // εισόδου" (ζητήθηκε ρητά να διορθωθεί, να μένει στη σελίδα εισόδου με το μήνυμα).
    if (r.status === 401 && onUnauthorized && url !== '/api/auth/login') onUnauthorized();
    const data = await r.json().catch(() => ({}));
    if (!r.ok || data.ok === false) throw new Error(data.error || (method + ' ' + url + ' απέτυχε'));
    return data;
  },
};

/* ============================ Toasts ============================ */
let pushToastFn = () => {};
function Toasts() {
  const [items, setItems] = useState([]);
  useEffect(() => {
    pushToastFn = (msg, kind) => {
      const id = Math.random();
      setItems((s) => [...s, { id, msg, kind }]);
      setTimeout(() => setItems((s) => s.filter((t) => t.id !== id)), 2600);
    };
  }, []);
  return (
    <div className="toasts">
      {items.map((t) => (
        <div key={t.id} className={'toast ' + (t.kind === 'err' ? 'err' : '')}>{t.msg}</div>
      ))}
    </div>
  );
}
const toast = (m) => pushToastFn(m, 'ok');
const toastErr = (m) => pushToastFn(m, 'err');

/* ============================ Live pending-refs signal ============================ */
// Ίδιο module-level pub/sub convention με τα toasts παραπάνω — bump-άρεται όποτε ένα
// change-request δημιουργείται (υποβολή Συνόλου Αλλαγών) ή κρίνεται (approve/reject), ώστε
// η χρωματισμένη pending κατάσταση στα Clusters/Stores tabs να ενημερώνεται αμέσως, χωρίς
// να χρειάζεται reload σελίδας.
let bumpPendingRefsFn = () => {};
function usePendingRefsVersion() {
  const [v, setV] = useState(0);
  useEffect(() => {
    bumpPendingRefsFn = () => setV((n) => n + 1);
    return () => { bumpPendingRefsFn = () => {}; };
  }, []);
  return v;
}
const bumpPendingRefs = () => bumpPendingRefsFn();

/* ============================ Request counts + new-request notification ============================ */
// Δεν υπάρχει websocket/push infra σε αυτό το app — "νέο αίτημα" ανιχνεύεται μόνο με polling
// όσο το browser tab είναι ανοιχτό (ζητήθηκε ρητά: "θελω απλά να έχει notification αν θα
// είναι στο web live ή απλά στο επόμενο load της σελίδας δεν πειράζει, δεν με νοιάζει να
// είναι στον browser" — άρα OΧΙ Browser Push API/service worker, μόνο in-tab polling +
// φυσικό refetch σε κάθε mount/navigation αρκεί). REQUEST_COUNTS_POLL_MS: αρκετά συχνό για
// "μέσα σε λίγα λεπτά το βλέπω" χωρίς να είναι επιθετικό polling.
const REQUEST_COUNTS_POLL_MS = 30000;
const EMPTY_REQUEST_COUNTS = { cluster: { pending: 0, approved: 0, rejected: 0 }, store_assignment: { pending: 0, approved: 0, rejected: 0 }, spacegroup: { pending: 0, approved: 0, rejected: 0 } };
const RequestCountsContext = createContext(null);
const useRequestCounts = () => useContext(RequestCountsContext);

function RequestCountsProvider({ children }) {
  const me = useAuth();
  const canSeeRequests = me && CAN_SEE_REQUESTS_TAB(me.role);
  const [counts, setCounts] = useState(EMPTY_REQUEST_COUNTS);
  const pendingRefsVersion = usePendingRefsVersion();

  const poll = useCallback(() => {
    if (!canSeeRequests) return;
    api.get('/api/change-requests/counts').then(setCounts).catch(() => {});
  }, [canSeeRequests]);

  useEffect(() => {
    poll();
    const t = setInterval(poll, REQUEST_COUNTS_POLL_MS);
    return () => clearInterval(t);
  }, [poll, pendingRefsVersion]);

  const value = useMemo(() => ({ counts }), [counts]);
  return <RequestCountsContext.Provider value={value}>{children}</RequestCountsContext.Provider>;
}

/* ============================ Notifications (ενοποιημένο read/unread, 4 event types) ============================ */
// Αντικαθιστά το παλιό localStorage-only "seen pending requests" mechanism (ήταν μέρος του
// RequestCountsProvider πιο πάνω) — τώρα server-side, read/unread ανά χρήστη, καλύπτει ΚΑΙ
// new_request (reviewer) ΚΑΙ request_approved/request_rejected (submitter) ΚΑΙ
// store_cluster_changed (broadcast). Ίδιο 30s polling convention/REQUEST_COUNTS_POLL_MS με το
// RequestCountsProvider — "in-tab polling αρκεί" ήδη established, όχι websockets.
// Module-level cache του τελευταίου φορτωμένου `reference` (spaceGroupNames/storeNames) —
// γράφεται από το AppShell (βλ. εκεί), διαβάζεται εδώ ώστε το toast ενός notification να δείξει
// σωστά ονόματα χωρίς το NotificationsProvider (ζει ΠΑΝΩ από το AppShell) να χρειάζεται δικό
// του prop/context για κάτι που ήδη φορτώνεται εκεί.
let latestReferenceCache = null;
const NotificationsContext = createContext(null);
const useNotifications = () => useContext(NotificationsContext);

const NOTIFICATION_TYPE_LABELS = {
  new_request: 'Νέα αιτήματα',
  request_approved: 'Έγκριση αιτημάτων μου',
  request_rejected: 'Απόρριψη αιτημάτων μου',
  store_cluster_changed: 'Αλλαγές cluster καταστήματος',
  cluster_create_started: 'Νέο cluster σε εξέλιξη (PM)',
  cluster_create_completed: 'Ολοκλήρωση δημιουργίας cluster (PM)',
  stale_request: 'Παλιά εκκρεμή αιτήματα',
};
// Ίδια λίστα με το server (NOTIFICATION_TYPES στο server.js) — χρησιμοποιείται στις ρυθμίσεις
// προφίλ (βλ. ProfileDrawer) για να ξέρει ποια toggles να δείξει.
const NOTIFICATION_TYPES_CLIENT = ['new_request', 'request_approved', 'request_rejected', 'store_cluster_changed', 'cluster_create_started', 'cluster_create_completed', 'stale_request'];
// Ίδια τιμή με STALE_REQUEST_REMINDER_DAYS στο server.js — μόνο για το dashboard StatTile label.
const STALE_REQUEST_REMINDER_DAYS = 3;

// reference προαιρετικό (spaceGroupNames/storeNames) — όταν λείπει (π.χ. στο πρώτο toast πριν
// προλάβει να φορτώσει το /api/reference) δείχνει μόνο τα raw ids/codes, όχι ονόματα.
function describeNotification(n, reference) {
  const sgNames = (reference && reference.spaceGroupNames) || {};
  const storeNames = (reference && reference.storeNames) || {};
  const sgLabel = n.spacegroupId != null ? `SG ${n.spacegroupId}${sgNames[n.spacegroupId] ? ' · ' + sgNames[n.spacegroupId] : ''}` : '';
  const storeLabel = n.storeCode ? `${n.storeCode}${storeNames[n.storeCode] ? ' · ' + storeNames[n.storeCode] : ''}` : '';
  const domainLabel = n.domain === 'feed' ? ' (Feed)' : '';
  const target = storeLabel || sgLabel || '';
  if (n.type === 'new_request') return `Νέο αίτημα${domainLabel}${target ? ' · ' + target : ''}`;
  if (n.type === 'request_approved') return `Το αίτημά σου εγκρίθηκε${domainLabel}${target ? ' · ' + target : ''}`;
  if (n.type === 'request_rejected') return `Το αίτημά σου απορρίφθηκε${domainLabel}${target ? ' · ' + target : ''}${n.reviewNote ? ' — ' + n.reviewNote : ''}`;
  if (n.type === 'store_cluster_changed') return `Άλλαξε cluster: ${storeLabel}${sgLabel ? ' · ' + sgLabel : ''}${n.clusterCode ? ' · νέο cluster ' + n.clusterCode : ''}`;
  if (n.type === 'cluster_create_started') return `Νέο cluster σε εξέλιξη${sgLabel ? ' · ' + sgLabel : ''}${n.clusterCode ? ' (' + n.clusterCode + ')' : ''}`;
  if (n.type === 'cluster_create_completed') return `Ολοκληρώθηκε η δημιουργία cluster${sgLabel ? ' · ' + sgLabel : ''}${n.clusterCode ? ' (' + n.clusterCode + ')' : ''}`;
  if (n.type === 'stale_request') return `Αίτημα σε εκκρεμότητα${domainLabel}${target ? ' · ' + target : ''}${n.reviewNote ? ' — ' + n.reviewNote : ''}`;
  return 'Νέα ειδοποίηση';
}

// Πού πάει το click πάνω σε μια ειδοποίηση — ίδιο deep-link convention με τα ήδη υπάρχοντα
// "Δες αίτημα"/StoresPage sg= links.
function notificationLink(n) {
  if (n.type === 'store_cluster_changed') return n.spacegroupId != null ? `#/stores?sg=${n.spacegroupId}` : '#/stores';
  // cluster_create_started/completed: κανένα μεμονωμένο change_request id δεν αντιπροσωπεύει
  // αξιόπιστα "ολόκληρη" τη δημιουργία (2 γραμμές, βλ. create_group_id στο server) — πάει
  // απλά στο Clusters tab, όχι deep-link με highlight.
  if (n.type === 'cluster_create_started' || n.type === 'cluster_create_completed') return '#/clusters';
  return n.changeRequestId != null ? `#/requests?highlight=${n.changeRequestId}` : '#/requests';
}

function NotificationsProvider({ children }) {
  const me = useAuth();
  const [items, setItems] = useState([]);
  const [unreadCount, setUnreadCount] = useState(0);
  // In-memory μόνο (ΟΧΙ localStorage) — το read/unread είναι πλέον server-side, αυτό εδώ
  // χρειάζεται μόνο για να ξέρουμε ΠΟΙΑ ids έχουμε ήδη δείξει σαν toast μέσα σε αυτή τη
  // session, ώστε ένα re-poll να μην ξανα-toast-άρει ό,τι ήδη φάνηκε.
  const toastedIdsRef = useRef(new Set());
  const firstPollRef = useRef(true);

  const poll = useCallback(() => {
    if (!me) return;
    api.get('/api/notifications?limit=30').then(({ notifications }) => {
      if (!firstPollRef.current) {
        const fresh = notifications.filter((n) => !n.readAt && !toastedIdsRef.current.has(n.id));
        fresh.forEach((n) => { toast(describeNotification(n, latestReferenceCache)); toastedIdsRef.current.add(n.id); });
      } else {
        notifications.forEach((n) => toastedIdsRef.current.add(n.id));
      }
      firstPollRef.current = false;
      setItems(notifications);
    }).catch(() => {});
    api.get('/api/notifications/unread-count').then(({ count }) => setUnreadCount(count)).catch(() => {});
  }, [me]);

  useEffect(() => {
    poll();
    const t = setInterval(poll, REQUEST_COUNTS_POLL_MS);
    // Οι browsers ρίχνουν δραστικά τη συχνότητα ενός setInterval σε background tab (Chrome:
    // έως 1 φορά/λεπτό ή και λιγότερο) — ζητήθηκε ρητά "δεν σκάει απευθείας... θέλει refresh".
    // Χωρίς αυτό, ένας reviewer/submitter που είχε το tab ανοιχτό αλλά όχι focused έβλεπε
    // παλιά δεδομένα μέχρι το επόμενο (καθυστερημένο) tick — εδώ κάνει άμεσο poll() μόλις το
    // tab ξαναγίνει visible, σαν το ξαναδιάλεξμα του tab να ήταν ήδη "refresh".
    function onVisible() { if (document.visibilityState === 'visible') poll(); }
    document.addEventListener('visibilitychange', onVisible);
    return () => { clearInterval(t); document.removeEventListener('visibilitychange', onVisible); };
  }, [poll]);

  const markRead = useCallback((id) => {
    setItems((cur) => cur.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: new Date().toISOString() } : n)));
    setUnreadCount((c) => Math.max(0, c - 1));
    api.send('PUT', `/api/notifications/${id}/read`).catch(() => {});
  }, []);

  const markAllRead = useCallback(() => {
    setItems((cur) => cur.map((n) => ({ ...n, readAt: n.readAt || new Date().toISOString() })));
    setUnreadCount(0);
    api.send('PUT', '/api/notifications/mark-all-read').catch(() => {});
  }, []);

  const value = useMemo(() => ({ items, unreadCount, markRead, markAllRead }), [items, unreadCount, markRead, markAllRead]);
  return <NotificationsContext.Provider value={value}>{children}</NotificationsContext.Provider>;
}

/* ============================ Auth ============================ */
const AuthContext = createContext(null);
const useAuth = () => useContext(AuthContext);

/* ============================ Theme (light/dark/system) ============================ */
// Server-side ανά χρήστη (users.theme_pref, βλ. PUT /api/users/me/theme-pref), ΟΧΙ
// localStorage ως πηγή αλήθειας — ζητήθηκε ρητά cross-device συνέπεια, ίδιο convention με
// view_prefs/column_prefs. Το localStorage (κλειδί 'themePrefCache') είναι ΜΟΝΟ ένα
// flash-prevention cache που διαβάζεται συγχρονισμένα στο index.html πριν καν φορτώσει το
// React (βλ. inline script εκεί) — εδώ απλά το κρατάμε ενημερωμένο ώστε το επόμενο paint να
// ξεκινάει ήδη σωστό. Ο server ΠΑΝΤΑ κερδίζει σε reconciliation.
const ThemeContext = createContext(null);
const useTheme = () => useContext(ThemeContext);

// pref ζωντανεύει σαν 'system' | 'light' | 'dark' | 'light:N' | 'dark:N' (N=1|2|3, το variant —
// βλ. ThemeVariantPicker) — μία στήλη (theme_pref), όχι δύο, ώστε να μη χρειάζεται migration.
// Χωρίς ':N' σημαίνει variant 1 (default/"ήσυχο"). Το variant εφαρμόζεται ΜΟΝΟ όταν το mode
// είναι ρητό (light/dark), όχι σε 'system' — αλλιώς θα έπρεπε να καλύψουμε 3 variants × live OS
// switching, μεγάλη πολυπλοκότητα για ελάχιστο όφελος.
function parseThemePref(pref) {
  if (!pref || pref === 'system') return { mode: 'system', variant: 1 };
  const [mode, variantStr] = String(pref).split(':');
  return { mode, variant: variantStr ? Number(variantStr) : 1 };
}
function composeThemePref(mode, variant) {
  if (mode === 'system') return 'system';
  return variant && variant !== 1 ? `${mode}:${variant}` : mode;
}

function resolveTheme(pref) {
  const { mode } = parseThemePref(pref);
  if (mode === 'dark' || mode === 'light') return mode;
  return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}

function applyThemeAttr(pref) {
  // data-theme/data-variant ΜΟΝΟ όταν ο χρήστης έχει επιλέξει ρητά κάτι — 'system' αφήνει τα
  // attributes εντελώς εκτός ώστε το CSS @media(prefers-color-scheme) block να αναλάβει από
  // μόνο του (ίδιο resolve αποτέλεσμα, μηδέν επιπλέον state to keep in sync με live OS αλλαγές).
  const { mode, variant } = parseThemePref(pref);
  if (mode === 'dark' || mode === 'light') {
    document.documentElement.setAttribute('data-theme', mode);
    document.documentElement.setAttribute('data-variant', String(variant));
  } else {
    document.documentElement.removeAttribute('data-theme');
    document.documentElement.removeAttribute('data-variant');
  }
}

// ThemeProvider ζει ΕΚΤΟΣ AuthContext.Provider (mount-άρεται πάντα, ακόμα και σε login/
// changepw screens) — αυτές οι οθόνες παραμένουν εκτεθειμένες μόνο στο system-preference CSS
// block (καμία ρητή επιλογή δυνατή πριν συνδεθεί κανείς), αλλά το ίδιο Provider απλά δεν έχει
// server τιμή να συγχρονίσει μέχρι να υπάρξει `me` — δεν πειράζει τίποτα αν δεν καλεστεί ποτέ
// setPref πριν το login.
function ThemeProvider({ children, serverThemePref }) {
  const [pref, setPrefState] = useState(() => {
    try { return localStorage.getItem('themePrefCache') || 'system'; } catch (e) { return 'system'; }
  });

  // Server -> client reconciliation: κάθε φορά που αλλάζει το serverThemePref (login, ή
  // GET /api/auth/me re-check μετά από αλλαγή σε άλλη συσκευή), ο server κερδίζει πάντα.
  useEffect(() => {
    if (serverThemePref == null) return;
    setPrefState(serverThemePref);
    applyThemeAttr(serverThemePref);
    try { localStorage.setItem('themePrefCache', serverThemePref); } catch (e) {}
  }, [serverThemePref]);

  // Live system-preference tracking όταν pref==='system' — αν ο χρήστης αλλάξει OS theme ενώ
  // είναι ήδη μέσα στην εφαρμογή, ακολουθεί real-time (χωρίς αυτό, μόνο επόμενο reload θα το
  // έπιανε αφού δεν υπάρχει data-theme attribute να ξαναϋπολογιστεί).
  useEffect(() => {
    const { mode } = parseThemePref(pref);
    if (mode !== 'system' || !window.matchMedia) return;
    const mq = window.matchMedia('(prefers-color-scheme: dark)');
    const onChange = () => applyThemeAttr('system');
    mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange);
    return () => { mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange); };
  }, [pref]);

  const commit = useCallback((next) => {
    setPrefState(next);
    applyThemeAttr(next);
    try { localStorage.setItem('themePrefCache', next); } catch (e) {}
    api.send('PUT', '/api/users/me/theme-pref', { themePref: next }).catch(() => {});
  }, []);

  const { mode, variant } = parseThemePref(pref);
  // setPref: αλλάζει mode (light/dark/system), κρατάει το τρέχον variant. setVariant: αλλάζει
  // variant, κρατάει το τρέχον mode — no-op σε 'system' (δεν έχει νόημα, κρύβεται κιόλας στο UI).
  const setPref = useCallback((nextMode) => commit(composeThemePref(nextMode, variant)), [commit, variant]);
  const setVariant = useCallback((nextVariant) => {
    if (mode === 'system') return;
    commit(composeThemePref(mode, nextVariant));
  }, [commit, mode]);

  const value = useMemo(() => ({ pref: mode, variant, setPref, setVariant, resolved: resolveTheme(pref) }),
    [pref, mode, variant, setPref, setVariant]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

/* ============================ Avatar ============================ */
// Επαναχρησιμοποιήσιμο avatar — εικόνα (lazy, GET /api/users/:id/avatar) αν ο χρήστης έχει
// ανεβάσει μία, αλλιώς initials πάνω σε χρώμα παραγόμενο από hash του ονόματος. Ίδιο
// category-color πνεύμα με το ήδη υπάρχον CHART_PALETTE (deterministic, ίδιο όνομα -> πάντα
// ίδιο χρώμα, καμία νέα υποδομή). onError -> ξαναγυρνάει σε initials αντί για σπασμένη εικόνα
// (π.χ. signed URL έληξε ενδιάμεσα).
const AVATAR_PALETTE = ['#2f52b3', '#0d7d7d', '#b7791f', '#8a4fd1', '#e0553b', '#1f8a4c', '#c24329', '#7885a3'];
function avatarColorFor(name) {
  let hash = 0;
  for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
  return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
}
function initialsFor(name) {
  const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return '?';
  return (parts[0][0] + (parts[1] ? parts[1][0] : '')).toUpperCase();
}
function Avatar({ userId, name, hasAvatar, size = 32 }) {
  const [broken, setBroken] = useState(false);
  useEffect(() => { setBroken(false); }, [userId, hasAvatar]);
  const showImg = hasAvatar && userId != null && !broken;
  return (
    <div className="avatar" style={{ width: size, height: size, fontSize: Math.round(size * .4), background: showImg ? undefined : avatarColorFor(name || '?') }}>
      {showImg ? (
        <img src={`/api/users/${userId}/avatar`} alt="" onError={() => setBroken(true)} />
      ) : (
        <span>{initialsFor(name)}</span>
      )}
    </div>
  );
}

// ============================ User profile modal (κλικ-πάνω-σε-όνομα, οπουδήποτε) ============================
// Ζητήθηκε ρητά "όπου εμφανίζονται ονόματα χρηστών με κλικ πάνω στο όνομα να ανοίγει modal
// παράθυρο μικρό" — global provider (mount μία φορά, βλ. App()) ώστε το UserNameLink να μπορεί
// να ανοίξει το modal από ΟΠΟΥΔΗΠΟΤΕ στο δέντρο (RequestCard, Clusters table, Users admin,
// Spacegroups admin...) χωρίς prop-drilling ενός open-handler μέσα από όλα αυτά τα components.
const UserProfileModalContext = createContext(null);
const useUserProfileModal = () => useContext(UserProfileModalContext);

function UserProfileModalProvider({ children }) {
  const [userId, setUserId] = useState(null);
  const value = useMemo(() => ({ open: setUserId }), []);
  return (
    <UserProfileModalContext.Provider value={value}>
      {children}
      {userId ? <UserProfileModal userId={userId} onClose={() => setUserId(null)} /> : null}
    </UserProfileModalContext.Provider>
  );
}

function UserProfileModal({ userId, onClose }) {
  const [profile, setProfile] = useState(null); // undefined=error, null=loading, object=ready
  useEffect(() => {
    let alive = true;
    setProfile(null);
    api.get('/api/users/' + userId + '/profile')
      .then((p) => { if (alive) setProfile(p); })
      .catch(() => { if (alive) setProfile(undefined); });
    return () => { alive = false; };
  }, [userId]);

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal-card" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Στοιχεία χρήστη</b>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        {profile === null ? (
          <div className="empty">Φόρτωση…</div>
        ) : profile === undefined ? (
          <div className="empty">Δεν βρέθηκε.</div>
        ) : (
          <div className="user-modal-body">
            <Avatar userId={profile.id} name={profile.displayName} hasAvatar={!!profile.avatarPath} size={64} />
            <div className="user-modal-name">{profile.displayName}</div>
            <span className="pill role">{profile.title || ROLE_LABELS[profile.role] || profile.role}</span>
            <div className="user-modal-contact">
              <div><Icon name="phone" size={14} />{profile.phone || <span className="muted">Χωρίς τηλέφωνο</span>}</div>
              <div><Icon name="mail" size={14} />{profile.email || <span className="muted">Χωρίς email</span>}</div>
              <div><Icon name="team" size={14} />{profile.teamEmail || <span className="muted">Χωρίς team email</span>}</div>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// Κοινό clickable-name component — αντικαθιστά κάθε "plain text name" σε RequestCard/Clusters/
// Users admin/Spacegroups admin κ.λπ. `user` = { id, displayName, title? } | null/undefined
// (π.χ. διαγραμμένος/deactivated χρήστης, ή απλά δεν βρέθηκε match) -> fallback σε plain text.
function UserNameLink({ user, fallback, label }) {
  const modal = useUserProfileModal();
  if (!user || !user.id) return <>{fallback ?? (user && user.displayName) ?? '—'}</>;
  return (
    <button type="button" className="user-name-link" onClick={() => modal.open(user.id)}>
      {label || user.displayName || fallback || '—'}
    </button>
  );
}

function ThemeToggle() {
  const theme = useTheme();
  if (!theme) return null;
  const options = [
    ['light', 'Φωτεινό', <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>],
    ['dark', 'Σκοτεινό', <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" /></svg>],
    ['system', 'Σύστημα', <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8M12 16v4" /></svg>],
  ];
  return (
    <div className="theme-toggle" role="radiogroup" aria-label="Θέμα εμφάνισης">
      {options.map(([key, label, icon]) => (
        <button key={key} type="button" className={'theme-toggle-btn' + (theme.pref === key ? ' active' : '')}
          title={label} aria-label={label} aria-pressed={theme.pref === key}
          onClick={() => theme.setPref(key)}>
          {icon}
        </button>
      ))}
    </div>
  );
}

// 3 βασικά χρώματα ανά mode (ζητήθηκε ρητά, genuinely διαφορετικές αποχρώσεις — όχι απλά
// φωτεινότητα) — light: λευκό/μπλε/πράσινο, dark: μπλε/κυπαρίσι/γκρι. Κρυμμένο σε mode='system'
// (δεν έχει νόημα να διαλέγεις variant για ένα mode που δεν είναι καν ενεργό/ρητά επιλεγμένο
// ακόμα). Κάθε swatch έχει ΣΤΑΤΙΚΟ preview χρώμα (όχι var(--x), θα έδειχνε πάντα το ΤΡΕΧΟΝ όχι
// την προεπισκόπηση) — βλ. .variant-swatch-* στο styles.css, ίδιες τιμές με τα CSS token overrides.
function ThemeVariantPicker() {
  const theme = useTheme();
  if (!theme || theme.pref === 'system') return null;
  const isDark = theme.pref === 'dark';
  const labels = isDark
    ? [[1, 'Μπλε'], [2, 'Κυπαρίσι'], [3, 'Γκρι']]
    : [[1, 'Λευκό'], [2, 'Μπλε'], [3, 'Πράσινο']];
  return (
    <div className="variant-picker" role="radiogroup" aria-label="Απόχρωση θέματος">
      {labels.map(([v, label]) => (
        <button key={v} type="button"
          className={'variant-swatch variant-swatch-' + (isDark ? 'dark' : 'light') + '-' + v + (theme.variant === v ? ' active' : '')}
          title={label} aria-label={label} aria-pressed={theme.variant === v}
          onClick={() => theme.setVariant(v)} />
      ))}
    </div>
  );
}

/* ============================ Icons ============================ */
// Μικρό inline-SVG icon set — αρχικά μόνο για τα glyphs που ήταν πριν colored emoji (🔗 🔒 🔓),
// αφού αυτά render ασυνεπώς cross-platform/browser (διαφορετικό βάρος/χρώμα ανά OS font
// stack), ενώ τα υπόλοιπα Unicode σύμβολα του app (✕ ✎ ← →) είναι ήδη συνεπή/μονόχρωμα και
// δεν έχουν αυτό το πρόβλημα — δεν χρειάζεται να αντικατασταθούν. Πλέον καλύπτει και το thin-
// stroke εικονίδιο set του sidebar nav (dashboard/layers/store/book/sliders/chevron/bell).
// name-keyed map, size σε px (default 14), stroke="currentColor" ώστε να κληρονομεί το text
// color του caller.
const ICONS = {
  link: <path d="M9 15L15 9M11 6l.6-.6a4 4 0 0 1 5.7 5.7L16.7 12M13 18l-.6.6a4 4 0 0 1-5.7-5.7L7.3 12" />,
  lock: <><rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
  unlock: <><rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V7a4 4 0 0 1 7.5-2" /></>,
  history: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3.5 2" /></>,
  inbox: <><path d="M4 12h4l2 3h4l2-3h4" /><path d="M5.5 6h13L21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6L5.5 6Z" /></>,
  dashboard: <><rect x="3" y="3" width="8" height="8" rx="1.5" /><rect x="13" y="3" width="8" height="5" rx="1.5" /><rect x="13" y="12" width="8" height="9" rx="1.5" /><rect x="3" y="15" width="8" height="6" rx="1.5" /></>,
  layers: <><path d="M12 3l9 5-9 5-9-5 9-5Z" /><path d="M3 13l9 5 9-5" /></>,
  store: <><path d="M4 9V4h16v5" /><path d="M4 9a3 3 0 0 0 6 0 3 3 0 0 0 6 0 3 3 0 0 0 6 0" /><path d="M5 9v10a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9" /></>,
  book: <><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5v-15Z" /><path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H20" /></>,
  sliders: <><path d="M4 6h10M18 6h2M4 18h2M8 18h12" /><circle cx="16" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /></>,
  chevron: <path d="M9 6l6 6-6 6" />,
  bell: <><path d="M6 9a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6Z" /><path d="M10 19a2 2 0 0 0 4 0" /></>,
  // Spacegroups — ταξινόμηση/κατηγοριοποίηση (Division/Family/Category/Spacegroup), tag = ετικέτα.
  tag: <><path d="M11 3H4a1 1 0 0 0-1 1v7a1 1 0 0 0 .29.71l9 9a1 1 0 0 0 1.42 0l7-7a1 1 0 0 0 0-1.42l-9-9A1 1 0 0 0 11 3Z" /><circle cx="7.5" cy="7.5" r="1.3" /></>,
  // Χρήστες — διαχείριση λογαριασμών, ένα άτομο.
  user: <><circle cx="12" cy="8" r="3.5" /><path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6" /></>,
  // PMM — read-only roster/λίστα ονομάτων, clipboard.
  clipboard: <><rect x="5" y="4" width="14" height="17" rx="2" /><path d="M9 8h6M9 12h6M9 16h3" /></>,
  // Import — δεδομένα μπαίνουν ΜΕΣΑ στο σύστημα, βέλος προς τα κάτω σε τρέι.
  download: <><path d="M12 3v12m0 0-4-4m4 4 4-4" /><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" /></>,
  // Καταστήματα (admin) — μητρώο τοποθεσιών, pin. Ξεχωριστό από το "store" (storefront) που
  // δείχνει το Cluster ανά κατάστημα.
  pin: <><path d="M12 22s7-7.58 7-12.5A7 7 0 0 0 5 9.5C5 14.42 12 22 12 22Z" /><circle cx="12" cy="9.5" r="2.5" /></>,
  // Τετραγωνικά — εμβαδόν/διαστάσεις, βελάκια προς τις 4 γωνίες.
  maximize: <><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></>,
  // Διαγραφή — κάδος (ζητήθηκε ρητά "σύμβολο του κάδου" αντί για κείμενο "Διαγραφή").
  trash: <><path d="M4 7h16" /><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" /><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12" /><path d="M10 11v6M14 11v6" /></>,
  // Ενεργοποίηση/Απενεργοποίηση χρήστη — power symbol, το χρώμα (πράσινο/κόκκινο) δείχνει την
  // ενέργεια, όχι το ίδιο το icon (ζητήθηκε ρητά "σύμβολο ... με πράσινο κόκκινο αντίστοιχα").
  power: <><path d="M12 3v7" /><path d="M6.5 6.5a7 7 0 1 0 11 0" /></>,
  // Custom checkbox checkmark (βλ. .chk-box) — αντικαθιστά το raw native checkbox appearance.
  check: <path d="M5 12l5 5L19 8" />,
  // Στοιχεία επικοινωνίας (UserProfileModal) — τηλέφωνο/email.
  phone: <path d="M6.5 3h2.7l1.6 4-2 1.4a11 11 0 0 0 4.8 4.8l1.4-2 4 1.6v2.7a2 2 0 0 1-2.2 2C9.8 17 6 13.2 5.5 6.7a2 2 0 0 1 2-2.2Z" />,
  mail: <><rect x="3.5" y="5.5" width="17" height="13" rx="2" /><path d="M4 7l8 5.5L20 7" /></>,
  // Team/group email (UserProfileModal) — ξεχωριστό από το ατομικό mail, δύο άτομα.
  team: <><circle cx="9" cy="9" r="3" /><circle cx="16.5" cy="10.5" r="2.4" /><path d="M4 19c0-3 2.2-5.2 5-5.2s5 2.2 5 5.2" /><path d="M14.7 14.3c2.1.4 3.5 2.3 3.5 4.7" /></>,
  // Εξωτερικός σύνδεσμος (Platform Summary sidebar item) — ανοίγει σε νέα καρτέλα, ίδιο
  // "box + βέλος προς τα έξω" σύμβασης με macOS/browser chrome.
  external: <><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><path d="M15 3h6v6" /><path d="M10 14L21 3" /></>,
  // Comment info popover (Clusters/Matrix) — κύκλος με "i" (η κουκκίδα είναι μια zero-length
  // γραμμή με το ήδη υπάρχον strokeLinecap="round" του Icon wrapper, ώστε να έχει ΤΟ ΙΔΙΟ πάχος
  // με το υπόλοιπο εικονίδιο αντί για μικροσκοπική fill κουκκίδα).
  info: <><circle cx="12" cy="12" r="9" /><path d="M12 11v5" /><path d="M12 7.5v.01" /></>,
};
function Icon({ name, size = 14, className }) {
  const path = ICONS[name];
  if (!path) return null;
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
      strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}
      style={{ display: 'inline-block', verticalAlign: 'middle' }}>
      {path}
    </svg>
  );
}

// Μικρό "i" κουμπί δίπλα σε ένα cluster κωδικό — δείχνει το comment (τρόπος στησίματος/έπιπλο)
// σε popover, click-to-toggle. Χρησιμοποιείται ΚΑΙ στο Clusters page ΚΑΙ στο Cluster ανά
// κατάστημα matrix (ζητήθηκε ρητά και στα δύο) — portal στο document.body (ίδιο pattern με
// MatrixCell/MultiSelectDropdown) ώστε να μη κόβεται μέσα σε scroll containers (.tablewrap,
// matrix cells). Δεν εμφανίζεται καθόλου αν δεν υπάρχει comment (τίποτα προς δείξιμο).
function CommentInfoPopover({ text }) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState(null);
  const triggerRef = useRef(null);
  const popoverRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) {
      if (triggerRef.current && triggerRef.current.contains(e.target)) return;
      if (popoverRef.current && popoverRef.current.contains(e.target)) return;
      setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  if (!text) return null;

  function toggle(e) {
    e.stopPropagation();
    if (open) { setOpen(false); return; }
    const r = triggerRef.current.getBoundingClientRect();
    setPos({ top: r.bottom + 4, left: r.left });
    setOpen(true);
  }

  return (
    <span className="comment-info" onClick={(e) => e.stopPropagation()}>
      <button type="button" ref={triggerRef} className="comment-info-trigger" title="Comment" onClick={toggle}>
        <Icon name="info" size={13} />
      </button>
      {open && pos ? ReactDOM.createPortal(
        <div className="comment-info-popover" ref={popoverRef} style={{ position: 'fixed', top: pos.top, left: pos.left }}>
          {text}
        </div>,
        document.body,
      ) : null}
    </span>
  );
}

// info: {live,open,boxMoving,nonDisplay,feed,comment} (βλ. GET /api/cluster-tiers clusterInfo,
// GET /api/clusters) -> "1 live · 1 open · 5 feed" — μόνο τα πεδία με πραγματική τιμή (null/
// undefined παραλείπεται, ίδιο "δείξε ό,τι υπάρχει" convention με το MatrixCell's faceNote).
function clusterFacesText(info) {
  if (!info) return '';
  return CLUSTER_5_FIELD_DEFS.map(([f, label]) => (info[f] != null ? `${info[f]} ${label.toLowerCase()}` : null)).filter(Boolean).join(' · ');
}

// Πλήρες hover popover: faces + comment μαζί (ζητήθηκε ρητά "στο mousehover να φέρνει τα faces
// ... και τα σχόλια, ώστε να ξέρουμε τι κοιτάμε" — tab "Clusters ανά Store Tier"). Hover αντί για click
// (CommentInfoPopover) — μία εικόνα, ανοίγει/κλείνει με mouseenter/mouseleave, δεν χρειάζεται
// "click έξω για να κλείσει" μηχανισμό αφού δεν έχει interactive περιεχόμενο μέσα του.
function ClusterFacesPopover({ info }) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState(null);
  const triggerRef = useRef(null);
  if (!info) return null;
  const faces = clusterFacesText(info);
  if (!faces && !info.comment) return null;
  function onEnter() {
    const r = triggerRef.current.getBoundingClientRect();
    setPos({ top: r.bottom + 4, left: r.left });
    setOpen(true);
  }
  return (
    <span ref={triggerRef} className="cluster-faces-trigger" onMouseEnter={onEnter} onMouseLeave={() => setOpen(false)}>
      <Icon name="info" size={12} />
      {open && pos ? ReactDOM.createPortal(
        <div className="cluster-faces-popover" style={{ position: 'fixed', top: pos.top, left: pos.left }}>
          {faces ? <div>{faces}</div> : null}
          {info.comment ? <div className="cluster-faces-popover-comment">{info.comment}</div> : null}
        </div>,
        document.body,
      ) : null}
    </span>
  );
}

// Ίδιο περιεχόμενο με το ClusterFacesPopover, πάντα ορατό αντί για hover — ζητήθηκε ρητά "ένα
// toggle που να ανοιγοκλείνω την πληροφορία μόνιμα πάνω στον πίνακα" (βλ. showInfo στο
// ClusterTiersPage). Χρησιμοποιείται αντί για το popover όταν το toggle είναι ενεργό.
function ClusterFacesInline({ info }) {
  if (!info) return null;
  const faces = clusterFacesText(info);
  if (!faces && !info.comment) return null;
  return (
    <div className="cluster-faces-inline">
      {faces ? <div className="cluster-faces-inline-faces">{faces}</div> : null}
      {info.comment ? (
        <div className="cluster-faces-inline-comment"><b>Σχόλιο:</b> {info.comment}</div>
      ) : null}
    </div>
  );
}

/* ============================ Request cart ============================ */
// Σύνολο Αλλαγών (προτεινόμενες αλλαγές, pm) πριν το "Υποβολή" — μοιράζεται μεταξύ
// ClustersPage και StoresPage. Server-backed (πίνακας cart_drafts, βλ.
// sql/archive/migrate_cart_drafts.sql) — ΠΟΤΕ δεν σβήνει αυτόματα (reload/logout/session expiry),
// μόνο ρητή αφαίρεση (μεμονωμένη ή μαζική στο "Υποβολή") το αδειάζει. Optimistic local state
// (ίδια instant αίσθηση με το παλιό in-memory cart) + background sync στο server· σε
// αποτυχία συγχρονισμού γίνεται reload από τον server ώστε να μη μείνει ασυνεπές τοπικό state.
const RequestCartContext = createContext(null);
const useRequestCart = () => useContext(RequestCartContext);

function cartItemKey(item) {
  if (item.entity === 'cluster' && item.action === 'update') return `cluster:${item.targetId}`;
  if (item.entity === 'store_assignment') return `store:${item.storeCode}:${item.spaceGroupId}`;
  return `new:${Math.random()}`; // κάθε πρόταση νέας γραμμής μένει ξεχωριστή
}

function RequestCartProvider({ children }) {
  const me = useAuth();
  const [items, setItems] = useState([]);
  const canUseCart = me && (CAN_SUBMIT_DISPLAY_REQUESTS(me.role) || CAN_SUBMIT_FEED_REQUESTS(me.role));

  const reload = useCallback(() => {
    if (!canUseCart) { setItems([]); return; }
    api.get('/api/cart-drafts')
      .then((rows) => setItems(rows.map((r) => ({
        localId: r.id, id: r.id, key: cartItemKey({ entity: r.entity, action: r.action, targetId: r.targetId, storeCode: r.storeCode, spaceGroupId: r.spacegroupId }),
        entity: r.entity, action: r.action, targetId: r.targetId, spaceGroupId: r.spacegroupId, storeCode: r.storeCode,
        currentClusterCode: r.currentClusterCode, domain: r.domain,
        afterValue: r.afterValue, label: r.label, diffs: r.diffs || [],
      }))))
      .catch(() => {});
  }, [canUseCart]);
  useEffect(() => { reload(); }, [reload]);

  const add = useCallback((item) => {
    if (!canUseCart) return;
    // Optimistic: εμφανίζεται αμέσως (ίδια "instant" αίσθηση με πριν), key-based dedupe
    // τοπικά όπως έκανε πάντα το cart. Το πραγματικό id έρχεται μετά το server round-trip —
    // reload() το συγχρονίζει σιωπηλά όταν έρθει, χωρίς να χρειάζεται ο caller να περιμένει.
    const key = cartItemKey(item);
    setItems((list) => [...list.filter((i) => i.key !== key), { localId: Math.random(), key, ...item }]);
    api.send('POST', '/api/cart-drafts', item).then(reload).catch((e) => { toastErr(e.message); reload(); });
  }, [canUseCart, reload]);

  const remove = useCallback((localId) => {
    setItems((list) => list.filter((i) => i.localId !== localId));
    // localId είναι το πραγματικό DB id (bigint, ακέραιος) αφού συγχρονιστεί μία φορά με τον
    // server· Math.random() (πάντα < 1, ποτέ ακέραιος) μόνο για μία στιγμή optimistic πριν
    // ολοκληρωθεί το αρχικό POST — δεν υπάρχει ακόμα πραγματικό id να διαγραφεί, το επόμενο
    // reload() (από το ίδιο το add()) θα το συγχρονίσει σωστά.
    if (Number.isInteger(localId)) {
      api.send('DELETE', `/api/cart-drafts/${localId}`).catch((e) => { toastErr(e.message); reload(); });
    }
  }, [reload]);

  const clear = useCallback(() => {
    setItems([]);
    api.send('DELETE', '/api/cart-drafts').catch(() => {});
  }, []);

  const value = useMemo(() => ({ items, add, remove, clear }), [items, add, remove, clear]);
  return <RequestCartContext.Provider value={value}>{children}</RequestCartContext.Provider>;
}

const ROLE_LABELS = {
  super_admin: 'Super Admin',
  admin: 'Admin',
  moderator: 'Moderator',
  viewer: 'Viewer',
  pm: 'PM',
  space_manager: 'Space Manager',
  space_user: 'Space User',
  space_viewer: 'Space Viewer',
  on_demand_manager: 'On Demand Manager',
  on_demand_user: 'On Demand User',
  on_demand_viewer: 'On Demand Viewer',
};

const SPACEGROUP_DIM_FIELDS = ['division', 'family', 'category'];
// pm δεν γράφει πια απευθείας — υποβάλλει change_requests που εγκρίνει/απορρίπτει
// moderator/admin/super_admin. moderator δεν επεξεργάζεται τίποτα απευθείας πια.
const CAN_DIRECT_WRITE = (role) => role === 'super_admin' || role === 'admin';
const CAN_SUBMIT_REQUESTS = (role) => role === 'pm';
// space_manager/on_demand_manager: μπορούν πλέον να ανοίξουν νέα γραμμή (POST
// /api/clusters/propose-create — το δικό τους domain γράφεται απευθείας, το άλλο περνάει σαν
// αίτημα προς έγκριση), όχι μόνο να επεξεργαστούν υπάρχουσα γραμμή στο δικό τους domain (ήδη
// καλυπτόταν αλλού, βλ. canEditField/CAN_DIRECT_WRITE_DISPLAY/FEED).
const CAN_EDIT_SOMEHOW = (role) => CAN_DIRECT_WRITE(role) || CAN_SUBMIT_REQUESTS(role) || CAN_SUBMIT_DISPLAY_REQUESTS(role) || CAN_SUBMIT_FEED_REQUESTS(role) || role === 'space_manager' || role === 'on_demand_manager';
const CAN_REVIEW_REQUESTS = (role) => role === 'moderator' || role === 'admin' || role === 'super_admin';
// Βλέπει τη σελίδα Users + επεξεργάζεται Display Name (ζητήθηκε ρητά — πλατύτερο από πριν,
// όπου ήταν αυστηρά super_admin only). Ρόλος/Ενεργός-Ανενεργός/Reset password/Διαγραφή/
// Ενεργοποίηση παραμένουν ΑΠΟΚΛΕΙΣΤΙΚΑ super_admin — δικό τους check (π.χ. `me.role ===
// 'super_admin'`) μέσα στο UsersAdminPage, ΟΧΙ αυτό εδώ.
const CAN_MANAGE_USERS = (role) => role === 'super_admin' || role === 'admin';
// Team-scoped διαχείριση — δύο layers, ίδια ονόματα/σχήμα με το server (server.js):
// TEAM_MANAGED_ROLES = ποιους ρόλους μπορεί να ΔΗΜΙΟΥΡΓΗΣΕΙ/επεξεργαστεί/status ένας team
// ρόλος (space_manager/on_demand_manager τον δικό τους submit+viewer ρόλο, space_user/
// on_demand_user ΜΟΝΟ τον έναν viewer ρόλο ακριβώς από κάτω τους, "να φτιάχνουν τους
// επόμενους"). Χρησιμοποιείται εδώ ΜΟΝΟ για UI convenience (ποιοι ρόλοι φαίνονται στο
// dropdown δημιουργίας, ποια κελιά/κουμπιά είναι editable ανά γραμμή)· η πραγματική επιβολή
// γίνεται πάντα server-side.
// 'pm' και στους δύο managers μαζί — ίδιο σκεπτικό με το server mirror (ζητήθηκε ρητά "οι
// managers να μπορούν να ενεργοποιήσουν/απενεργοποιήσουν/μετονομάσουν/δημιουργήσουν pm
// λογαριασμούς"), ώστε να μπορούν να κάνουν και τα σχετικά assign στα spacegroups.
const TEAM_MANAGED_ROLES = {
  space_manager: ['space_user', 'space_viewer', 'pm'],
  on_demand_manager: ['on_demand_user', 'on_demand_viewer', 'pm'],
  space_user: ['space_viewer'],
  on_demand_user: ['on_demand_viewer'],
};
const CAN_MANAGE_TEAM = (role) => role === 'space_manager' || role === 'on_demand_manager';
// Broader read-only πρόσβαση στη σελίδα Χρήστες/Διαχείριση ομάδας — space_user/on_demand_user
// βλέπουν πλέον κι αυτοί (ζητήθηκε ρητά "οι users να βλέπουν από managers και κάτω..."), απλά
// με ΠΟΛΥ πιο περιορισμένο edit scope από τους managers (βλ. TEAM_MANAGED_ROLES παραπάνω).
const CAN_VIEW_TEAM = (role) => CAN_MANAGE_TEAM(role) || role === 'space_user' || role === 'on_demand_user';
// Ζητήθηκε ρητά: viewer/moderator/admin βλέπουν ΟΛΑ τα clusters/spacegroups (χωρίς
// ownership scoping, ίδιο με admin) — μόνο pm περιορίζεται στα δικά του. "Δουν" != "να
// επεξεργαστούν" — τα write actions στο SpacegroupsAdminPage παραμένουν πίσω από
// CAN_DIRECT_WRITE (super_admin/admin only), ξεχωριστό flag από αυτό εδώ. Οι 6 νέοι
// Space/On Demand ρόλοι βλέπουν κι αυτοί τα πάντα (χωρίς ownership scoping, ρητά ζητήθηκε).
const CAN_VIEW_SPACEGROUPS = (role) => ['super_admin', 'admin', 'moderator', 'viewer',
  'space_manager', 'space_user', 'space_viewer', 'on_demand_manager', 'on_demand_user', 'on_demand_viewer'].includes(role);
const CAN_MANAGE_SPACEGROUPS = CAN_VIEW_SPACEGROUPS;
// PMM roster tab: ζητήθηκε ρητά ΕΞΩ από τον viewer (δεν το χρειάζεται) — ίδιο σύνολο
// ρόλων με CAN_VIEW_SPACEGROUPS μείον 'viewer', ξεχωριστό flag ώστε να μην ξαναγίνει ένα
// μπερδεμένο σημείο αν αλλάξει ξανά το ένα χωρίς το άλλο.
const CAN_VIEW_PMM = (role) => role === 'super_admin' || role === 'admin' || role === 'moderator';
// Dashboard — ζητήθηκε ρητά "στον moderator και στους managers να εμφανίζεται" (πριν μόνο
// super_admin). "managers" = και οι δύο τύποι (space_manager/on_demand_manager) — ΟΧΙ οι
// αντίστοιχοι *_user/*_viewer, ΟΧΙ admin/pm (δεν ζητήθηκαν). Ίδιο σύνολο ρόλων και server-side
// (requireRole στο GET /api/dashboard/stats & /compare).
const CAN_VIEW_DASHBOARD = (role) => role === 'super_admin' || role === 'moderator' || role === 'space_manager' || role === 'on_demand_manager';
// Τετραγωνικά (SqftPage) — direct write, ΟΧΙ μέσω request/approve cart (ζητήθηκε ρητά
// "να μπορούν να αλλάξουν απευθείας"). space_manager/space_user συγκεκριμένα εδώ (όχι όλο το
// CAN_VIEW_SPACEGROUPS σύνολο) — πχ viewer/moderator/on_demand_* βλέπουν μόνο, δεν γράφουν.
const CAN_EDIT_SQFT = (role) => role === 'super_admin' || role === 'space_manager' || role === 'space_user';

// Space/On Demand ρόλοι — παράλληλο σύστημα δίπλα στο pm/moderator (βλ. server.js
// DISPLAY_*/FEED_* σταθερές, ίδια ονομασία/σχήμα). space_manager/on_demand_manager γράφουν
// απευθείας ΚΑΙ εγκρίνουν το δικό τους domain (δύο ξεχωριστές CAN_* λόγω αυτού — μπορεί να
// είναι true και οι δύο). space_user/on_demand_user μόνο υποβάλλουν. *_viewer μόνο βλέπουν
// (καλύπτονται ήδη από CAN_VIEW_SPACEGROUPS, καμία επιπλέον write δυνατότητα).
const CAN_DIRECT_WRITE_DISPLAY = (role) => CAN_DIRECT_WRITE(role) || role === 'space_manager';
const CAN_DIRECT_WRITE_FEED = (role) => role === 'super_admin' || role === 'admin' || role === 'on_demand_manager';
const CAN_SUBMIT_DISPLAY_REQUESTS = (role) => role === 'pm' || role === 'space_user';
const CAN_SUBMIT_FEED_REQUESTS = (role) => role === 'pm' || role === 'on_demand_user';
const CAN_REVIEW_DISPLAY_REQUESTS = (role) => CAN_REVIEW_REQUESTS(role) || role === 'space_manager';
const CAN_REVIEW_FEED_REQUESTS = (role) => role === 'moderator' || role === 'admin' || role === 'super_admin' || role === 'on_demand_manager';
// Βλέπει το tab "Αιτήματα" καθόλου — pm/moderator/admin/super_admin (όπως πριν) ΚΑΙ όλοι οι
// 6 νέοι ρόλοι (Manager/User/Viewer και για τα δύο domains) — ρητά ζητήθηκε καθολική
// ορατότητα (βλ. ALL_VISIBLE_ROLES στο server.js, ίδιο σύνολο ρόλων).
const CAN_SEE_REQUESTS_TAB = (role) => CAN_SUBMIT_REQUESTS(role) || CAN_REVIEW_REQUESTS(role) ||
  ['space_manager', 'space_user', 'space_viewer', 'on_demand_manager', 'on_demand_user', 'on_demand_viewer'].includes(role);

// "Locked" — 5 ανεξάρτητα booleans στο spacegroups (block_live_edit/block_open_edit/
// block_box_moving_edit/block_non_display_edit/block_feed_edit, βλ. sql/archive/migrate_cluster_5fields.sql)
// αντί για το παλιό ζεύγος block_display_edit/block_feed_edit — κάθε πεδίο κλειδώνει
// ανεξάρτητα, καμία "Non-Display exception" πλέον (ζητήθηκε ρητά: ένα locked πεδίο δεν
// προτείνεται καθόλου, όχι μερική εξαίρεση). Ελέγχεται ΚΑΙ server-side (πραγματική αρχή) —
// αυτό εδώ μόνο εμποδίζει το άσκοπο 403 round-trip.
const FIELD_LOCK_FLAG = {
  live: 'blockLiveEdit', open: 'blockOpenEdit', boxMoving: 'blockBoxMovingEdit',
  nonDisplay: 'blockNonDisplayEdit', feed: 'blockFeedEdit',
};
const FEED_FIELDS = ['feed'];

function fieldDomain(field) { return FEED_FIELDS.includes(field) ? 'feed' : 'display'; }

// Ίδια λογική με το server (lib/auth.js + server.js) — εδώ μόνο για UX, ο server
// είναι πάντα η τελική αρχή. sg: το (flat, βλ. mergeClusterRow) cluster row που ήδη έχει
// τα 5 block*Edit flags πάνω του — canEditField κοιτάζει ΜΟΝΟ το lock flag του ίδιου του
// πεδίου (FIELD_LOCK_FLAG[field]), όχι ένα domain-level flag.
function canEditField(role, field, sg) {
  if (CLUSTER_FIELDS.readOnly.includes(field)) return false;
  // date/requestedBy: ΠΑΝΤΑ server-controlled, για ΚΑΘΕ ρόλο (ούτε super_admin) — ζητήθηκε
  // ρητά "εντελώς αυτόματα σε όλους τους ρόλους ... και για επεξεργασία". Ο server αγνοεί/
  // ξαναγράφει ό,τι σταλεί εδώ ούτως ή άλλως (βλ. PUT /api/clusters/:id), αυτό εδώ είναι μόνο
  // για να μην εμφανίζονται καν σαν επεξεργάσιμα πεδία στο UI.
  if (field === 'requestedBy' || field === 'date') return false;
  // Ταυτότητα της γραμμής — σε ποιο spacegroup/division/category/PM ανήκει — δεν αλλάζει σε
  // μια ήδη υπάρχουσα γραμμή, ΓΙΑ ΚΑΝΕΝΑ ρόλο· επιλέγονται ΜΟΝΟ σε νέα γραμμή (βλ.
  // isFieldEditableInDrawer isNew branch). Ζητήθηκε ρητά: "αφού ανήκει ήδη σε division κτλ,
  // θα πρέπει να είναι προσυμπληρωμένα και ανενεργά ... δεν αλλάζουν αυτά".
  if (SPACEGROUP_DIM_FIELDS.includes(field)) return false;
  if (field === 'spaceGroupId') return false;
  const domain = fieldDomain(field);
  const canDirectWrite = domain === 'feed' ? CAN_DIRECT_WRITE_FEED(role) : CAN_DIRECT_WRITE_DISPLAY(role);
  const canSubmit = domain === 'feed' ? CAN_SUBMIT_FEED_REQUESTS(role) : CAN_SUBMIT_DISPLAY_REQUESTS(role);
  if (!canDirectWrite && !canSubmit) return false; // viewer/moderator/λάθος-domain ρόλος: καθόλου edit
  // currentStatement το θέτει ο server κατά την έγκριση — κανένας submit-only ρόλος δεν το
  // προτείνει ελεύθερα. approval δεν είναι πλέον πεδίο καθόλου (πλήρως υπολογισμένο από
  // pending display/feed requests, βλ. approvalPill).
  if (field === 'currentStatement' && canSubmit && !canDirectWrite) return false;
  // comment: ΜΟΝΟ Space (space_manager direct, space_user submit) — ο pm (εμπορικός) δεν έχει
  // δικαίωμα σχολίου cluster, ζητήθηκε ρητά, αν και είναι στο CAN_SUBMIT_DISPLAY_REQUESTS για
  // όλα τα άλλα display πεδία. Ελέγχεται ΚΑΙ server-side (πραγματική αρχή, βλ. POST
  // /api/change-requests) — αυτό εδώ μόνο εμποδίζει το άσκοπο 403 round-trip.
  if (field === 'comment' && role === 'pm') return false;
  const lockFlag = FIELD_LOCK_FLAG[field];
  const blocked = lockFlag && sg && sg[lockFlag];
  if (blocked && canSubmit && !canDirectWrite) return false;
  return true;
}

function isFieldEditableInDrawer(role, field, isNew, sg) {
  const domain = fieldDomain(field);
  const canDirectWrite = domain === 'feed' ? CAN_DIRECT_WRITE_FEED(role) : CAN_DIRECT_WRITE_DISPLAY(role);
  const canSubmit = domain === 'feed' ? CAN_SUBMIT_FEED_REQUESTS(role) : CAN_SUBMIT_DISPLAY_REQUESTS(role);
  if (isNew) {
    // Locked: καμία νέα γραμμή καθόλου (ίδιο enforce σε POST /api/change-requests) — ισχύει
    // ανά domain ξεχωριστά (π.χ. display locked δεν εμποδίζει feed-only νέα πρόταση, αλλά
    // στην πράξη μια νέα γραμμή cluster έχει πάντα και τα δύο domains μαζί, οπότε αν
    // ΟΠΟΙΟΔΗΠΟΤΕ από τα 4 display locks ή το feed lock είναι ενεργό, μπλοκάρεται ολόκληρη
    // η δημιουργία).
    const displayLocked = sg && (sg.blockLiveEdit || sg.blockOpenEdit || sg.blockBoxMovingEdit || sg.blockNonDisplayEdit);
    const feedLocked = sg && sg.blockFeedEdit;
    if ((displayLocked || feedLocked) && canSubmit && !canDirectWrite) return false;
    // key: auto-generated (spaceGroup+cluster, βλ. setField). date/requestedBy: ΠΑΝΤΑ
    // κλειδωμένα, ΓΙΑ ΚΑΘΕ ρόλο που δημιουργεί γραμμή (ζητήθηκε ρητά "και για νέα γραμμή...
    // σε όλους τους ρόλους"), όχι μόνο request-submitting roles όπως πριν.
    return !['spaceGroup', 'currentStatement', 'key', 'date', 'requestedBy', 'pmName'].includes(field);
  }
  return canEditField(role, field, sg);
}

/* ============================ Login / change password ============================ */
// Lock screen — μπαίνει ΠΡΙΝ το LoginPage, "σαν κλειδωμένα Windows" (ζητήθηκε ρητά): ρολόι +
// prompt, click οπουδήποτε ή οποιοδήποτε πλήκτρο αποκαλύπτει το πραγματικό login form. Καθαρά
// διακοσμητικό UX gate — ΚΑΜΙΑ ασφάλεια πίσω του, το πραγματικό auth παραμένει το login form
// μετά. Ίδιο .authscreen background με το LoginPage (ζωντανεύει ξανά ξεκλειδωμένο) ώστε η
// μετάβαση να νιώθει συνεχής, όχι δύο άσχετες οθόνες.
function LockScreen({ onUnlock }) {
  const [exiting, setExiting] = useState(false);
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const t = setInterval(() => setNow(new Date()), 30000);
    return () => clearInterval(t);
  }, []);

  function reveal() {
    setExiting(true);
    setTimeout(onUnlock, 320);
  }

  // Χωρίς dependency array — ξαναδένει το listener σε κάθε render (φτηνό), αποφεύγει stale
  // closure πάνω στο onUnlock/reveal χωρίς useCallback.
  useEffect(() => {
    window.addEventListener('keydown', reveal);
    return () => window.removeEventListener('keydown', reveal);
  });

  const time = now.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' });
  const date = now.toLocaleDateString('el-GR', { weekday: 'long', day: 'numeric', month: 'long' });

  return (
    <div className={'authscreen lockscreen' + (exiting ? ' exiting' : '')} onClick={reveal}
      role="button" tabIndex={0} aria-label="Πατήστε για είσοδο">
      <div className="lockscreen-brand">
        <div className="wordmark lockscreen-logo" role="img" aria-label="Public" />
        <span>Cluster Console</span>
      </div>
      <div className="lockscreen-card">
        <div className="lockscreen-lock"><Icon name="lock" size={20} /></div>
        <div className="lockscreen-clock">
          <div className="lockscreen-time">{time}</div>
          <div className="lockscreen-date">{date}</div>
        </div>
      </div>
      <div className="lockscreen-hint">
        <Icon name="lock" size={15} />
        Πατήστε οπουδήποτε για είσοδο
      </div>
    </div>
  );
}

function LoginPage({ onLoggedIn, idleNotice }) {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [busy, setBusy] = useState(false);
  const [success, setSuccess] = useState(false);
  const [err, setErr] = useState('');

  async function submit(e) {
    e.preventDefault();
    setBusy(true); setErr('');
    try {
      await api.send('POST', '/api/auth/login', { username, password });
      // Σύντομο success state πριν αλλάξει η οθόνη (onLoggedIn unmount-άρει αυτό το component) —
      // ζητήθηκε ρητά έμπνευση από premium login pages· η ίδια η μετάβαση μένει γρήγορη (<600ms),
      // όχι θεαματική intro sequence.
      setSuccess(true);
      setTimeout(onLoggedIn, 450);
    } catch (e) { setErr(e.message); setBusy(false); }
  }

  return (
    <div className="authscreen">
      <div className="authpanel">
        <div className="authhero">
          <div className="wordmark authlogo" role="img" aria-label="Public" />
          <div className="authhero-text">
            <b>Cluster Console</b>
            <span>Store Design</span>
          </div>
        </div>
        <form className="authcard" onSubmit={submit}>
          {idleNotice ? <div className="authnotice">Αποσυνδεθήκατε λόγω 15 λεπτών αδράνειας. Συνδεθείτε ξανά.</div> : null}
          <div className="field">
            <label>Username</label>
            <input autoFocus value={username} onChange={(e) => setUsername(e.target.value)} disabled={success} />
          </div>
          <div className="field">
            <label>Password</label>
            <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} disabled={success} />
          </div>
          {err ? <div className="autherr">{err}</div> : null}
          <button className={'btn primary' + (success ? ' success' : '')} type="submit" disabled={busy || !username || !password}>
            {success ? '✓ Επιτυχία' : busy ? 'Σύνδεση…' : 'Σύνδεση'}
          </button>
        </form>
      </div>
    </div>
  );
}

function ChangePasswordPage({ onChanged }) {
  const [p1, setP1] = useState('');
  const [p2, setP2] = useState('');
  const [busy, setBusy] = useState(false);
  const [success, setSuccess] = useState(false);
  const [err, setErr] = useState('');

  async function submit(e) {
    e.preventDefault();
    if (p1.length < 8) return setErr('Το password πρέπει να έχει τουλάχιστον 8 χαρακτήρες.');
    if (p1 !== p2) return setErr('Τα passwords δεν ταιριάζουν.');
    setBusy(true); setErr('');
    try {
      await api.send('POST', '/api/auth/change-password', { newPassword: p1 });
      setSuccess(true);
      setTimeout(onChanged, 450);
    } catch (e) { setErr(e.message); setBusy(false); }
  }

  return (
    <div className="authscreen">
      <div className="authpanel">
        <div className="authhero">
          <div className="wordmark authlogo" role="img" aria-label="Public" />
          <div className="authhero-text">
            <b>Ορισμός password</b>
            <span>Πρώτη σύνδεση — όρισε το δικό σου password</span>
          </div>
        </div>
        <form className="authcard" onSubmit={submit}>
          <div className="field">
            <label>Νέο password</label>
            <input type="password" autoFocus value={p1} onChange={(e) => setP1(e.target.value)} disabled={success} />
          </div>
          <div className="field">
            <label>Επιβεβαίωση password</label>
            <input type="password" value={p2} onChange={(e) => setP2(e.target.value)} disabled={success} />
          </div>
          {err ? <div className="autherr">{err}</div> : null}
          <button className={'btn primary' + (success ? ' success' : '')} type="submit" disabled={busy || !p1 || !p2}>
            {success ? '✓ Επιτυχία' : busy ? 'Αποθήκευση…' : 'Ορισμός password'}
          </button>
        </form>
      </div>
    </div>
  );
}

/* ============================ helpers ============================ */
// Η στήλη Έγκριση δεν είναι πλέον ελεύθερο πεδίο (clusters.approval) — υπολογίζεται από το αν
// υπάρχει pending display και/ή pending feed change_request πάνω σε αυτό το cluster (βλ.
// pendingClusterDomains στο ClustersPage, targetId -> Set από domains με pending αίτημα).
// Τέσσερα states, ζητήθηκαν ρητά: (α) τίποτα pending -> Εγκρίθηκε (πράσινο, ίδιο με πριν),
// (β) και τα δύο pending -> Αναμονή εγκρίσεων (μπλε+πορτοκαλί gradient), (γ)/(δ) μόνο το ένα
// pending -> "Εγκρίθηκε Χ / Αναμονή Ψ" (πράσινο+[μπλε ή πορτοκαλί] gradient, δηλώνει ΠΟΙΟ
// domain εκκρεμεί ρητά στο ίδιο το κείμενο).
// Τιμές ζουν πλέον σε CSS custom properties (--domain-display-fg/--domain-feed-fg, βλ.
// styles.css) ώστε να ακολουθούν το theme αντί για μόνιμα σκούρο hex — var(...) δουλεύει
// κανονικά μέσα σε inline style strings, ο browser το επιλύει σαν κανονική CSS τιμή.
const DOMAIN_PILL_COLOR = { display: 'var(--domain-display-fg)', feed: 'var(--domain-feed-fg)' };
function approvalPill(pendingDomains) {
  const displayPending = pendingDomains && pendingDomains.has('display');
  const feedPending = pendingDomains && pendingDomains.has('feed');
  if (!displayPending && !feedPending) return <span className="pill ok">Εγκρίθηκε</span>;
  if (displayPending && feedPending) {
    return <span className="pill gradient-wait" style={{ background: `linear-gradient(90deg, ${DOMAIN_PILL_COLOR.display} 50%, ${DOMAIN_PILL_COLOR.feed} 50%)` }}>Αναμονή Display & Feed</span>;
  }
  const pendingColor = displayPending ? DOMAIN_PILL_COLOR.display : DOMAIN_PILL_COLOR.feed;
  const text = displayPending ? 'Εγκρίθηκε Feed / Αναμονή Space' : 'Εγκρίθηκε Space / Αναμονή Feed';
  return <span className="pill gradient-wait" style={{ background: `linear-gradient(90deg, var(--ok) 50%, ${pendingColor} 50%)` }}>{text}</span>;
}

/* ============================ Clusters page ============================ */
// [field, label] για τα 5 ανεξάρτητα numeric πεδία ενός cluster row — μία κοινή λίστα,
// ξαναχρησιμοποιείται στο required-on-create check (save()), στο CLUSTER_HISTORY_FIELDS και
// στο matrix cell annotation (StoresPage/MatrixCell).
const CLUSTER_5_FIELD_DEFS = [
  ['live', 'Live'], ['open', 'Open'], ['boxMoving', 'Box Moving'],
  ['nonDisplay', 'Non-Display'], ['feed', 'Feed'],
];

const CLUSTER_FIELDS = {
  select: {
    cluster: 'clusterCodes',
    currentStatement: 'currentStatements',
    division: 'divisions',
    category: 'categories',
  },
  number: ['live', 'open', 'boxMoving', 'nonDisplay', 'feed'],
  date: ['date'],
  // 'cluster' read-only εδώ ΜΟΝΟ σημαίνει "όχι μέσω της γενικής επεξεργασίας γραμμής" — σε
  // isNew (νέα γραμμή) παραμένει επιλέξιμο κανονικά (isFieldEditableInDrawer δεν περνάει καν
  // από εδώ όταν isNew), απλά μια ήδη υπάρχουσα γραμμή αλλάζει cluster code ΜΟΝΟ μέσω του
  // ρητού "Μετονομασία…" action (PUT .../rename-code, super_admin only) που ενημερώνει ΚΑΙ
  // store_cluster_assignments/change_requests/cart_drafts — όχι απλή επεξεργασία πεδίου.
  readOnly: ['id', 'key', 'spaceGroup', 'cluster', 'pmName'],
};

function EditableCell({ field, value, onChange, reference, optionsOverride, isNew }) {
  // Cluster σε ΝΕΑ γραμμή: ελεύθερο text field, όχι dropdown υπαρχόντων κωδικών — ο χρήστης
  // εφευρίσκει έναν καινούριο κωδικό εκείνη τη στιγμή, δεν διαλέγει από ήδη υπάρχοντες
  // (ζητήθηκε ρητά, "επειδή το φτιάχνω εκείνη την ώρα θέλω να είναι text field").
  if (field === 'cluster' && isNew) {
    return <input autoFocus value={value ?? ''} onChange={(e) => onChange(e.target.value)} placeholder="νέος κωδικός cluster" />;
  }
  if (field === 'spaceGroupId') {
    const names = (reference && reference.spaceGroupNames) || {};
    return (
      <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
        <option value="">— επίλεξε space group —</option>
        {Object.entries(names).sort((a, b) => a[1].localeCompare(b[1])).map(([id, name]) => (
          <option key={id} value={id}>{id} — {name}</option>
        ))}
      </select>
    );
  }
  if (CLUSTER_FIELDS.select[field]) {
    const opts = optionsOverride || (reference && reference[CLUSTER_FIELDS.select[field]]) || [];
    const known = opts.includes(value);
    return (
      <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
        <option value=""></option>
        {!known && value ? <option value={value}>{value}</option> : null}
        {opts.map((o) => <option key={o} value={o}>{o}</option>)}
      </select>
    );
  }
  if (CLUSTER_FIELDS.number.includes(field)) {
    return (
      <input className="num" type="number" value={value ?? ''}
        onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} />
    );
  }
  if (CLUSTER_FIELDS.date.includes(field)) {
    return <input type="date" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />;
  }
  // Comment: περιγραφικό κείμενο (τρόπος στησίματος/έπιπλο) — textarea αντί για μονόγραμμο
  // input, καλύτερο UX για κάτι που ρεαλιστικά είναι μια ολόκληρη πρόταση.
  if (field === 'comment') {
    return <textarea rows={3} value={value ?? ''} onChange={(e) => onChange(e.target.value)} />;
  }
  return <input value={value ?? ''} onChange={(e) => onChange(e.target.value)} />;
}

// Σειρά ζητήθηκε ρητά: Division -> Category -> Space group, ώστε ο χρήστης να στενεύει
// πρώτα το taxonomy πριν διαλέξει το ίδιο το spacegroup — το auto-fill (βλ. setField) παραμένει
// ανεξάρτητο από τη σειρά εμφάνισης, το spacegroup ΠΑΝΤΑ αντικαθιστά division/category όταν
// διαλέγεται, ακόμα κι αν είχαν ήδη επιλεγεί χειροκίνητα.
const DRAWER_FIELDS = [
  ['division', 'Division'],
  ['category', 'Category'],
  ['spaceGroupId', 'Space group'],
  ['spaceGroup', 'Space group name'],
  ['pmName', 'PM'],
  ['cluster', 'Cluster'],
  ['live', 'Live'],
  ['open', 'Open'],
  ['boxMoving', 'Box Moving'],
  ['nonDisplay', 'Non-Display'],
  ['feed', 'Feed (SKUs)'],
  ['comment', 'Comment'],
  ['date', 'Ημ/νία'],
  ['requestedBy', 'Αιτών'],
  ['key', 'Key'],
];

// Μόνο pm — προτείνει ένα ΝΕΟ spacegroup σαν αίτημα (entity='spacegroup'). Μόλις εγκριθεί, ο
// server το αναθέτει αυτόματα στον ίδιο τον pm που το πρότεινε (σαν να το είχε "ταγκάρει" ένας
// super_admin) — βλ. POST/approve στο server.js.
// Cascading taxonomy picker — κάθε πεδίο (Division/Family/Category) δείχνει
// μόνο τιμές που πραγματικά συνυπάρχουν με ό,τι έχει ήδη επιλεγεί στα άλλα (AND/
// intersection, βλ. GET /api/spacegroups/taxonomy) — μπορείς να ξεκινήσεις από
// οποιοδήποτε πεδίο, όχι μόνο Division->Category.
const TAXONOMY_FIELDS = ['division', 'family', 'category'];
const TAXONOMY_LABELS = { division: 'Division', family: 'Family', category: 'Category' };

function ProposeSpacegroupDrawer({ me, onClose }) {
  const cart = useRequestCart();
  const [id, setId] = useState('');
  const [name, setName] = useState('');
  const [taxonomy, setTaxonomy] = useState({ division: '', family: '', category: '' });
  const [taxonomyOptions, setTaxonomyOptions] = useState({});
  // ownerPmmId: ένας pm ΕΙΝΑΙ πλέον απλά ένα users row — ο ίδιος του ο λογαριασμός είναι το
  // ownership key, καμία ξεχωριστή roster σύνδεση να ελεγχθεί.
  const ownerPmmId = me.id;

  useEffect(() => {
    const t = setTimeout(() => {
      api.get('/api/spacegroups/taxonomy' + qs(taxonomy)).then(setTaxonomyOptions).catch(() => {});
    }, 200);
    return () => clearTimeout(t);
  }, [taxonomy]);

  function setTaxonomyField(field, value) { setTaxonomy((t) => ({ ...t, [field]: value })); }
  function optionsFor(field) {
    const base = taxonomyOptions[field] || [];
    const cur = taxonomy[field];
    return cur && !base.includes(cur) ? [cur, ...base] : base;
  }

  function submit() {
    const idT = id.trim(), nameT = name.trim();
    if (!idT || !nameT) { toastErr('Χρειάζεται ID και όνομα'); return; }
    // spacegroups.id είναι integer — validate εδώ, όχι μόνο server-side, ώστε το σφάλμα να
    // φαίνεται πριν προστεθεί στο Σύνολο Αλλαγών.
    if (!/^\d+$/.test(idT)) { toastErr('Το ID πρέπει να είναι αριθμός'); return; }
    cart.add({
      entity: 'spacegroup', action: 'create', targetId: null,
      spaceGroupId: idT,
      afterValue: { name: nameT, ...taxonomy, ownerPmmId: Number(ownerPmmId) },
      diffs: [
        { field: 'id', after: idT }, { field: 'name', after: nameT },
        ...TAXONOMY_FIELDS.map((f) => ({ field: TAXONOMY_LABELS[f], after: taxonomy[f] || '—' })),
      ],
      label: 'Νέο spacegroup · ' + idT,
    });
    toast('Προστέθηκε στο Σύνολο Αλλαγών');
    onClose();
  }

  return (
    <div className="drawer-backdrop" onClick={onClose}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Νέο spacegroup (αίτημα)</b>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        <div className="drawer-body">
          <div className="drawer-field">
            <label>ID</label>
            <input autoFocus inputMode="numeric" value={id} onChange={(e) => setId(e.target.value)} />
          </div>
          <div className="drawer-field">
            <label>Όνομα</label>
            <input value={name} onChange={(e) => setName(e.target.value)} />
          </div>
          {TAXONOMY_FIELDS.map((f) => (
            <div className="drawer-field" key={f}>
              <label>{TAXONOMY_LABELS[f]}</label>
              <select value={taxonomy[f]} onChange={(e) => setTaxonomyField(f, e.target.value)}>
                <option value="">—</option>
                {optionsFor(f).map((v) => <option key={v} value={v}>{v}</option>)}
              </select>
            </div>
          ))}
        </div>
        <div className="drawer-foot">
          <button className="btn primary" onClick={submit}>Προσθήκη στο Σύνολο Αλλαγών</button>
          <button className="btn ghost" onClick={onClose}>Άκυρο</button>
        </div>
      </div>
    </div>
  );
}

function ClusterEditDrawer({ row, reference, role, onClose, onSaved, onCreated }) {
  const isNew = !row;
  const cart = useRequestCart();
  const me = useAuth();
  // true αν ο ρόλος υποβάλλει αίτημα (αντί για απευθείας save) σε ΤΟΥΛΑΧΙΣΤΟΝ ένα από τα δύο
  // domains — pm υποβάλλει και τα δύο (display+feed) μέσω ΤΟΥ ΙΔΙΟΥ drawer/save button, ενώ
  // space_manager/on_demand_manager γράφουν το δικό τους domain απευθείας (δεν φτάνουν καν σε
  // αυτό το branch για τα πεδία τους, isFieldEditableInDrawer τα βγάζει ήδη εκτός μέσω
  // canDirectWrite). space_user/on_demand_user υποβάλλουν μόνο το δικό τους domain.
  const submitsAsRequest = CAN_SUBMIT_DISPLAY_REQUESTS(role) || CAN_SUBMIT_FEED_REQUESTS(role);
  // Μετονομασία cluster code — ξεχωριστό, cascading action (ενημερώνει ΚΑΙ
  // store_cluster_assignments/change_requests/cart_drafts, βλ. PUT .../rename-code στο
  // server.js), ζητήθηκε ρητά "από το UI περιβάλλον του super admin" — πιο αυστηρό gate από
  // το CAN_DIRECT_WRITE της υπόλοιπης φόρμας.
  const [renaming, setRenaming] = useState(false);
  const [renameValue, setRenameValue] = useState('');
  const [renameBusy, setRenameBusy] = useState(false);
  async function submitRename() {
    const newCode = renameValue.trim();
    if (!newCode || newCode === row.cluster) return;
    setRenameBusy(true);
    try {
      const result = await api.send('PUT', '/api/clusters/' + row.id + '/rename-code', { newCode });
      toast(`Μετονομάστηκε σε "${newCode}" — ενημερώθηκαν ${result.storesUpdated} καταστήματα, ${result.requestsUpdated} εκκρεμή αιτήματα/προτάσεις`);
      setRenaming(false);
      onSaved();
    } catch (e) { toastErr(e.message); }
    setRenameBusy(false);
  }
  const [draft, setDraft] = useState(() => {
    // pmName: read-only προεπισκόπηση του PM του (ήδη επιλεγμένου) spacegroup — σε ήδη
    // υπάρχουσα γραμμή έρχεται έτοιμο από το row.pmGr (embedded join, βλ. mergeClusterRow στο
    // server.js), σε νέα γραμμή γεμίζει αυτόματα μόλις διαλεγεί spacegroup (βλ. setField).
    // requestedBy/date: πάντα το συνδεδεμένο προφίλ / η τρέχουσα ημερομηνία σε νέα γραμμή,
    // ΓΙΑ ΚΑΘΕ ρόλο (όχι μόνο request-submitting) — ο server ξαναγράφει ό,τι σταλεί εδώ ούτως
    // ή άλλως (βλ. isFieldEditableInDrawer), αυτό είναι μόνο η preview στο read-only πεδίο.
    return row
      ? { ...row, pmName: row.pmGr ? row.pmGr.displayName : null, pmGrId: row.pmGr ? row.pmGr.id : null }
      : { currentStatement: 'Current state', date: new Date().toISOString().slice(0, 10), pmName: null, pmGrId: null, requestedBy: me.name || me.username };
  });
  const [saving, setSaving] = useState(false);
  // Cascading Division/Category options — ΙΔΙΟ fetch-on-change pattern με το
  // ProposeSpacegroupDrawer (GET /api/spacegroups/taxonomy), scoped σε division/category μόνο
  // (αυτό το drawer δεν έχει family πεδίο). Live data ΠΑΝΤΑ (όχι μόνο σε νέα
  // γραμμή) — πριν η νέα γραμμή's Division dropdown έπαιρνε τιμές από το στατικό
  // reference.divisions (Reference lists import), που σημαίνει ότι ένα φρέσκο Spacegroups CSV
  // import με ΝΕΟ division δεν εμφανιζόταν πουθενά μέχρι να προστεθεί ΞΕΧΩΡΙΣΤΑ στο Reference
  // lists — ζητήθηκε ρητά να παίρνει τις τιμές απευθείας από τη βάση.
  const [taxonomyOptions, setTaxonomyOptions] = useState({});
  useEffect(() => {
    const t = setTimeout(() => {
      api.get('/api/spacegroups/taxonomy' + qs({ division: draft.division, category: draft.category }))
        .then(setTaxonomyOptions).catch(() => {});
    }, 200);
    return () => clearTimeout(t);
  }, [draft.division, draft.category]);

  function setField(f, v) {
    setDraft((d) => {
      const next = { ...d, [f]: v };
      if (f === 'spaceGroupId') {
        next.spaceGroup = (reference.spaceGroupNames || {})[v] || '';
        // Νέα γραμμή: division/category ΤΑΥΤΙΖΟΝΤΑΙ με το επιλεγμένο spacegroup — αυτόματο
        // fill μόλις διαλέγεται spacegroup (ζητήθηκε ρητά, "να επιλέγεται αυτόματα με βάση το
        // που ανήκει") ώστε να μη χρειάζεται να ξαναδιαλέγει κανείς χειροκίνητα κάτι που ήδη
        // καθορίζεται από το ίδιο το spacegroup. Μόνο σε νέα γραμμή — μια ήδη υπάρχουσα γραμμή
        // δεν αλλάζει spacegroup μέσω αυτού του πεδίου συνήθως (spaceGroupId στα DRAWER_FIELDS
        // επεξεργάσιμο μόνο από CAN_DIRECT_WRITE ρόλους, ρητά μια "μετακόμιση" ενέργεια — δεν
        // υποθέτουμε αυτόματα ότι θέλει ΚΑΙ αλλαγή division/category μαζί).
        if (isNew) {
          const dims = (reference.spaceGroupDims || {})[v];
          if (dims) {
            next.division = dims.division || ''; next.category = dims.category || '';
            next.pmName = dims.pmName || null; next.pmGrId = dims.pmGrId || null;
          }
        }
      }
      // key = spaceGroup+cluster (βλ. sql/schema_all.sql:74 comment) — best-effort auto-fill
      // μόνο, ξαναϋπολογίζεται όποτε αλλάζει είτε το spacegroup είτε το cluster.
      if (isNew && (f === 'spaceGroupId' || f === 'cluster')) {
        next.key = (next.spaceGroup || '') + (next.cluster || '');
      }
      return next;
    });
  }

  // Χωρίζει τα diffs σε display/feed — ΔΥΟ ξεχωριστά change_requests rows, ΠΟΤΕ ανάμειξη στο
  // ίδιο (jsonb blob ολόκληρου του field set, βλ. σχόλιο στο schema) ώστε ο Space Manager να
  // εγκρίνει το δικό του ανεξάρτητα από τον On Demand Manager. Ένα cart item ανά domain που
  // πραγματικά άλλαξε — αν άλλαξε μόνο το ένα, μόνο ένα item προστίθεται.
  function stageForRequest() {
    const byDomain = { display: { afterValue: {}, diffs: [] }, feed: { afterValue: {}, diffs: [] } };
    for (const [field] of DRAWER_FIELDS) {
      if (!isFieldEditableInDrawer(role, field, isNew, row)) continue;
      const domain = fieldDomain(field);
      const beforeVal = isNew ? undefined : row[field];
      const afterVal = draft[field];
      if (isNew) {
        if (afterVal !== undefined && afterVal !== '') {
          byDomain[domain].afterValue[field] = afterVal;
          byDomain[domain].diffs.push({ field, before: undefined, after: afterVal });
        }
      } else if (String(beforeVal ?? '') !== String(afterVal ?? '')) {
        byDomain[domain].afterValue[field] = afterVal;
        byDomain[domain].diffs.push({ field, before: beforeVal, after: afterVal });
      }
    }
    if (!byDomain.display.diffs.length && !byDomain.feed.diffs.length) { toastErr('Δεν έγινε καμία αλλαγή'); return; }
    if (!draft.spaceGroupId) { toastErr('Επίλεξε space group'); return; }
    const sgName = (reference.spaceGroupNames || {})[draft.spaceGroupId] || '';
    const sgLabel = `SG ${draft.spaceGroupId} ${sgName}`.trim();
    const clusterLabel = draft.cluster ? `Cluster ${draft.cluster}` : '';
    for (const domain of ['display', 'feed']) {
      const { afterValue, diffs } = byDomain[domain];
      if (!diffs.length) continue;
      const domainLabel = domain === 'feed' ? ' (Feed)' : '';
      cart.add({
        entity: 'cluster', action: isNew ? 'create' : 'update', domain,
        targetId: isNew ? null : row.id, spaceGroupId: draft.spaceGroupId,
        // currentClusterCode: το cluster code ΠΡΙΝ αυτή την αλλαγή (ίδιο ασχέτως αν το ίδιο το
        // πεδίο cluster άλλαξε ή όχι) — χρειάζεται στο cart drawer για να δείξει "ποια
        // καταστήματα επηρεάζονται" ΚΑΙ σε αμιγώς faces/non-display αλλαγές, όχι μόνο σε
        // rename cluster code (πριν έδειχνε affected stores ΜΟΝΟ όταν άλλαζε το ίδιο το
        // cluster field, ζητήθηκε ρητά να δείχνει πάντα).
        currentClusterCode: isNew ? null : row.cluster,
        afterValue, diffs,
        label: isNew ? `Νέα γραμμή · ${sgLabel}${clusterLabel ? ' · ' + clusterLabel : ''}${domainLabel}` : `#${row.id} · ${sgLabel}${clusterLabel ? ' · ' + clusterLabel : ''}${domainLabel}`,
      });
    }
    toast('Προστέθηκε στο Σύνολο Αλλαγών');
    onClose();
  }

  // Νέα γραμμή από ρόλο ΧΩΡΙΣ direct-write και στα δύο domains (space_manager/
  // on_demand_manager: μόνο στο δικό τους· pm: κανένα) — POST /api/clusters/propose-create
  // αντί για το γενικό cart/stageForRequest flow. Το δικό του domain εφαρμόζεται απευθείας, το
  // άλλο περνάει σαν pending αίτημα προς το άλλο τμήμα (βλ. server, create_group_id). Ένα
  // click, καμία έμμεση διαδρομή μέσω cart drawer — ζητήθηκε ρητά "ανοίγει νέο cluster...
  // καταχωρείται σαν νέο αίτημα", όχι "πρόσθεσε στο καλάθι και μετά υπόβαλε ξεχωριστά".
  async function proposeCreate() {
    for (const [field, label] of CLUSTER_5_FIELD_DEFS) {
      if (draft[field] === undefined || draft[field] === null || draft[field] === '') {
        toastErr(label + ' υποχρεωτικό'); return;
      }
    }
    if (!draft.spaceGroupId) { toastErr('Επίλεξε space group'); return; }
    if (!draft.cluster) { toastErr('Cluster code υποχρεωτικό'); return; }
    setSaving(true);
    try {
      await api.send('POST', '/api/clusters/propose-create', {
        spaceGroupId: draft.spaceGroupId, cluster: draft.cluster,
        live: draft.live, open: draft.open, boxMoving: draft.boxMoving, nonDisplay: draft.nonDisplay, feed: draft.feed,
      });
      toast('Καταχωρήθηκε — το δικό σου domain ενεργοποιήθηκε αμέσως, το άλλο περιμένει έγκριση');
      onCreated();
    } catch (e) { toastErr(e.message); }
    setSaving(false);
  }

  async function save() {
    if (isNew && ['space_manager', 'on_demand_manager', 'pm'].includes(role)) return proposeCreate();
    if (submitsAsRequest) return stageForRequest();
    if (isNew) {
      for (const [field, label] of CLUSTER_5_FIELD_DEFS) {
        if (draft[field] === undefined || draft[field] === null || draft[field] === '') {
          toastErr(label + ' υποχρεωτικό'); return;
        }
      }
    }
    setSaving(true);
    try {
      if (isNew) {
        const { row: created } = await api.send('POST', '/api/clusters', draft);
        toast('Δημιουργήθηκε');
        onCreated(created);
      } else {
        // Μόνο τα πεδία που το drawer έδειξε ως επεξεργάσιμα σε αυτόν τον ρόλο — ΟΧΙ ολόκληρο
        // το draft. Το draft κουβαλάει ΚΑΙ το feed (αναγκαίο για το read-only preview), οπότε
        // ένα ανέγγιχτο feed έμπαινε πάντα στο PUT body· ο server απορρίπτει (403) μόλις δει
        // 'feed' in patch για ρόλο χωρίς feed δικαιώματα (π.χ. space_manager) — ακόμα κι όταν
        // η τιμή δεν είχε αλλάξει καθόλου. Ίδιο φίλτρο με το stageForRequest παραπάνω.
        const patch = Object.fromEntries(
          DRAWER_FIELDS.filter(([f]) => isFieldEditableInDrawer(role, f, isNew, row)).map(([f]) => [f, draft[f]])
        );
        const { row: updated } = await api.send('PUT', '/api/clusters/' + draft.id, patch);
        toast('Αποθηκεύτηκε');
        onSaved(updated);
      }
    } catch (e) { toastErr(e.message); }
    setSaving(false);
  }

  const anyEditable = DRAWER_FIELDS.some(([f]) => isFieldEditableInDrawer(role, f, isNew, row));
  // Ονόματα των πεδίων που είναι κλειδωμένα σε αυτή τη γραμμή — μόνο ενημερωτικό banner,
  // το ίδιο το κλειδωμένο πεδίο δεν εμφανίζεται καν ως επεξεργάσιμο παρακάτω (canEditField).
  const lockedFieldLabels = row ? CLUSTER_5_FIELD_DEFS.filter(([f]) => row[FIELD_LOCK_FLAG[f]]).map(([, l]) => l) : [];

  return (
    <div className="drawer-backdrop" onClick={onClose}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <div>
            <b>{isNew ? 'Νέα γραμμή' : `Επεξεργασία cluster #${row.id}`}</b>
            {/* Ζητήθηκε ρητά "να έχει τα στοιχεία αλλαγής ... κατανοητό" — πριν έδειχνε το
                mashed row.key (spaceGroup+cluster χωρίς κενά/ετικέτες, π.χ. "Cosmos Mallc1").
                Εδώ τα ίδια στοιχεία (SG id/όνομα/cluster) αλλά ρητά labeled, ζωντανά από το
                draft ώστε να ενημερώνεται και σε νέα γραμμή μόλις διαλέγεται spacegroup. */}
            {draft.spaceGroupId ? (
              <div className="drawer-subtitle">
                SG {draft.spaceGroupId} · {draft.spaceGroup || '—'}{draft.cluster ? ` · Cluster ${draft.cluster}` : ''}
              </div>
            ) : null}
          </div>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        {lockedFieldLabels.length ? (
          <div className="matrix-note" style={{ margin: '0 20px 10px', color: '#b3382a' }}>
            <span className="pill no" style={{ marginRight: 6 }}>Locked: {lockedFieldLabels.join(', ')}</span>
            Τα κλειδωμένα πεδία δεν εμφανίζονται για επεξεργασία.
          </div>
        ) : null}
        <div className="drawer-body">
          {DRAWER_FIELDS.map(([field, label]) => {
            const editable = isFieldEditableInDrawer(role, field, isNew, row);
            const showCurrent = editable && !isNew && submitsAsRequest && row[field] !== null && row[field] !== undefined && row[field] !== '';
            // Division/Category: live τιμές από τη βάση (taxonomy endpoint, ήδη #N/A-filtered
            // server-side) — ΟΧΙ το στατικό reference bag, ζητήθηκε ρητά. Category cascades από
            // Division (ίδιο AND/intersection convention με το ProposeSpacegroupDrawer). Ισχύει
            // ΚΑΙ σε ήδη υπάρχουσα γραμμή, όχι μόνο νέα — CAN_DIRECT_WRITE ρόλοι επεξεργάζονται
            // κι αυτά τα πεδία εκεί.
            let optionsOverride;
            if (field === 'division') {
              optionsOverride = taxonomyOptions.division || [];
            } else if (field === 'category') {
              optionsOverride = taxonomyOptions.category || [];
            }
            return (
              <div className="drawer-field" key={field}>
                <label>{label}</label>
                {editable ? (
                  <>
                    <EditableCell field={field} value={draft[field]} reference={reference} isNew={isNew}
                      optionsOverride={optionsOverride} onChange={(v) => setField(field, v)} />
                    {showCurrent ? <div className="drawer-current">Τρέχον: {String(row[field])}</div> : null}
                  </>
                ) : field === 'pmName' ? (
                  <div className="drawer-readonly">
                    {draft.pmName ? <UserNameLink user={draft.pmGrId ? { id: draft.pmGrId, displayName: draft.pmName } : null} fallback={draft.pmName} /> : '—'}
                  </div>
                ) : (
                  <div className="drawer-readonly">{draft[field] === null || draft[field] === undefined || draft[field] === '' ? '—' : String(draft[field])}</div>
                )}
                {field === 'cluster' && !isNew && role === 'super_admin' ? (
                  renaming ? (
                    <div className="drawer-rename-row">
                      <input autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
                        placeholder="νέος κωδικός" style={{ width: 120 }} />
                      <button type="button" className="btn primary sm" disabled={renameBusy || !renameValue.trim() || renameValue.trim() === row.cluster}
                        onClick={submitRename}>{renameBusy ? 'Μετονομασία…' : 'Μετονομασία'}</button>
                      <button type="button" className="btn ghost sm" onClick={() => setRenaming(false)}>Άκυρο</button>
                    </div>
                  ) : (
                    <button type="button" className="btn ghost sm" style={{ marginTop: 4 }}
                      onClick={() => { setRenameValue(row.cluster || ''); setRenaming(true); }}>
                      Μετονομασία…
                    </button>
                  )
                ) : null}
              </div>
            );
          })}
        </div>
        <div className="drawer-foot">
          {anyEditable ? (
            <button className="btn primary" disabled={saving} onClick={save}>
              {saving ? 'Αποθήκευση…' : submitsAsRequest ? 'Προσθήκη στο Σύνολο Αλλαγών' : (isNew ? 'Δημιουργία' : 'Αποθήκευση')}
            </button>
          ) : null}
          <button className="btn ghost" onClick={onClose}>Κλείσιμο</button>
        </div>
      </div>
    </div>
  );
}

// [field, label, kind (για στοίχιση), τύπος φίλτρου, reference key για select φίλτρα]
// Οι επιλογές του φίλτρου της (computed, βλ. approvalPill) στήλης Έγκριση υπολογίζονται
// δυναμικά στο ClustersPage (approvalFilterOptions, βλ. εκεί) — μόνο ό,τι πράγματι υπάρχει
// σήμερα, ζητήθηκε ρητά, όχι στατική λίστα με τις 4 πιθανές τιμές πάντα ορατές. Τα ελληνικά
// labels ΕΙΝΑΙ η τιμή που φτάνει στο GET /api/clusters?approval=... (MultiSelectDropdown/
// MultiSelectFilterTh δουλεύουν με flat string array, καμία ξεχωριστή value/label υποδομή) —
// το ίδιο branch στο server.js τα αναγνωρίζει ρητά (δεν είναι internal codes).

const CLUSTER_COLS = [
  ['division', 'Division', '', 'select', 'divisions'],
  ['category', 'Category', '', 'select', 'categories'],
  ['spaceGroupId', 'SG ID', 'code', 'select'],
  ['spaceGroup', 'Space Group', '', 'select'],
  ['cluster', 'Cluster', '', 'select', 'clusterCodes'],
  ['storeCount', 'Stores', 'num', 'text'],
  ['live', 'Live', 'num', 'select'],
  ['open', 'Open', 'num', 'select'],
  ['boxMoving', 'Box Moving', 'num', 'select'],
  ['nonDisplay', 'Non-Disp.', 'num', 'select'],
  ['feed', 'Feed (SKUs)', 'num', 'select'],
  ['approval', 'Έγκριση', '', 'select'],
  ['date', 'Ημ/νία', '', 'select'],
  ['requestedBy', 'Αιτών', '', 'select'],
  ['pmGrId', 'PM', '', 'select'],
];
const emptyClusterFilters = () => Object.fromEntries(CLUSTER_COLS.map(([f]) => [f, '']));

// Ένα κελί ενός cluster row, ίδιο ΑΚΡΙΒΩΣ rendering και στους δύο πίνακες Clusters (τον κύριο
// ΚΑΙ το "Clusters σε αναμονή δημιουργίας" — ζητήθηκε ρητά "ίδια ακριβώς δομή πίνακα... τα ίδια
// columns") — μία υλοποίηση, reused by both, όχι διπλός κώδικας. pendingDomains: Set<'display'|
// 'feed'> — στον κύριο πίνακα από το pendingClusterDomains Map, στον πίνακα αναμονής
// δημιουργίας από r.pendingDomains (πάντα μη-κενό εκεί, εξ ορισμού κάτι εκκρεμεί ακόμα).
function renderClusterCell(r, f, label, kind, pendingDomains) {
  if (f === 'approval') return <td key={f}>{approvalPill(pendingDomains)}</td>;
  if (f === 'cluster') return <td key={f}>{r[f] ? <><span className="chip">{r[f]}</span><CommentInfoPopover text={r.comment} /></> : ''}</td>;
  if (f === 'pmGrId') return <td key={f}>{r.pmGr ? <UserNameLink user={r.pmGr} /> : '—'}</td>;
  if (f === 'requestedBy') return <td key={f} className={kind}>{r.requestedBy ? <UserNameLink user={r.requestedByUser} fallback={r.requestedBy} /> : ''}</td>;
  return <td key={f} className={kind}>{r[f] === null || r[f] === undefined ? '' : String(r[f])}</td>;
}

function SortableTh({ field, label, sortBy, sortDir, onSort, className }) {
  const active = sortBy === field;
  return (
    <th className={className} onClick={() => onSort(field)} style={{ cursor: 'pointer', userSelect: 'none' }}>
      {label}
      <span className="sortarrow">{active ? (sortDir === 'desc' ? ' ▾' : ' ▴') : ''}</span>
    </th>
  );
}

const CLUSTER_HISTORY_FIELDS = [['cluster', 'Cluster'], ...CLUSTER_5_FIELD_DEFS, ['comment', 'Comment'], ['approval', 'Έγκριση'], ['date', 'Ημ/νία'], ['requestedBy', 'Αιτών']];
const CELL_HISTORY_FIELDS = [['clusterCode', 'Cluster']];

// Κοινό drawer για ιστορικό (edit_history) — είτε clusters ενός spacegroup είτε ένα κελί
// matrix. `url` φορτώνεται μία φορά κατά το mount.
function HistoryDrawer({ title, url, fields, onClose }) {
  const [entries, setEntries] = useState(null);

  useEffect(() => {
    api.get(url).then(setEntries).catch((e) => toastErr(e.message));
  }, [url]);

  return (
    <div className="drawer-backdrop" onClick={onClose}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Ιστορικό · {title}</b>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        <div className="drawer-body">
          {!entries ? (
            <div className="empty">Φόρτωση…</div>
          ) : entries.length === 0 ? (
            <div className="empty">Δεν υπάρχει ιστορικό ακόμα.</div>
          ) : entries.map((h) => (
            <div className="history-entry" key={h.id}>
              <div className="history-entry-date">{new Date(h.archivedAt).toLocaleString('el')}</div>
              <div className="history-entry-body">
                {fields.map(([k, label]) => {
                  const before = h.value ? h.value[k] : null;
                  const after = h.after ? h.after[k] : null;
                  const beforeStr = before === null || before === undefined || before === '' ? '—' : String(before);
                  const afterStr = after === null || after === undefined || after === '' ? '—' : String(after);
                  if (beforeStr === '—' && afterStr === '—') return null;
                  return (
                    <span key={k} className="history-field">
                      <b>{label}:</b> {beforeStr}
                      {beforeStr !== afterStr ? <> → <span className="history-after">{afterStr}</span></> : null}
                    </span>
                  );
                })}
              </div>
            </div>
          ))}
        </div>
        <div className="drawer-foot">
          <button className="btn ghost" onClick={onClose}>Κλείσιμο</button>
        </div>
      </div>
    </div>
  );
}

// "Clusters σε αναμονή δημιουργίας" — πάνω από τον κύριο πίνακα Clusters (ζητήθηκε ρητά), ίδια
// δομή/columns με το κύριο (βλ. renderClusterCell/CLUSTER_COLS, ίδιο ΑΚΡΙΒΩΣ rendering). Χωρίς
// δικό της αναζήτηση/φίλτρα (ζητήθηκε ρητά να αφαιρεθούν — μικρό, bounded dataset, δεν τα
// χρειάζεται) — μόνο page local state, ΟΧΙ persisted. Φαίνεται ΜΟΝΟ όταν υπάρχει έστω μία
// ακόμα-pending δημιουργία (total > 0), όχι πίσω από κάποιο toggle — ένα κρυφό/άδειο section θα
// ήταν ακριβώς το "χαοτικό" που το ζητούμενο θέλει να αποφύγει.
function PendingClusterCreationsSection({ visibleClusterCols, reloadToken }) {
  const [rows, setRows] = useState(null);
  const [total, setTotal] = useState(0);
  const [page, setPage] = useState(0);
  const perPage = 50;

  const reload = useCallback(() => {
    api.get('/api/clusters/pending-creations')
      .then((d) => { setRows(d.rows); setTotal(d.total); }).catch(() => {});
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [reloadToken]);
  useEffect(() => { reload(); }, [reload]);

  // rows === null σημαίνει "ακόμα φορτώνει" — αν δείχναμε το section σε αυτό το ενδιάμεσο state
  // (με "Φόρτωση…" μέσα) θα εμφανιζόταν ΠΑΝΤΑ στιγμιαία σε κάθε reload σελίδας, ακόμα κι όταν
  // τελικά δεν υπάρχει τίποτα pending — ακριβώς το "εμφανίζεται και μετά χάνεται" flicker που
  // αναφέρθηκε. Κρύβεται ΚΑΙ στα δύο states (loading + άδειο), εμφανίζεται μόνο όταν είναι ήδη
  // βέβαιο ότι υπάρχει κάτι να δείξει.
  if (!rows || rows.length === 0) return null;
  const pageCount = Math.max(1, Math.ceil(total / perPage));
  const pageRows = (rows || []).slice(page * perPage, (page + 1) * perPage);

  return (
    <div className="cluster-pending-section">
      <h3 className="cluster-pending-title">Clusters σε αναμονή δημιουργίας</h3>
      <div className="tablewrap">
        <table>
          <thead>
            <tr>
              {visibleClusterCols.map(([f, label]) => <th key={f}>{label}</th>)}
              <th className="sticky-actions">Ενέργειες</th>
            </tr>
          </thead>
          <tbody>
            {pageRows.map((r) => (
              <tr key={r.id}>
                {visibleClusterCols.map(([f, label, kind]) => renderClusterCell(r, f, label, kind, new Set(r.pendingDomains)))}
                <td className="sticky-actions actions">
                  <button className="btn ghost sm" title="Δες το εκκρεμές αίτημα"
                    onClick={() => { location.hash = '#/requests?highlight=' + r.pendingChangeRequestId; }}>
                    <Icon name="inbox" />
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <Pager page={page} total={total} perPage={perPage} pageCount={pageCount} onPageChange={setPage} />
    </div>
  );
}

function ClustersPage({ reference }) {
  const me = useAuth();
  const { role } = me;
  const pendingRefsVersion = usePendingRefsVersion();
  const [rows, setRows] = useState(null);
  const [total, setTotal] = useState(0);
  const [facets, setFacets] = useState({});
  // Στήλη "PM" (ζητήθηκε ρητά) — /api/pmm είναι super_admin/admin/moderator-only server-side,
  // skip το fetch για ρόλους χωρίς πρόσβαση (π.χ. pm/viewer βλέπουν ήδη μόνο τα δικά τους/όλα
  // χωρίς να χρειάζονται το roster για filtering). ΟΧΙ cascading facet (καμία RPC support
  // ακόμα εδώ, σε αντίθεση με το ίδιο φίλτρο στο SpacegroupsAdminPage) — πάντα η πλήρης λίστα.
  const [pmm, setPmm] = useState(null);
  useEffect(() => {
    if (!CAN_VIEW_PMM(role)) return;
    api.get('/api/pmm').then(setPmm).catch(() => {});
  }, [role]);
  const pmGrOptions = pmm || [];
  function pmIdList(raw) { return String(raw || '').split(',').filter(Boolean).map(Number); }
  function pmIdsToNames(raw, roster) {
    const byId = new Map(roster.map((p) => [p.id, p.displayName]));
    return pmIdList(raw).map((id) => byId.get(id)).filter(Boolean);
  }
  function toggleNameInIdList(raw, name, roster) {
    const p = roster.find((x) => x.displayName === name);
    if (!p) return raw;
    const ids = new Set(pmIdList(raw));
    if (ids.has(p.id)) ids.delete(p.id); else ids.add(p.id);
    return [...ids].join(',');
  }
  // q/filters/sortBy/sortDir: θυμάται το τελευταίο view ανά χρήστη (server, users.view_prefs,
  // βλ. useViewPrefs/usePersistedField) — ζητήθηκε ρητά να μη χρειάζεται να ξαναφτιάξει τα
  // φίλτρα σε κάθε επίσκεψη, ΚΑΙ ρητά server-side ώστε να ακολουθεί τον χρήστη cross-device.
  // page/drawerRow/κτλ ΔΕΝ επιμένουν (καθαρά εφήμερο navigation state).
  const viewPrefs = useViewPrefs('clusters');
  const [q, setQ] = usePersistedField(viewPrefs, 'q', '');
  const [qDebounced, setQDebounced] = useState(q);
  const [filters, setFilters] = usePersistedField(viewPrefs, 'filters', emptyClusterFilters());
  const [filtersDebounced, setFiltersDebounced] = useState(filters);
  const [page, setPage] = useState(0);
  const [sortBy, setSortBy] = usePersistedField(viewPrefs, 'sortBy', 'id');
  const [sortDir, setSortDir] = usePersistedField(viewPrefs, 'sortDir', 'asc');
  const [drawerRow, setDrawerRow] = useState(undefined); // undefined = closed, null = create, object = edit
  const [proposeSgOpen, setProposeSgOpen] = useState(false);
  const [historyFor, setHistoryFor] = useState(null); // { title, url } | null
  // Ανεβαίνει κάθε φορά που κλείνει το ClusterEditDrawer μετά από create — αναγκάζει το
  // PendingClusterCreationsSection να ξαναφορτώσει (βλ. reloadToken εκεί), ώστε μια νέα
  // πρόταση δημιουργίας (space_manager/on_demand_manager/pm) να εμφανιστεί αμέσως εκεί.
  const [pendingCreationsVersion, setPendingCreationsVersion] = useState(0);
  const perPage = 50;
  const { isHidden, toggle: toggleColumn, resetToDefault: resetColumns } = useColumnVisibility('clusters');
  const visibleClusterCols = useMemo(() => CLUSTER_COLS.filter(([f]) => !isHidden(f)), [isHidden]);

  function setFilter(field, value) { setFilters((f) => ({ ...f, [field]: value })); }
  function toggleSort(field) {
    if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    else { setSortBy(field); setSortDir('asc'); }
  }

  // Επιλογές κάθε select-φίλτρου, περιορισμένες σε ό,τι πραγματικά υπάρχει δεδομένων ΟΛΩΝ
  // των ΑΛΛΩΝ ενεργών φίλτρων (cascading/faceted search) — βλ. GET /api/clusters/facets.
  // Πάντα ενώνουμε τις τρέχουσες επιλεγμένες τιμές, ώστε καμία να μην εξαφανίζεται σιωπηλά.
  function optionsFor(field, refKey) {
    // String(...) — π.χ. nonDisplay είναι integer στήλη, τα facet values φτάνουν σαν
    // αριθμοί, αλλά το MultiSelectDropdown κάνει .toLowerCase() σε κάθε option (typeahead).
    const base = (facets[field] || (reference && reference[refKey]) || []).map(String);
    // filters[field] είναι comma-joined πολλαπλών επιλογών (MultiSelectFilterTh/toggleInSet),
    // ΟΧΙ μία τιμή — πρέπει να σπάσει σε μεμονωμένες τιμές πριν το merge, αλλιώς ολόκληρο το
    // joined string (π.χ. "A,B,C") μπαίνει σαν ΕΝΑ ψεύτικο option στη λίστα (bug: εμφανιζόταν
    // σαν ένα επιπλέον, λάθος entry μόλις άνοιγε ξανά το dropdown μετά από ≥2 επιλογές).
    const cur = String(filters[field] || '').split(',').filter(Boolean);
    const missing = cur.filter((v) => !base.includes(v));
    return missing.length ? [...missing, ...base] : base;
  }

  useEffect(() => {
    const t = setTimeout(() => setQDebounced(q), 300);
    return () => clearTimeout(t);
  }, [q]);

  useEffect(() => {
    const t = setTimeout(() => setFiltersDebounced(filters), 300);
    return () => clearTimeout(t);
  }, [filters]);

  useEffect(() => { setPage(0); }, [qDebounced, filtersDebounced, sortBy, sortDir]);

  // reloadSeq: αγνοεί απαντήσεις "μπαγιάτικων" requests — αν ο χρήστης αλλάξει φίλτρο/σελίδα
  // ξανά πριν προλάβει να απαντήσει το προηγούμενο GET, δύο requests είναι ταυτόχρονα in-flight
  // και χωρίς αυτό το guard όποιο απαντήσει ΤΕΛΕΥΤΑΙΟ "κερδίζει" (μπορεί να είναι το παλιό),
  // δείχνοντας λάθος/ξεπερασμένα rows για τα τρέχοντα φίλτρα.
  const reloadSeq = useRef(0);
  const reload = useCallback(() => {
    const seq = ++reloadSeq.current;
    setRows(null);
    api.get('/api/clusters' + qs({ q: qDebounced, ...filtersDebounced, page, pageSize: perPage, sortBy, sortDir }))
      .then((d) => { if (seq === reloadSeq.current) { setRows(d.rows); setTotal(d.total); } })
      .catch((e) => { if (seq === reloadSeq.current) toastErr(e.message); });
  }, [qDebounced, filtersDebounced, page, sortBy, sortDir]);

  useEffect(() => { reload(); }, [reload]);

  useEffect(() => {
    api.get('/api/clusters/facets' + qs({ q: qDebounced, ...filtersDebounced }))
      .then(setFacets).catch(() => {});
  }, [qDebounced, filtersDebounced]);

  // Ήδη-υποβλημένα (εκκρεμούν έγκριση) cluster requests — χρωματίζει τη γραμμή στη λίστα ώστε
  // ένας reviewer να το βλέπει με μια ματιά, χωρίς να μπει στο Requests tab, ΚΑΙ τροφοδοτεί τη
  // στήλη Έγκριση (βλ. approvalPill) που είναι πλέον πλήρως υπολογισμένη από αυτό το state, όχι
  // από το clusters.approval πεδίο. Map targetId -> Set<domain> (όχι single domain) γιατί ένα
  // cluster μπορεί να έχει ΚΑΙ display ΚΑΙ feed εκκρεμές αίτημα ταυτόχρονα (δύο ξεχωριστά rows,
  // βλ. ClusterEditDrawer split-σε-2) και η στήλη Έγκριση πρέπει να δείχνει και τα δύο ξεχωριστά
  // ("Εγκρίθηκε Space / Αναμονή Feed" κτλ). Ελαφρύ endpoint, ξεχωριστό από το reload του πίνακα
  // (δεν χρειάζεται να ξανατρέξει σε κάθε αλλαγή φίλτρου/σελίδας — τα pending requests δεν
  // αλλάζουν τόσο συχνά).
  const [pendingClusterDomains, setPendingClusterDomains] = useState(() => new Map());
  // targetId -> id ενός (οποιουδήποτε) pending request πάνω σε αυτό το cluster — τροφοδοτεί
  // το κουμπί "Δες αίτημα" στη γραμμή (πάει #/requests?highlight=ID). Αν υπάρχουν 2 (display+
  // feed ταυτόχρονα), κρατάμε το πρώτο που βρέθηκε — αρκεί ένα deep-link target, ο χρήστης θα
  // δει ΚΑΙ τα δύο αφού προσγειωθεί στη σελίδα Αιτήματα (η κάρτα-στόχος απλά κάνει scroll+pulse,
  // δεν κρύβει τις υπόλοιπες).
  const [pendingClusterRequestId, setPendingClusterRequestId] = useState(() => new Map());
  useEffect(() => {
    api.get('/api/change-requests/pending-refs').then((refs) => {
      const map = new Map();
      const idMap = new Map();
      for (const r of refs) {
        if (r.entity !== 'cluster' || r.targetId == null) continue;
        const domain = r.domain || 'display';
        if (!map.has(r.targetId)) map.set(r.targetId, new Set());
        map.get(r.targetId).add(domain);
        if (!idMap.has(r.targetId)) idMap.set(r.targetId, r.id);
      }
      setPendingClusterDomains(map);
      setPendingClusterRequestId(idMap);
    }).catch(() => {});
  }, [rows, pendingRefsVersion]);

  // Το dropdown φίλτρο της (computed) στήλης Έγκριση δείχνει ΜΟΝΟ τιμές που πράγματι
  // υπάρχουν σήμερα — ζητήθηκε ρητά, πριν έδειχνε πάντα και τις 4 πιθανές τιμές ασχέτως αν
  // υπήρχε έστω ένα cluster σε αυτή την κατάσταση. pendingClusterDomains ήδη καλύπτει ΟΛΑ τα
  // pending refs συνολικά (όχι scoped στο τρέχον page/filters), άρα "Εγκρίθηκε" υπάρχει πάντα
  // εκτός αν ΚΥΡΙΟΛΕΚΤΙΚΑ κάθε cluster στη βάση έχει pending αίτημα (πρακτικά ποτέ) — οι άλλες
  // 3 επιλογές εμφανίζονται μόνο αν υπάρχει έστω ένα cluster σε αυτό το state. Πάντα ενώνουμε
  // την τρέχουσα επιλεγμένη τιμή (ίδιο convention με optionsFor) ώστε να μην εξαφανίζεται
  // σιωπηλά αν ο χρήστης είχε ήδη επιλέξει κάτι που έγινε στο μεταξύ κενό.
  const approvalFilterOptions = useMemo(() => {
    let hasDisplay = false, hasFeed = false, hasBoth = false;
    for (const domains of pendingClusterDomains.values()) {
      if (domains.has('display') && domains.has('feed')) hasBoth = true;
      else if (domains.has('display')) hasDisplay = true;
      else if (domains.has('feed')) hasFeed = true;
    }
    const opts = ['Εγκρίθηκε'];
    if (hasDisplay) opts.push('Αναμονή Display');
    if (hasFeed) opts.push('Αναμονή Feed');
    if (hasBoth) opts.push('Αναμονή Display & Feed');
    const cur = filters.approval;
    if (cur) {
      for (const v of cur.split(',')) if (v && !opts.includes(v)) opts.push(v);
    }
    return opts;
  }, [pendingClusterDomains, filters.approval]);

  const pageCount = Math.max(1, Math.ceil(total / perPage));

  async function removeRow(r) {
    if (!confirm('Διαγραφή της γραμμής ' + (r.key || r.id) + ';')) return;
    try {
      await api.send('DELETE', '/api/clusters/' + r.id);
      toast('Διαγράφηκε');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  const anyFilterActive = q || Object.values(filters).some(Boolean);
  function clearFilters() { setQ(''); setFilters(emptyClusterFilters()); }
  const anyStructuredFilterActive = Object.values(filters).some(Boolean);
  const [filtersOpen, setFiltersOpen] = useState(false);

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow">
          <label>Αναζήτηση</label>
          <input className="search" placeholder="key, space group, cluster, αιτών…"
            value={q} onChange={(e) => setQ(e.target.value)} />
        </div>
        <button className="btn ghost sm" onClick={clearFilters} disabled={!anyFilterActive}>Καθαρισμός φίλτρων</button>
        <div className="spacer" />
        <button type="button" className="btn ghost" onClick={() => setFiltersOpen(true)}>
          Φίλτρα{anyStructuredFilterActive ? <span className="cartbadge">•</span> : null}
        </button>
        <ColumnsToggleButton fields={CLUSTER_COLS.map(([f, label]) => [f, label])} isHidden={isHidden} toggle={toggleColumn} resetToDefault={resetColumns} />
        {CAN_SUBMIT_REQUESTS(role) ? (
          <button className="btn ghost" onClick={() => setProposeSgOpen(true)}>+ Νέο Spacegroup (αίτημα)</button>
        ) : null}
        {CAN_EDIT_SOMEHOW(role) ? (
          <button className="btn primary" onClick={() => setDrawerRow(null)}>+ Νέα γραμμή</button>
        ) : null}
      </div>
      {proposeSgOpen ? <ProposeSpacegroupDrawer me={me} onClose={() => setProposeSgOpen(false)} /> : null}

      <PendingClusterCreationsSection visibleClusterCols={visibleClusterCols} reloadToken={pendingCreationsVersion} />

      {filtersOpen ? (
        <div className="drawer-backdrop" onClick={() => setFiltersOpen(false)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Φίλτρα</b>
              <button className="btn ghost sm" onClick={() => setFiltersOpen(false)} aria-label="Κλείσιμο">✕</button>
            </div>
            <div className="drawer-body">
              {visibleClusterCols.map(([f, label, kind, filterType, refKey]) => (
                f === 'approval' ? (
                  <MultiSelectFilterField key={f} label={label}
                    options={approvalFilterOptions}
                    value={filters[f]} onChange={(v) => setFilter(f, v)} placeholder="Όλα" />
                ) : f === 'pmGrId' ? (
                  <div className="field" key={f}>
                    <MultiSelectDropdown label={label}
                      options={pmGrOptions.map((p) => p.displayName)}
                      selected={new Set(pmIdsToNames(filters[f], pmGrOptions))}
                      placeholder="Όλοι"
                      onToggle={(name) => setFilter(f, toggleNameInIdList(filters[f], name, pmGrOptions))}
                    />
                  </div>
                ) : filterType === 'select' ? (
                  <MultiSelectFilterField key={f} label={label}
                    options={optionsFor(f, refKey).filter((v) => v !== '#N/A')}
                    value={filters[f]} onChange={(v) => setFilter(f, v)} placeholder="Όλα" />
                ) : filterType === 'none' ? null : (
                  <div className="drawer-field" key={f}>
                    <label>{label}</label>
                    <input value={filters[f]} onChange={(e) => setFilter(f, e.target.value)} placeholder="…"
                      title={f === 'storeCount' ? 'Δέχεται εκφράσεις σύγκρισης με & (AND) — π.χ. =>0&<10, >2&<7&!=5' : undefined} />
                  </div>
                )
              ))}
            </div>
          </div>
        </div>
      ) : null}

      <div className="count" style={{ marginBottom: 8 }}>
        {rows ? total.toLocaleString('el') : '…'} γραμμές
      </div>

      <div className="tablewrap">
        <table>
          <thead>
            <tr>
              {visibleClusterCols.map(([f, label]) => (
                f === 'approval' || f === 'pmGrId'
                  ? <th key={f}>{label}</th>
                  : <SortableTh key={f} field={f} label={label} sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              ))}
              <th className="sticky-actions">Ενέργειες</th>
            </tr>
          </thead>
          <tbody>
            {!rows ? (
              <tr><td colSpan={visibleClusterCols.length + 1} className="empty-cell">Φόρτωση…</td></tr>
            ) : rows.length === 0 ? (
              <tr><td colSpan={visibleClusterCols.length + 1} className="empty-cell">Καμία γραμμή</td></tr>
            ) : rows.map((r) => {
              const pendingDomains = pendingClusterDomains.get(r.id);
              const rowDomainClass = !pendingDomains ? '' :
                pendingDomains.size > 1 ? 'domain-both' : 'domain-' + [...pendingDomains][0];
              return (
              <tr key={r.id} className={rowDomainClass ? 'pending-request-row ' + rowDomainClass : ''}>
                {visibleClusterCols.map(([f, label, kind]) => renderClusterCell(r, f, label, kind, pendingDomains))}
                <td className="sticky-actions actions">
                  <button className="btn ghost sm" title={CAN_EDIT_SOMEHOW(role) ? 'Επεξεργασία' : 'Προβολή'}
                    onClick={() => setDrawerRow(r)}>
                    ✎
                  </button>
                  <button className="btn ghost sm" title="Ιστορικό"
                    onClick={() => setHistoryFor({ title: `SG ${r.spaceGroupId} · ${r.spaceGroup || ''}`, url: '/api/spacegroups/' + encodeURIComponent(r.spaceGroupId) + '/history' })}>
                    <Icon name="history" />
                  </button>
                  <button className="btn ghost sm" title="Δες ποια καταστήματα έχουν αυτό το cluster"
                    onClick={() => { location.hash = '#/stores?sg=' + encodeURIComponent(r.spaceGroupId) + '&cluster=' + encodeURIComponent(r.cluster); }}>
                    <Icon name="link" />
                  </button>
                  {pendingClusterRequestId.has(r.id) ? (
                    <button className="btn ghost sm" title="Δες το εκκρεμές αίτημα"
                      onClick={() => { location.hash = '#/requests?highlight=' + pendingClusterRequestId.get(r.id); }}>
                      <Icon name="inbox" />
                    </button>
                  ) : null}
                  {CAN_DIRECT_WRITE(role) ? (
                    <button className="btn ghost sm danger" title="Διαγραφή" onClick={() => removeRow(r)}>✕</button>
                  ) : null}
                </td>
              </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      <Pager page={page} total={total} perPage={perPage} pageCount={pageCount} onPageChange={setPage} />

      {drawerRow !== undefined ? (
        <ClusterEditDrawer
          row={drawerRow}
          reference={reference}
          role={role}
          onClose={() => setDrawerRow(undefined)}
          onSaved={() => { setDrawerRow(undefined); reload(); }}
          onCreated={() => { setDrawerRow(undefined); reload(); setPendingCreationsVersion((v) => v + 1); }}
        />
      ) : null}

      {historyFor ? (
        <HistoryDrawer title={historyFor.title} url={historyFor.url} fields={CLUSTER_HISTORY_FIELDS} onClose={() => setHistoryFor(null)} />
      ) : null}
    </div>
  );
}

/* ============================ Cluster per Store page ============================ */
const DIVISION_ORDER = ['Technology', 'Entertainment', 'Home Appliances', 'Other'];
const DIVISION_CLASS = {
  Technology: 'div-tech',
  Entertainment: 'div-ent',
  'Home Appliances': 'div-home',
  Other: 'div-other',
};
function divisionKey(division) {
  return (!division || division === '#N/A') ? 'Other' : division;
}
const DIVISION_BORDER_COLOR = {
  Technology: '#2f52b3',
  Entertainment: '#916908',
  'Home Appliances': '#0d7d7d',
  Other: '#b6bcc6',
};

// Ομάδα καταστήματος (Orange/Orange+/White/White+) — ΑΣΧΕΤΟ με τα DIVISION_* παραπάνω και
// ΑΣΧΕΤΟ με τα "cluster" codes (store_cluster_assignments) — δυστυχώς ίδια λέξη
// ("cluster"/"ομάδα") με τελείως διαφορετική έννοια. 4 σταθερές τιμές, καμία στη βάση —
// βλ. server.js STORE_GROUPS whitelist (canonical κεφαλαία μορφή, ίδια εδώ). Ταξινομημένα
// σκόπιμα σε ζευγάρια: πορτοκαλί/κίτρινο για τα δύο "Orange" (κατ' αντιστοιχία με το request),
// μπλε/μοβ για τα δύο "White" — ώστε τα 4 χρώματα να διακρίνονται εύκολα μεταξύ τους.
// Ίδιο convention με το DOMAIN_PILL_COLOR παραπάνω — var(...) strings αντί για raw hex,
// ώστε τα ~20 σημεία χρήσης παρακάτω (borders/chips/text color) να ακολουθούν το theme.
const STORE_GROUP_COLOR = {
  Orange: 'var(--store-orange-fg)',
  'Orange+': 'var(--store-orangeplus-fg)',
  White: 'var(--store-white-fg)',
  'White+': 'var(--store-whiteplus-fg)',
};
const STORE_GROUP_LABELS = ['Orange', 'Orange+', 'White', 'White+'];

function Toggle({ checked, onChange, label }) {
  return (
    <label className="toggle">
      <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
      <span className="toggle-track"><span className="toggle-thumb" /></span>
      <span className="toggle-label">{label}</span>
    </label>
  );
}

// Αριθμημένη pagination footer ("Εμφάνιση X–Y από Z" + windowed αριθμημένα κουμπιά γύρω από
// την τρέχουσα σελίδα, με "…" όπου παραλείπονται σελίδες) αντί για το παλιό "Σελίδα X/Y" + 4
// βέλη — καθαρά visual, ίδιο page/setPage/pageCount state από κάτω. Μοιράζεται μεταξύ
// ClustersPage και SpacegroupsAdminPage (ίδιο ΑΚΡΙΒΩΣ pagination shape και στα δύο).
function pagerWindow(current, count, span) {
  const pages = [];
  const start = Math.max(0, current - span);
  const end = Math.min(count - 1, current + span);
  if (start > 0) { pages.push(0); if (start > 1) pages.push('gap-start'); }
  for (let i = start; i <= end; i++) pages.push(i);
  if (end < count - 1) { if (end < count - 2) pages.push('gap-end'); pages.push(count - 1); }
  return pages;
}
function Pager({ page, total, perPage, pageCount, onPageChange }) {
  if (total === 0) return null;
  const from = page * perPage + 1;
  const to = Math.min(total, (page + 1) * perPage);
  return (
    <div className="pager">
      <span className="pager-summary">Εμφάνιση {from}–{to} από {total}</span>
      <div className="pager-pages">
        <button type="button" className="pager-btn" disabled={page === 0} onClick={() => onPageChange(page - 1)} aria-label="Προηγούμενη σελίδα">‹</button>
        {pagerWindow(page, pageCount, 2).map((p, i) => (
          typeof p === 'number' ? (
            <button key={p} type="button" className={'pager-btn' + (p === page ? ' active' : '')} onClick={() => onPageChange(p)}>{p + 1}</button>
          ) : (
            <span key={p + i} className="pager-ellipsis">…</span>
          )
        ))}
        <button type="button" className="pager-btn" disabled={page >= pageCount - 1} onClick={() => onPageChange(page + 1)} aria-label="Επόμενη σελίδα">›</button>
      </div>
    </div>
  );
}

// Deep-link από το Clusters tab: #/stores?sg=224&cluster=c3 -> "δείξε ποια καταστήματα
// έχουν αυτό ακριβώς το cluster δηλωμένο σήμερα για αυτό το spacegroup".
function parseHashQuery() {
  const qIdx = location.hash.indexOf('?');
  if (qIdx === -1) return {};
  return Object.fromEntries(new URLSearchParams(location.hash.slice(qIdx + 1)));
}

function toggleInSet(set, value) {
  const next = new Set(set);
  if (next.has(value)) next.delete(value); else next.add(value);
  return next;
}

// Συμπαγές multi-select: κλειστό by default ("N επιλεγμένα"/placeholder), ανοίγει popover
// με typeahead input (φιλτράρει client-side όσο πληκτρολογείς, ίδιο instant-filter
// convention με το optionsFor() στο ClustersPage) + checkboxes. Αντικαθιστά τα πάντα-
// ανοιχτά picker-scroll lists (π.χ. Category, ~95 στοιχεία πάντα ορατά πριν).
function MultiSelectDropdown({ label, options, selected, onToggle, placeholder, wide }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [pos, setPos] = useState(null); // { top, left, width } σε viewport συντεταγμένες | null
  const wrapRef = useRef(null);
  const triggerRef = useRef(null);
  const popoverRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) {
      if (wrapRef.current && wrapRef.current.contains(e.target)) return;
      if (popoverRef.current && popoverRef.current.contains(e.target)) return;
      setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  // Αυτό το dropdown ζει συχνά μέσα σε .tablewrap (filter-row th) ή άλλους overflow:auto/
  // hidden containers — ένα position:absolute popover ΜΕΣΑ σε τέτοιο container κόβεται
  // στο ορατό ύψος του (το overflow κόβει ΟΛΑ τα descendants), με αποτέλεσμα οι επιλογές να
  // "χάνονται" όταν ο πίνακας δεν έχει αρκετό ορατό ύψος από κάτω — ζητήθηκε ρητά να διορθωθεί.
  // Ίδιο portal-σε-document.body + position:fixed λύση με το MatrixCell popover παραπάνω,
  // υπολογισμένο από το πραγματικό screen rect του trigger ώστε να "ξεφεύγει" οπτικά από
  // ΚΑΘΕ scroll container ανεξαρτήτου πού χρησιμοποιείται αυτό το component.
  function openDropdown() {
    const r = triggerRef.current.getBoundingClientRect();
    const left = wide ? r.right - Math.max(r.width, 280) : r.left;
    const width = Math.max(r.width, wide ? 280 : 220);
    // Εκτίμηση μέγιστου ύψους popover (search input + .msd-list max-height 220px + padding/
    // clear button, βλ. styles.css) — αν δεν χωράει προς τα κάτω (π.χ. τελευταίο φίλτρο σε ένα
    // ψηλό Φίλτρα drawer, trigger κοντά στο κάτω άκρο της οθόνης) ανοίγει προς τα πάνω αντί να
    // κόβεται αόρατο/unclickable κάτω από το viewport — ζητήθηκε ρητά να διορθωθεί.
    const ESTIMATED_HEIGHT = 300;
    const spaceBelow = window.innerHeight - r.bottom;
    const openUpward = spaceBelow < ESTIMATED_HEIGHT && r.top > spaceBelow;
    setPos(openUpward
      ? { bottom: window.innerHeight - r.top + 4, left, width }
      : { top: r.bottom + 4, left, width });
    setOpen(true);
  }

  const shown = useMemo(() => {
    const nq = query.trim().toLowerCase();
    return nq ? options.filter((o) => o.toLowerCase().includes(nq)) : options;
  }, [options, query]);

  const summary = selected.size ? `${selected.size} επιλεγμένα` : (placeholder || 'Όλα');

  // Το search placeholder ήταν ΠΑΝΤΑ "π.χ. 7010,7516" (store codes) άσχετα με το τι πραγματικά
  // φιλτράρει αυτό το instance — μπερδευτικό/λάθος για Division/Category/PM κτλ. dropdowns όπου
  // δεν υπάρχουν καν τέτοιοι κωδικοί (ζητήθηκε ρητά να διορθωθεί). Παράγεται εδώ από τα ίδια τα
  // πραγματικά options (πρώτο τμήμα πριν το " · " αν είναι "code · name" label) ώστε να ΜΗΝ
  // μπορεί να ξαναμπαγιατέψει — πάντα ταιριάζει στα πραγματικά δεδομένα αυτού του dropdown.
  const searchExample = useMemo(() => {
    const codes = options.slice(0, 2).map((o) => o.split(' · ')[0]);
    return codes.length ? ` (ή π.χ. ${codes.join(',')})` : '';
  }, [options]);

  // Πληκτρολόγηση/επικόλληση με κόμματα (π.χ. "7010,7516") επιλέγει απευθείας τα ταιριαστά
  // options — ζητήθηκε ρητά για γρήγορο bulk-select κωδικών καταστήματος. options είναι είτε
  // "γυμνές" τιμές (exact match) είτε "code · name" labels (π.χ. καταστήματα/spacegroups) —
  // ταιριάζει και τα δύο μέσω exact match ή "ξεκινάει με code · ".
  function commitToken(raw) {
    const t = raw.trim();
    if (!t) return;
    const tLower = t.toLowerCase();
    const match = options.find((o) => o.toLowerCase() === tLower || o.toLowerCase().startsWith(tLower + ' · '));
    if (match && !selected.has(match)) onToggle(match);
  }
  function onSearchChange(e) {
    const val = e.target.value;
    if (val.includes(',')) {
      val.split(',').forEach(commitToken);
      setQuery('');
    } else {
      setQuery(val);
    }
  }

  return (
    <div className="msd" ref={wrapRef}>
      {label ? <label className="picker-col-label">{label}</label> : null}
      <button type="button" ref={triggerRef} className="msd-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
        <span className={selected.size ? '' : 'msd-placeholder'}>{summary}</span>
        <span className="msd-caret">{open ? '▴' : '▾'}</span>
      </button>
      {open && pos ? ReactDOM.createPortal(
        <div className="msd-popover" ref={popoverRef}
          style={{ position: 'fixed', left: pos.left, width: pos.width, ...(pos.top != null ? { top: pos.top } : { bottom: pos.bottom }) }}>
          <input className="msd-search" autoFocus placeholder={`Αναζήτηση…${searchExample}`} value={query}
            onChange={onSearchChange} />
          <div className="msd-list">
            {shown.length === 0 ? <div className="msd-empty">Καμία επιλογή</div> : shown.map((o) => (
              <label key={o} className="picker-check">
                <input type="checkbox" checked={selected.has(o)} onChange={() => onToggle(o)} />
                <span className="chk-box"><Icon name="check" size={11} /></span>
                <span>{o}</span>
              </label>
            ))}
          </div>
          {selected.size ? (
            <button type="button" className="btn ghost sm msd-clear" onClick={() => [...selected].forEach(onToggle)}>
              Καθαρισμός ({selected.size})
            </button>
          ) : null}
        </div>,
        document.body,
      ) : null}
    </div>
  );
}

/* ============================ Persisted view/filter state (server, per-account) ============================ */
// Θυμάται φίλτρα/ταξινόμηση/επιλογές προβολής ανά tab ώστε να μη χρειάζεται να τα ξαναφτιάξει
// ο χρήστης σε κάθε επίσκεψη — ζητήθηκε ρητά, ΚΑΙ ρητά server-side ("όλες οι αλλαγές των
// χρηστών θεωρώ πως πρέπει να μένουν στην βάση και όχι στον browser για να μένει ακόμα και
// από άλλο pc να μπει"), ΟΧΙ localStorage. users.view_prefs jsonb, ίδιο merge-only convention
// με useColumnVisibility/column_prefs — PUT /api/users/me/view-prefs.
//
// Module-level cache (ΟΧΙ localStorage — ζει μόνο στη μνήμη, μηδενίζεται σε πραγματικό reload,
// άρα δεν παραβιάζει "server μόνο" convention) — διορθώνει πραγματικό bug: το `me` object
// έρχεται ΜΙΑ φορά στο login (App()'s checkAuth(), ΔΕΝ ξαναφορτώνεται σε κάθε in-app tab
// navigation), οπότε ένα ClusterPage που κάνει unmount/remount όταν ο χρήστης αλλάζει tab και
// ξαναγυρνάει διάβαζε ΠΑΝΤΑ το αρχικό (ενδεχομένως κενό, πριν οριστεί κανένα φίλτρο) me.viewPrefs
// snapshot — τα φίλτρα "χάνονταν" ΜΕΣΑ στην ίδια session ασχέτως ότι το PUT είχε ήδη πετύχει
// server-side (ζητήθηκε ρητά fix: "έφυγα από σελίδα, γύρισα, δεν φορτώνει αποτελέσματα"). Το
// cache ενημερώνεται ΣΕ ΚΑΘΕ save, άρα ένα remount μέσα στην ίδια browser session βλέπει πάντα
// την πιο πρόσφατη τιμή. Πραγματικό full page reload συνεχίζει να παίρνει τη σωστή τιμή από το
// φρέσκο GET /api/auth/me, το cache είναι απλά προτεραιότητα όταν υπάρχει.
const viewPrefsCache = {};
function useViewPrefs(tab) {
  const me = useAuth();
  const fromServer = (me && me.viewPrefs && me.viewPrefs[tab]) || {};
  const initial = tab in viewPrefsCache ? viewPrefsCache[tab] : fromServer;
  const prefsRef = useRef(initial);
  const timerRef = useRef(null);

  const save = useCallback((patch) => {
    prefsRef.current = { ...prefsRef.current, ...patch };
    viewPrefsCache[tab] = prefsRef.current;
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      api.send('PUT', '/api/users/me/view-prefs', { tab, prefs: prefsRef.current }).catch(() => {});
    }, 600);
  }, [tab]);

  // Flush ό,τι εκκρεμεί όταν το component unmount-άρει (π.χ. αλλαγή tab) — αλλιώς μία αλλαγή
  // λίγο πριν το navigation θα χανόταν αθόρυβα (ο debounce timer ποτέ δεν θα έτρεχε).
  useEffect(() => () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      api.send('PUT', '/api/users/me/view-prefs', { tab, prefs: prefsRef.current }).catch(() => {});
    }
  }, [tab]);

  return { initial, save };
}

// usePersistedField: ένα useState πεδίο που αρχικοποιείται από τα αποθηκευμένα view prefs και
// γράφει πίσω (debounced, μέσω useViewPrefs.save) σε κάθε αλλαγή — ίδιο API σχήμα με απλό
// useState ώστε τα ήδη-υπάρχοντα ClustersPage/StoresPage να χρειάζονται ελάχιστη αλλαγή.
function usePersistedField(viewPrefs, field, fallback) {
  const [state, setState] = useState(() => (viewPrefs.initial[field] !== undefined ? viewPrefs.initial[field] : fallback));
  useEffect(() => { viewPrefs.save({ [field]: state }); }, [state]);
  return [state, setState];
}

// Ίδιο με usePersistedField αλλά για Set state (π.χ. τα pick* multi-select φίλτρα στο
// StoresPage) — το jsonb αποθηκεύει array (Sets δεν κάνουν JSON.stringify απευθείας),
// μετατρέπεται σε/από Set στο boundary. sgIds είναι numeric Set αλλού στο αρχείο (Number()
// coercion) — asNumbers=true κάνει το ίδιο εδώ ώστε τα μέλη να παραμείνουν αριθμοί μετά το
// reload, όχι strings (θα έσπαγε π.χ. sgIdSet.has(224) αν το 224 γινόταν "224").
function usePersistedSetField(viewPrefs, field, asNumbers) {
  const [arr, setArr] = usePersistedField(viewPrefs, field, []);
  const set = useMemo(() => new Set(asNumbers ? arr.map(Number) : arr), [arr]);
  const setSet = useCallback((updater) => {
    setArr((prevArr) => {
      const prevSet = new Set(asNumbers ? prevArr.map(Number) : prevArr);
      const nextSet = typeof updater === 'function' ? updater(prevSet) : updater;
      return [...nextSet];
    });
  }, [setArr, asNumbers]);
  return [set, setSet];
}

/* ============================ Column visibility (Clusters/Spacegroups) ============================ */
// Per-account (backend, βλ. PUT /api/users/me/column-prefs) — ΟΧΙ localStorage, ζητήθηκε
// ρητά cross-device. Αποθηκεύουμε τις ΚΡΥΦΕΣ στήλες (όχι τις ορατές) ώστε μια νέα στήλη
// που προστίθεται αργότερα να είναι default ορατή σε όλους χωρίς migration. Local state
// seeded μία φορά από me.columnPrefs — optimistic update στο toggle, ίδιο pattern με τα
// υπόλοιπα direct writes σε αυτό το αρχείο (toast/toastErr).
// clusters/sqft ΜΟΝΟ: default κρυμμένες στήλες για ΚΑΘΕ λογαριασμό που δεν έχει ΠΟΤΕ
// αποθηκεύσει δική του επιλογή — ζητήθηκε ρητά "να αποκρύπτονται by default". undefined
// (καμία αποθηκευμένη επιλογή) -> αυτό εδώ. Ήδη-αποθηκευμένο [] (ο χρήστης έδειξε ρητά όλες
// τις στήλες) ΔΕΝ ξαναγίνεται default — undefined vs [] έχουν διαφορετική σημασία εδώ.
const CLUSTERS_DEFAULT_HIDDEN_COLS = ['date', 'requestedBy'];
// sqft: πίνακας-δομή (όροφοι = γραμμές, καταστήματα = στήλες, βλ. SQFT_FLOORS) — όλοι οι
// όροφοι ορατοί by default (ζητήθηκε ρητά "να μου εμφανίσεις έτσι την δομή"), το "Όροφοι"
// toolbar button μένει μόνο για opt-out ανά όροφο.
const SQFT_DEFAULT_HIDDEN_COLS = [];
const DEFAULT_HIDDEN_COLS_BY_TABLE = { clusters: CLUSTERS_DEFAULT_HIDDEN_COLS, sqft: SQFT_DEFAULT_HIDDEN_COLS };
// Ίδιο module-level cache fix με το viewPrefsCache παραπάνω (βλ. σχόλιο εκεί) — το `me` object
// δεν ξαναφορτώνεται σε in-app tab navigation, οπότε χωρίς αυτό το cache οι κρυμμένες στήλες
// θα "επανεμφανίζονταν" σε κάθε remount μέσα στην ίδια session.
const columnPrefsCache = {};
function useColumnVisibility(tableKey) {
  const me = useAuth();
  const [hidden, setHidden] = useState(() => {
    if (tableKey in columnPrefsCache) return new Set(columnPrefsCache[tableKey]);
    const saved = me.columnPrefs && me.columnPrefs[tableKey];
    if (saved !== undefined) return new Set(saved);
    return new Set(DEFAULT_HIDDEN_COLS_BY_TABLE[tableKey] || []);
  });

  const isHidden = useCallback((field) => hidden.has(field), [hidden]);

  const toggle = useCallback((field) => {
    setHidden((prev) => {
      const next = toggleInSet(prev, field);
      columnPrefsCache[tableKey] = [...next];
      api.send('PUT', '/api/users/me/column-prefs', { table: tableKey, hidden: [...next] })
        .catch((e) => toastErr(e.message));
      return next;
    });
  }, [tableKey]);

  // "Επαναφορά προεπιλογών" (ColumnsToggleButton drawer) — γυρνάει στο εργοστασιακό default
  // hidden-set αυτού του πίνακα (DEFAULT_HIDDEN_COLS_BY_TABLE), ΟΧΙ σε "όλες ορατές".
  const resetToDefault = useCallback(() => {
    const next = new Set(DEFAULT_HIDDEN_COLS_BY_TABLE[tableKey] || []);
    setHidden(next);
    columnPrefsCache[tableKey] = [...next];
    api.send('PUT', '/api/users/me/column-prefs', { table: tableKey, hidden: [...next] })
      .catch((e) => toastErr(e.message));
  }, [tableKey]);

  return { isHidden, toggle, resetToDefault, hiddenSet: hidden };
}

// "Στήλες" — δεξί drawer (ζητήθηκε ρητά, βλ. redesign mockup
// cluster_console_column_management_drawer): αναζήτηση ονόματος στήλης + toggle switch ανά
// στήλη + "Επαναφορά προεπιλογών" κάτω. Ίδιο useColumnVisibility hook από κάτω (isHidden/
// toggle/resetToDefault) — μόνο το visual control άλλαξε, καμία αλλαγή στη λογική ορατότητας.
function ColumnsToggleButton({ fields, isHidden, toggle, resetToDefault, label }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');

  const shown = useMemo(() => {
    const nq = query.trim().toLowerCase();
    return nq ? fields.filter(([, flabel]) => flabel.toLowerCase().includes(nq)) : fields;
  }, [fields, query]);

  return (
    <>
      <button type="button" className="btn ghost" onClick={() => setOpen(true)}>{label || 'Στήλες'}</button>
      {open ? (
        <div className="drawer-backdrop" onClick={() => setOpen(false)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Στήλες</b>
              <button className="btn ghost sm" onClick={() => setOpen(false)} aria-label="Κλείσιμο">✕</button>
            </div>
            <div className="colpicker-search">
              <input autoFocus placeholder="Αναζήτηση στήλης…" value={query} onChange={(e) => setQuery(e.target.value)} />
            </div>
            <div className="drawer-body">
              {shown.length === 0 ? <div className="msd-empty">Καμία στήλη</div> : shown.map(([field, flabel]) => (
                <div key={field} className="colpicker-item">
                  <span>{flabel}</span>
                  <Toggle checked={!isHidden(field)} onChange={() => toggle(field)} />
                </div>
              ))}
            </div>
            <div className="drawer-foot">
              <button className="btn ghost" onClick={resetToDefault}>Επαναφορά προεπιλογών</button>
            </div>
          </div>
        </div>
      ) : null}
    </>
  );
}

// Επιλογέας πριν φορτώσει το matrix — αντί για default "όλα τα spacegroups × όλα τα
// καταστήματα" (βαρύ σε πρώτη φόρτωση), ο χρήστης διαλέγει ρητά τι θέλει να δει.
// Division/Category/Spacegroups/Καταστήματα συνδυάζονται με AND μεταξύ τύπων, OR μέσα
// στον ίδιο τύπο (π.χ. "Technology" division ΚΑΙ κάποιο από τα επιλεγμένα spacegroups).
function StoresViewPicker({
  data, reference,
  pickDivisions, setPickDivisions,
  pickCategories, setPickCategories,
  pickFamilies, setPickFamilies,
  pickSgIds, setPickSgIds,
  pickStoreCodes, setPickStoreCodes,
  onShow,
}) {
  const divisions = useMemo(() => [...new Set(data.spacegroups.map((sg) => divisionKey(sg.division)))].sort(), [data]);
  const categories = useMemo(() => [...new Set(data.spacegroups.map((sg) => sg.category).filter(Boolean))].sort(), [data]);
  const families = useMemo(() => [...new Set(data.spacegroups.map((sg) => sg.family).filter(Boolean))].sort(), [data]);

  // Label "id · name" -> id, ίδιο pattern με τα υπόλοιπα option lists — το MultiSelectDropdown
  // φιλτράρει/δείχνει labels, εμείς κρατάμε raw id/code στα pick* sets.
  const sgLabelById = useMemo(() => new Map(data.spacegroups.map((sg) => [sg.id, `${sg.id} · ${sg.name || ''}`])), [data]);
  const sgIdByLabel = useMemo(() => new Map([...sgLabelById].map(([id, label]) => [label, id])), [sgLabelById]);
  const sgLabels = useMemo(() => [...sgLabelById.values()].sort(), [sgLabelById]);
  const pickSgLabels = useMemo(() => new Set([...pickSgIds].map((id) => sgLabelById.get(id)).filter(Boolean)), [pickSgIds, sgLabelById]);

  const storeLabelByCode = useMemo(() => new Map(data.stores.map((s) => [s.code, `${s.code} · ${s.name || ''}`])), [data]);
  const storeCodeByLabel = useMemo(() => new Map([...storeLabelByCode].map(([code, label]) => [label, code])), [storeLabelByCode]);
  const storeLabels = useMemo(() => [...storeLabelByCode.values()].sort(), [storeLabelByCode]);
  const pickStoreLabels = useMemo(() => new Set([...pickStoreCodes].map((c) => storeLabelByCode.get(c)).filter(Boolean)), [pickStoreCodes, storeLabelByCode]);

  const hasAnyPick = pickDivisions.size || pickCategories.size || pickFamilies.size ||
    pickSgIds.size || pickStoreCodes.size;

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow"><b>Cluster ανά κατάστημα</b></div>
      </div>
      <div className="matrix-note">
        Διάλεξε τι θέλεις να δεις πριν φορτωθεί ο πίνακας — division/category/family/
        spacegroups περιορίζουν τις στήλες, καταστήματα περιορίζουν τις γραμμές. Χωρίς
        καμία επιλογή σε μια κατηγορία, δεν περιορίζεται από αυτήν (π.χ. καμία επιλογή
        division = όλα). Οι κατηγορίες συνδυάζονται με ΚΑΙ μεταξύ τους (π.χ. Division ΚΑΙ
        Spacegroup).
      </div>

      <div className="picker-grid">
        <div className="picker-col">
          <MultiSelectDropdown label="Division" options={divisions} selected={pickDivisions}
            onToggle={(v) => setPickDivisions((s) => toggleInSet(s, v))} placeholder="Όλα τα divisions" />
        </div>

        <div className="picker-col">
          <MultiSelectDropdown label="Category" options={categories} selected={pickCategories}
            onToggle={(v) => setPickCategories((s) => toggleInSet(s, v))} placeholder="Όλες οι categories" />
        </div>

        <div className="picker-col">
          <MultiSelectDropdown label="Family" options={families} selected={pickFamilies}
            onToggle={(v) => setPickFamilies((s) => toggleInSet(s, v))} placeholder="Όλα τα family" />
        </div>

        <div className="picker-col">
          <MultiSelectDropdown label="Spacegroups" options={sgLabels} selected={pickSgLabels}
            onToggle={(label) => setPickSgIds((s) => toggleInSet(s, sgIdByLabel.get(label)))} placeholder="Όλα τα spacegroups" />
        </div>

        <div className="picker-col">
          <MultiSelectDropdown label="Καταστήματα" options={storeLabels} selected={pickStoreLabels}
            onToggle={(label) => setPickStoreCodes((s) => toggleInSet(s, storeCodeByLabel.get(label)))} placeholder="Όλα τα καταστήματα" />
        </div>
      </div>

      <div className="picker-actions">
        <button className="btn primary" onClick={onShow}>Εμφάνιση</button>
        {hasAnyPick ? (
          <button className="btn ghost" onClick={() => {
            setPickDivisions(new Set()); setPickCategories(new Set());
            setPickFamilies(new Set());
            setPickSgIds(new Set()); setPickStoreCodes(new Set());
          }}>Καθαρισμός επιλογής</button>
        ) : null}
      </div>
    </div>
  );
}

function StoresPage({ reference }) {
  const { role } = useAuth();
  const pendingRefsVersion = usePendingRefsVersion();
  const [data, setData] = useState(null);
  // Ελαφρύ payload (μόνο spacegroups/stores, χωρίς matrix/assignments/faces) — γεμίζει τα
  // dropdowns του picker ΠΡΙΝ πατηθεί "Εμφάνιση", χωρίς να χρειάζεται να έχει ήδη φορτωθεί
  // ολόκληρο το (βαρύ, ~19χιλ store_cluster_assignments) πλήρες dataset (βλ. plan §optimize).
  const [pickerData, setPickerData] = useState(null);
  const [clusterFilter, setClusterFilter] = useState(() => parseHashQuery().cluster || '');
  // Φίλτρο "κατάσταση κελιού" — ζητήθηκε ρητά ένα ισοδύναμο του Clusters-tab Έγκριση φίλτρου
  // εδώ στο matrix, αλλά το matrix είναι κελιά όχι γραμμές (καμία στήλη Έγκριση να φιλτραριστεί
  // server-side όπως εκεί) — client-side only, δεν κρύβει γραμμές/στήλες, μόνο ΑΠΟΧΡΩΝΕΙ
  // (dim) τα κελιά που δεν ταιριάζουν στην επιλεγμένη κατάσταση ώστε να ξεχωρίζουν οπτικά τα
  // ζητούμενα χωρίς να χαθεί το context γύρω τους (ίδιο 3 states με τα ήδη υπάρχοντα
  // pending-cell/pending-submitted-cell/cluster-highlight-cell CSS classes).
  const [cellStatusFilter, setCellStatusFilter] = useState('');
  // Φίλτρα/toggles σε modal drawer (ζητήθηκε ρητά — "να βγαίνει πάνω από το περιεχόμενο και
  // όχι να το πιέζει") αντί για τα δύο οριζόντια .toolbar rows πάνω από το matrix· ίδιο
  // ΑΚΡΙΒΩΣ περιεχόμενο/state, μόνο η αναπαράσταση αλλάζει. Κλειστό by default (modal — δεν
  // κρύβει το matrix με το που φορτώνει η σελίδα, ανοίγει μόνο on-demand).
  const [filterPanelOpen, setFilterPanelOpen] = useState(false);
  // showLive/showOpen/.../transposed/pick*: θυμάται το τελευταίο view ανά χρήστη (server, βλ.
  // useViewPrefs) — ζητήθηκε ρητά "το ίδιο και στο πρώτο tab" (ίδιο persistence με
  // ClustersPage), ρητά server-side. 5 ανεξάρτητα toggles (πριν ήταν ένα "Εμφάνιση
  // display/non-display") — καθένα δείχνει/κρύβει ανεξάρτητα το δικό του πεδίο μέσα στο κελί.
  const viewPrefs = useViewPrefs('stores');
  const [showLive, setShowLive] = usePersistedField(viewPrefs, 'showLive', false);
  const [showOpen, setShowOpen] = usePersistedField(viewPrefs, 'showOpen', false);
  const [showBoxMoving, setShowBoxMoving] = usePersistedField(viewPrefs, 'showBoxMoving', false);
  const [showNonDisplay, setShowNonDisplay] = usePersistedField(viewPrefs, 'showNonDisplay', false);
  const [showFeed, setShowFeed] = usePersistedField(viewPrefs, 'showFeed', false);
  const showField = { live: showLive, open: showOpen, boxMoving: showBoxMoving, nonDisplay: showNonDisplay, feed: showFeed };
  const [transposed, setTransposed] = usePersistedField(viewPrefs, 'transposed', false);
  // Σπάει τη σειρά των καταστημάτων σε "μεμονωμένα groups" (Orange/Orange+/White/White+
  // μαζί) αντί για την προεπιλεγμένη σειρά — ζητήθηκε ρητά, persisted server-side.
  const [groupByStoreGroup, setGroupByStoreGroup] = usePersistedField(viewPrefs, 'groupByStoreGroup', false);
  // Η στήλη Κατάστημα κάνει fit στο πλατύτερο πραγματικό περιεχόμενό της (CSS width:1px +
  // white-space:nowrap σε table-layout:auto, βλ. styles.css) — άρα το πλάτος της ΔΕΝ είναι
  // πια σταθερό. Το ανοιχτό (expanded) division header είναι κι αυτό sticky, ΔΙΠΛΑ στη
  // στήλη Κατάστημα, άρα μετράμε το πραγματικό πλάτος μετά το render και το περνάμε σαν
  // inline style αντί για hardcoded left:100px.
  const storeColRef = useRef(null);
  const [storeColWidth, setStoreColWidth] = useState(100);
  useEffect(() => {
    if (!storeColRef.current) return;
    // ResizeObserver αντί για μέτρηση σε κάθε render — το offsetWidth read είναι ένα
    // forced reflow, πολύ ακριβό αν έτρεχε σε κάθε re-render αυτού του (βαρύ) πίνακα.
    const el = storeColRef.current;
    const ro = new ResizeObserver(([entry]) => setStoreColWidth(Math.round(entry.contentRect.width)));
    ro.observe(el);
    return () => ro.disconnect();
  }, [transposed]);
  const [historyFor, setHistoryFor] = useState(null); // { title, url } | null
  // pick*: θυμάται την τελευταία επιλογή picker ανά χρήστη (server, βλ. useViewPrefs) —
  // ζητήθηκε ρητά. sgIds numeric (asNumbers=true, ίδιο λόγο με sgIdSet.has(sg.id) αλλού).
  const [pickDivisions, setPickDivisions] = usePersistedSetField(viewPrefs, 'pickDivisions');
  const [pickCategories, setPickCategories] = usePersistedSetField(viewPrefs, 'pickCategories');
  // Family — ίδιο "AND μεταξύ τύπων, OR μέσα στον τύπο" convention με
  // Division/Category, ζητήθηκε ρητά "όσα φίλτρα χρειάζομαι από spacegroups".
  const [pickFamilies, setPickFamilies] = usePersistedSetField(viewPrefs, 'pickFamilies');
  const [pickSgIds, setPickSgIds] = usePersistedSetField(viewPrefs, 'pickSgIds', true);
  const [pickStoreCodes, setPickStoreCodes] = usePersistedSetField(viewPrefs, 'pickStoreCodes');
  // Φίλτρο ομάδας καταστήματος (Orange/Orange+/White/White+) — 4 σταθερές τιμές, ΔΕΝ
  // χρειάζεται να παραχθεί από τα data (σε αντίθεση με pickStoreCodes/pickSgIds), client-side
  // only φίλτρο (ίδιο STORE_GROUP_LABELS με το import legend).
  const [pickStoreGroups, setPickStoreGroups] = usePersistedSetField(viewPrefs, 'pickStoreGroups');
  // Φίλτρο χώρας (GR/CY) — ίδιο client-side-only pattern με pickStoreGroups (2 σταθερές
  // τιμές, καμία ανάγκη server round-trip), ζητήθηκε ρητά "Ελλάδας ή Κύπρου ή όλα" + να
  // θυμάται την επιλογή ανά λογαριασμό (ίδιο persisted-server-side με τα υπόλοιπα pick*).
  const [pickCountries, setPickCountries] = usePersistedSetField(viewPrefs, 'pickCountries');
  // Division quick-filter (κουμπιά πάνω από τον πίνακα) — αντικαθιστά το παλιό accordion
  // collapse/expand ΜΕΣΑ στα headers του matrix (ζητήθηκε ρητά "να φύγουν απο εκεί τα κουμπιά
  // ... και να μπουν σε μορφή κουμπιών πάνω απο το matrix"). Κενό Set = δείξε όλα τα divisions
  // (ίδιο "empty = show all" convention με pickStoreGroups/pickCountries), persisted ανά
  // λογαριασμό (ζητήθηκε ρητά).
  const [visibleDivisions, setVisibleDivisions] = usePersistedSetField(viewPrefs, 'visibleDivisions');
  // Deep-link από το Clusters tab παρακάμπτει τον επιλογέα — ο χρήστης ήδη έκανε ρητή
  // επιλογή πατώντας το 🔗. Χωρίς deep-link, ο πίνακας ΔΕΝ φορτώνει τίποτα μέχρι να
  // επιλεγεί ρητά τι θα δειχτεί (491 spacegroups × όλα τα καταστήματα ήταν πολύ βαρύ ως
  // default αρχική προβολή) — ΕΚΤΟΣ από τον ρόλο 'pm' (server-side ownership scoping, δεν
  // έχει νόημα το gate) Ή αν έμεινε αποθηκευμένη μία ΠΡΑΓΜΑΤΙΚΗ στένωση από προηγούμενη
  // επίσκεψη (τουλάχιστον ένα pick* Set μη-κενό) — μόνο τότε είναι ασφαλές να παρακαμφθεί το
  // gate αυτόματα, αλλιώς θα ξαναφόρτωνε σιωπηλά το βαρύ "τίποτα επιλεγμένο" view.
  const [viewReady, setViewReady] = useState(() => role === 'pm' || !!parseHashQuery().sg ||
    !!(pickDivisions.size || pickCategories.size || pickFamilies.size ||
       pickSgIds.size || pickStoreCodes.size || pickStoreGroups.size || pickCountries.size));
  // Καταστήματα (γραμμές) σελιδοποιούνται client-side — το data/matrix έρχεται ήδη
  // server-side scoped από το picker, αλλά χωρίς αυτό εδώ ένα "Όλα τα καταστήματα" με πολλά
  // spacegroups στήνει χιλιάδες <td> στο DOM ταυτόχρονα (βλ. plan: no virtualization, no
  // build step -> δεν βάζουμε lib, απλά κόβουμε τις γραμμές που ρεντεράρονται).
  const [storePage, setStorePage] = useState(0);
  // Ίδιο label "id · name" -> id pattern με το StoresViewPicker — εδώ ξαναχρησιμοποιείται
  // ώστε το toolbar filter (μετά το "Εμφάνιση") να είναι το ΙΔΙΟ searchable multi-select
  // dropdown, όχι ξεχωριστό free-text search πάνω στα ίδια pick* sets. Μετά το "Εμφάνιση"
  // το data είναι ήδη το (τυχόν στενότερο) server-filtered σύνολο, άρα δείχνουμε labels
  // από αυτό αν υπάρχει, αλλιώς από το ελαφρύ pickerData (πριν το "Εμφάνιση").
  const labelSource = data || pickerData;
  // Division/Category/Family option lists για το toolbar filter row (μετά το
  // "Εμφάνιση") — ζητήθηκε ρητά "τα ίδια filter πεδία" και εκτός πίνακα (picker) ΚΑΙ μέσα
  // (toolbar), ίδιο derivation pattern με το StoresViewPicker.
  const toolbarDivisions = useMemo(() => [...new Set((labelSource ? labelSource.spacegroups : []).map((sg) => divisionKey(sg.division)))].sort(), [labelSource]);
  const toolbarCategories = useMemo(() => [...new Set((labelSource ? labelSource.spacegroups : []).map((sg) => sg.category).filter(Boolean))].sort(), [labelSource]);
  const toolbarFamilies = useMemo(() => [...new Set((labelSource ? labelSource.spacegroups : []).map((sg) => sg.family).filter(Boolean))].sort(), [labelSource]);
  const sgLabelById = useMemo(() => new Map((labelSource ? labelSource.spacegroups : []).map((sg) => [sg.id, `${sg.id} · ${sg.name || ''}`])), [labelSource]);
  const sgIdByLabel = useMemo(() => new Map([...sgLabelById].map(([id, label]) => [label, id])), [sgLabelById]);
  const sgLabels = useMemo(() => [...sgLabelById.values()].sort(), [sgLabelById]);
  const pickSgLabels = useMemo(() => new Set([...pickSgIds].map((id) => sgLabelById.get(id)).filter(Boolean)), [pickSgIds, sgLabelById]);
  const storeLabelByCode = useMemo(() => new Map((labelSource ? labelSource.stores : []).map((s) => [s.code, `${s.code} · ${s.name || ''}`])), [labelSource]);
  const storeCodeByLabel = useMemo(() => new Map([...storeLabelByCode].map(([code, label]) => [label, code])), [storeLabelByCode]);
  const storeLabels = useMemo(() => [...storeLabelByCode.values()].sort(), [storeLabelByCode]);
  const pickStoreLabels = useMemo(() => new Set([...pickStoreCodes].map((c) => storeLabelByCode.get(c)).filter(Boolean)), [pickStoreCodes, storeLabelByCode]);
  const cart = useRequestCart();
  // Matrix κελί = πάντα entity='store_assignment' = πάντα domain='display' (το feed δεν έχει
  // matrix κελιά, επιβεβαιωμένο με τον χρήστη) — άρα εδώ χρησιμοποιούνται τα DISPLAY_* gates
  // (space_manager γράφει απευθείας, space_user υποβάλλει), ΟΧΙ τα feed αντίστοιχα.
  const canDirectWrite = CAN_DIRECT_WRITE_DISPLAY(role);
  const canSubmitRequests = CAN_SUBMIT_DISPLAY_REQUESTS(role);
  const canEdit = canDirectWrite || canSubmitRequests;

  useEffect(() => {
    api.get('/api/cluster-per-store/picker-options').then(setPickerData).catch((e) => toastErr(e.message));
  }, []);

  // Το πλήρες (βαρύ) dataset φορτώνει ΜΟΝΟ όταν viewReady γίνει true (pm/deep-link bypass ή
  // "Εμφάνιση" του picker) — ΚΑΙ μόνο όταν ο χρήστης πατήσει ρητά "Εφαρμογή" στο toolbar filter
  // row μετά (ζητήθηκε ρητά "να μην κάνει auto apply" όποτε αλλάζει ένα dropdown — πριν αυτό
  // εδώ ξαναφόρτωνε αυτόματα σε ΚΑΘΕ αλλαγή pick* state). applyVersion είναι ο μοναδικός
  // τρόπος να "ξαναπυροδοτηθεί" αυτό το effect μετά το πρώτο viewReady fetch — μόνο το
  // "Εφαρμογή" button (`applyFilters` παρακάτω) το αυξάνει. Το "Καθαρισμός φίλτρων" ΔΕΝ αυξάνει
  // applyVersion — αδειάζει τα pick* sets ΚΑΙ βάζει data σε κενό (αλλά "φορτωμένο", όχι
  // loading) shape απευθείας, χωρίς κανένα fetch, μέχρι να ξαναπατηθεί "Εφαρμογή".
  const [applyVersion, setApplyVersion] = useState(0);
  // deepLinkConsumedRef: το deep-link ?sg= πρέπει να στενέψει ΜΟΝΟ το πρώτο, αρχικό fetch (βλ.
  // σχόλιο πιο πάνω) — το URL hash όμως ΔΕΝ καθαρίζει μόνο του όταν ο χρήστης αλλάζει φίλτρα
  // μετά, άρα ένα ξανα-διάβασμα του parseHashQuery().sg σε ΚΑΘΕ "Εφαρμογή" θα ξανα-επέβαλε το
  // ΙΔΙΟ αρχικό spacegroup για πάντα, αγνοώντας τελείως ό,τι επέλεξε ο χρήστης έκτοτε — ακριβώς
  // το bug "αλλάζω φίλτρα από τη σελίδα συνδεδεμένων καταστημάτων και δεν αλλάζει τίποτα στο
  // view" (ζητήθηκε ρητά να διορθωθεί). Ref γιατί δεν πρέπει να ξαναρενταράρει τίποτα όταν αλλάζει.
  const deepLinkConsumedRef = useRef(false);
  useEffect(() => {
    if (!viewReady) return;
    // Deep-link (#/stores?sg=) -> στενεύει το server fetch σε ΕΝΑ spacegroup αντί να
    // βασιστούμε αποκλειστικά στο client-side φιλτράρισμα του groupedSpacegroups — ΜΟΝΟ στο
    // πρώτο fetch μετά το mount, βλ. deepLinkConsumedRef.
    const deepLinkSg = !deepLinkConsumedRef.current ? parseHashQuery().sg : null;
    deepLinkConsumedRef.current = true;
    const params = qs({
      divisions: [...pickDivisions].join(','), categories: [...pickCategories].join(','),
      families: [...pickFamilies].join(','),
      sgIds: deepLinkSg ? deepLinkSg : [...pickSgIds].join(','), storeCodes: [...pickStoreCodes].join(','),
    });
    api.get('/api/cluster-per-store' + params).then(setData).catch((e) => toastErr(e.message));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [viewReady, applyVersion]);
  // Το deep-link ?sg= διάβαζε το URL param απευθείας ΜΟΝΟ μέσα στο fetch effect παραπάνω,
  // χωρίς ΠΟΤΕ να ενημερώνει το ίδιο το pickSgIds state — αποτέλεσμα: το picker/toolbar δεν
  // έδειχνε καμία επιλογή ("δεν φαίνεται στα filters") ΚΑΙ το "Καθαρισμός φίλτρων" έμενε
  // disabled (hasScope κοιτάζει μόνο τα pick* sets, ποτέ το ωμό URL param) — ζητήθηκε ρητά να
  // διορθωθεί. Τρέχει ΜΙΑ φορά στο mount, ΜΕΤΑ το αρχικό (ήδη σωστά scoped) fetch από πάνω —
  // απλά συγχρονίζει το state ώστε η UI να δείχνει τι πραγματικά ισχύει, δεν ξαναπυροδοτεί
  // fetch (το applyVersion δεν αλλάζει, ίδιο "όχι auto-apply" convention με τα υπόλοιπα pick*).
  // Καθαρίζει ΚΑΙ τυχόν παλιά division/category/family επιλογή από προηγούμενη επίσκεψη — το
  // groupedSpacegroups φιλτράρει ΟΛΑ τα pick* με AND μεταξύ τους, άρα ένα ξεχασμένο φίλτρο θα
  // μπορούσε να κρύψει ακριβώς το spacegroup που μόλις ζήτησε ρητά ο χρήστης μέσω 🔗.
  useEffect(() => {
    const deepLinkSg = parseHashQuery().sg;
    if (deepLinkSg) {
      setPickSgIds(new Set([Number(deepLinkSg)]));
      setPickDivisions(new Set());
      setPickCategories(new Set());
      setPickFamilies(new Set());
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  function applyFilters() { setApplyVersion((v) => v + 1); }
  function clearAndBlank() {
    setPickDivisions(new Set()); setPickCategories(new Set());
    setPickFamilies(new Set());
    setPickSgIds(new Set()); setPickStoreCodes(new Set()); setPickStoreGroups(new Set());
    setPickCountries(new Set());
    setClusterFilter(''); setCellStatusFilter('');
    setData({ matrix: {}, spacegroups: [], stores: [] });
    // Γυρνάει στην αρχική picker οθόνη αντί να αφήνει έναν άδειο πίνακα — ζητήθηκε ρητά
    // ("θέλω να τα καθαρίζει όλα"). Για 'pm' το gate δεν έχει καν νόημα (server-side
    // ownership scoping, βλ. viewReady init παραπάνω) — μένει πάντα true εκεί.
    if (role !== 'pm') setViewReady(false);
  }

  // Ήδη-υποβλημένα store_assignment requests (εκκρεμούν έγκριση) — χρωματίζει το κελί μπλε,
  // διαφορετικό από το amber .pending-cell (αυτό σημαίνει "στο δικό μου ανοιχτό Σύνολο
  // Αλλαγών, δεν υποβλήθηκε ακόμα" — άλλη έννοια). pendingRefs κρατάει το ΠΛΗΡΕΣ response
  // (με after_value/before_value) — χρησιμοποιείται παρακάτω και για το cluster-code
  // highlight και το faces preview, όχι μόνο για pendingSubmitted.
  const [pendingRefs, setPendingRefs] = useState(() => []);
  useEffect(() => {
    api.get('/api/change-requests/pending-refs').then(setPendingRefs).catch(() => {});
  }, [data, pendingRefsVersion]);

  const pendingSubmitted = useMemo(() => new Set(
    pendingRefs.filter((r) => r.entity === 'store_assignment').map((r) => r.spacegroupId + '|' + r.storeCode),
  ), [pendingRefs]);

  // spacegroupId -> { codes: Set(clusterCode), valuesByCode: Map(clusterCode -> { live, open,
  // boxMoving, nonDisplay, feed } — μόνο τα πεδία που πράγματι πρότεινε το αίτημα) } — ΟΛΑ τα
  // κελιά που μοιράζονται το ίδιο (spacegroup, cluster code) με ένα pending cluster-entity
  // αίτημα φωτίζονται, όχι μόνο το κελί που το δημιούργησε (βλ. plan §3/§4).
  const pendingClusterInfoBySg = useMemo(() => {
    const m = new Map();
    for (const r of pendingRefs) {
      if (r.entity !== 'cluster') continue;
      const code = (r.afterValue && r.afterValue.cluster) || (r.beforeValue && r.beforeValue.cluster);
      if (!r.spacegroupId || !code) continue;
      const entry = m.get(r.spacegroupId) || { codes: new Set(), valuesByCode: new Map() };
      entry.codes.add(code);
      if (r.afterValue) {
        const existing = entry.valuesByCode.get(code) || {};
        for (const [field] of CLUSTER_5_FIELD_DEFS) {
          if (r.afterValue[field] !== undefined) existing[field] = r.afterValue[field];
        }
        entry.valuesByCode.set(code, existing);
      }
      m.set(r.spacegroupId, entry);
    }
    return m;
  }, [pendingRefs]);

  // Στήλες ομαδοποιημένες ανά division, με τη σειρά του DIVISION_ORDER. Πριν το "Εμφάνιση"
  // στον επιλογέα, appliedScope είναι null -> καμία στήλη (βλ. viewReady gate στο render).
  // Το free-text search (πρώην sgQuery) έχει αντικατασταθεί από το ίδιο searchable
  // multi-select dropdown (pickSgIds) που χρησιμοποιεί και ο picker — μία λίστα επιλογής,
  // όχι δύο ξεχωριστοί μηχανισμοί φιλτραρίσματος.
  const groupedSpacegroups = useMemo(() => {
    if (!data || !viewReady) return [];
    let matches = data.spacegroups;
    // Το sgId deep-link (#/stores?sg=) γεμίζει το pickSgIds ΜΙΑ φορά στο mount (βλ. effect πιο
    // κάτω) — φιλτράρει πλέον ΑΠΟΚΛΕΙΣΤΙΚΑ μέσω pickSgIds, ΟΧΙ ξανά-διαβάζοντας το ωμό URL εδώ
    // (που ΠΟΤΕ δεν καθαρίζει μόνο του — έκανε τα φίλτρα να φαίνονται "παγωμένα" για πάντα στο
    // αρχικό spacegroup, ζητήθηκε ρητά να διορθωθεί).
    if (pickDivisions.size || pickCategories.size || pickFamilies.size || pickSgIds.size) {
      // AND μεταξύ τύπων (division/category/family/spacegroup-id), OR μέσα
      // στον ίδιο τύπο — π.χ. Division=Technology ΚΑΙ (SG σε pickSgIds) πρέπει να ισχύουν
      // ΜΑΖΙ, όχι το ένα ή το άλλο (ήταν bug: OR παντού, βλ. plan §1).
      matches = matches.filter((sg) =>
        (!pickDivisions.size || pickDivisions.has(divisionKey(sg.division))) &&
        (!pickCategories.size || (sg.category && pickCategories.has(sg.category))) &&
        (!pickFamilies.size || (sg.family && pickFamilies.has(sg.family))) &&
        (!pickSgIds.size || pickSgIds.has(sg.id)));
    }
    const byDiv = new Map();
    for (const sg of matches) {
      const key = divisionKey(sg.division);
      if (!byDiv.has(key)) byDiv.set(key, []);
      byDiv.get(key).push(sg);
    }
    return DIVISION_ORDER.filter((d) => byDiv.has(d)).map((d) => ({ division: d, items: byDiv.get(d) }));
  }, [data, viewReady, pickDivisions, pickCategories, pickFamilies, pickSgIds]);

  // Quick division filter (κουμπιά πάνω από τον πίνακα, βλ. visibleDivisions πιο πάνω) — κενό
  // Set δείχνει όλα τα (ήδη picker-φιλτραρισμένα) divisions. Ξεχωριστό memo από
  // groupedSpacegroups ώστε τα ίδια τα κουμπιά (με το σωστό count ανά division) να μπορούν να
  // δείχνουν ΟΛΑ τα διαθέσιμα divisions ακόμα κι όταν κάποια είναι προσωρινά κρυμμένα.
  const shownGroups = useMemo(
    () => (visibleDivisions.size ? groupedSpacegroups.filter((g) => visibleDivisions.has(g.division)) : groupedSpacegroups),
    [groupedSpacegroups, visibleDivisions],
  );

  const shownStores = useMemo(() => {
    if (!data || !viewReady) return [];
    let list = data.stores;
    if (pickStoreCodes.size) list = list.filter((s) => pickStoreCodes.has(s.code));
    if (pickStoreGroups.size) list = list.filter((s) => s.storeGroup && pickStoreGroups.has(s.storeGroup));
    if (pickCountries.size) list = list.filter((s) => pickCountries.has(s.country || 'GR'));
    if (clusterFilter) {
      // Μόνο καταστήματα που ΣΗΜΕΡΑ έχουν ακριβώς αυτό το cluster σε ΚΑΠΟΙΟ από τα
      // ορατά (φιλτραρισμένα από pickSgIds/division/category) spacegroups — βλ. deep-link
      // από Clusters tab.
      const sgIds = groupedSpacegroups.flatMap((g) => g.items.map((sg) => sg.id));
      list = list.filter((s) => sgIds.some((sgId) => (data.matrix[s.code] || {})[sgId] === clusterFilter));
    }
    // Ομαδοποίηση ανά ομάδα καταστήματος (Orange/Orange+/White/White+) — ζητήθηκε ρητά "να
    // σπάει σε μεμονωμένα groups matrix από τα groups των καταστημάτων, όλα τα orange μαζί
    // και white σε άλλο group". Stable sort (ίδιο group -> κρατάει την ήδη υπάρχουσα σειρά
    // μεταξύ τους) — τα ήδη-υπάρχοντα χρωματιστά chips/borders δείχνουν οπτικά τα όρια κάθε
    // ομάδας, δεν χρειάζεται ξεχωριστό section header row.
    if (groupByStoreGroup) {
      const order = new Map(STORE_GROUP_LABELS.map((g, i) => [g, i]));
      list = [...list].sort((a, b) => {
        const ai = a.storeGroup ? (order.has(a.storeGroup) ? order.get(a.storeGroup) : STORE_GROUP_LABELS.length) : STORE_GROUP_LABELS.length + 1;
        const bi = b.storeGroup ? (order.has(b.storeGroup) ? order.get(b.storeGroup) : STORE_GROUP_LABELS.length) : STORE_GROUP_LABELS.length + 1;
        return ai - bi;
      });
    }
    return list;
  }, [data, clusterFilter, groupedSpacegroups, viewReady, pickStoreCodes, pickStoreGroups, pickCountries, groupByStoreGroup]);

  const STORE_PAGE_SIZE = 100;
  const storePageCount = Math.max(1, Math.ceil(shownStores.length / STORE_PAGE_SIZE));
  useEffect(() => { setStorePage(0); }, [shownStores]);
  const pagedStores = useMemo(
    () => shownStores.slice(storePage * STORE_PAGE_SIZE, (storePage + 1) * STORE_PAGE_SIZE),
    [shownStores, storePage],
  );

  const pendingMap = useMemo(() => {
    const m = {};
    cart.items.forEach((i) => { if (i.entity === 'store_assignment') m[i.storeCode + '|' + i.spaceGroupId] = i; });
    return m;
  }, [cart.items]);

  // Flat, filtered spacegroup list (ίδιο φίλτρο με groupedSpacegroups, χωρίς grouping) —
  // χρησιμοποιείται στο transposed mode όπου τα spacegroups είναι γραμμές, όχι στήλες
  // (το accordion-by-division δεν χρειάζεται εκεί: οι γραμμές scroll-άρουν κάθετα μια χαρά,
  // ίδια λογική με το Clusters page).
  const flatSpacegroups = useMemo(() => shownGroups.flatMap((g) => g.items), [shownGroups]);

  // Cluster code επιλογές ανά spacegroup για το MatrixCell picker — LIVE, βγαλμένες από τα
  // ίδια τα clusters rows αυτού του spacegroup (ένωση των keys των 5 field maps, ίδια keyset
  // και στα 5 αφού χτίζονται μαζί στο ίδιο loop, server.js GET /api/cluster-per-store), ΟΧΙ
  // το στατικό reference.clusterCodes (Reference lists import, προαιρετικό — μετά από ένα
  // πλήρες DB reset είναι συχνά άδειο/ανύπαρκτο, που έκανε το MatrixCell να σκάει με
  // "Cannot read properties of undefined (reading 'length')" όταν κάποιος πάταγε ένα κελί).
  // Bonus σωστότητα: περιορίζει τις επιλογές σε cluster codes που ΠΡΑΓΜΑΤΙΚΑ ανήκουν σε αυτό
  // το spacegroup, όχι σε οποιοδήποτε cluster code έχει χρησιμοποιηθεί ποτέ οπουδήποτε.
  const clusterCodesBySg = useMemo(() => {
    const m = {};
    if (!data) return m;
    for (const sg of flatSpacegroups) {
      const codes = new Set();
      for (const field of ['live', 'open', 'boxMoving', 'nonDisplay', 'feed']) {
        const bySg = data[field] && data[field][sg.id];
        if (bySg) for (const code of Object.keys(bySg)) codes.add(code);
      }
      m[sg.id] = [...codes].sort();
    }
    return m;
  }, [data, flatSpacegroups]);

  // Per-κελί επιλογές dropdown — core deliverable: όταν υπάρχει configured κανόνας (βλ.
  // data.tierRules, GET /api/cluster-per-store) για (spacegroup, η ΣΥΓΚΕΚΡΙΜΕΝΗ ομάδα
  // καταστήματος αυτού του store), δείχνει ΜΟΝΟ αυτούς τους κωδικούς — hard filter, ζητήθηκε
  // ρητά, ΟΧΙ απλή υπόδειξη. Χωρίς κανόνα (ή κατάστημα χωρίς storeGroup) fallback στο σημερινό
  // clusterCodesBySg[sg.id] (όλοι οι κωδικοί του spacegroup) — καμία αλλαγή συμπεριφοράς για
  // spacegroups χωρίς ρητά ορισμένους κανόνες.
  const cellClusterOptions = useCallback((sgId, storeGroup) => {
    const tiered = storeGroup && data && data.tierRules && data.tierRules[sgId] && data.tierRules[sgId][storeGroup];
    return tiered && tiered.length ? tiered : (clusterCodesBySg[sgId] || []);
  }, [data, clusterCodesBySg]);

  // valueFor: γενικό reader ανά πεδίο (live/open/boxMoving/nonDisplay/feed) — 5 ανεξάρτητοι
  // αριθμοί στην ΙΔΙΑ γραμμή clusters, καθένας εμφανίζεται μόνο όταν είναι ενεργό το δικό του
  // toggle (showField[field]). GET /api/cluster-per-store επιστρέφει 5 maps (live/open/
  // boxMoving/nonDisplay/feed), ένα ανά πεδίο, [spacegroupId][clusterCode] -> τιμή.
  const valueFor = useCallback((field, sgId, clusterCode) => {
    if (!showField[field] || !clusterCode || !data[field]) return null;
    const bySg = data[field][sgId];
    return bySg ? (bySg[clusterCode] ?? null) : null;
  }, [showField, data]);

  // Proposed (pending, όχι ακόμα εγκεκριμένα) τιμή για το ΙΔΙΟ cluster code — δείχνεται
  // δίπλα στην τρέχουσα τιμή με άλλο χρώμα όταν διαφέρουν (βλ. plan §4).
  const pendingValueFor = useCallback((field, sgId, clusterCode) => {
    if (!showField[field] || !clusterCode) return null;
    const info = pendingClusterInfoBySg.get(sgId);
    if (!info) return null;
    const v = info.valuesByCode.get(clusterCode);
    return v && v[field] !== undefined ? v[field] : null;
  }, [showField, pendingClusterInfoBySg]);

  function ReadOnlyCell({ val, comment }) {
    if (!val) return <span className="cellempty">—</span>;
    return <><span className="chip">{val}</span><CommentInfoPopover text={comment} /></>;
  }

  const setCell = useCallback(async (store, spacegroup, value) => {
    if (canDirectWrite) {
      try {
        await api.send('PUT', '/api/cluster-per-store/cell', { store, spacegroup, value });
        setData((d) => {
          const matrix = { ...d.matrix, [store]: { ...(d.matrix[store] || {}) } };
          if (value === '' || value == null) delete matrix[store][spacegroup];
          else matrix[store][spacegroup] = value;
          return { ...d, matrix };
        });
        toast('Ενημερώθηκε');
      } catch (e) { toastErr(e.message); }
      return;
    }
    const before = (data.matrix[store] || {})[spacegroup] || '';
    const storeName = (data.stores.find((s) => s.code === store) || {}).name || '';
    const sgName = (data.spacegroups.find((sg) => sg.id === spacegroup) || {}).name || '';
    cart.add({
      entity: 'store_assignment', action: 'update', targetId: null,
      spaceGroupId: spacegroup, storeCode: store,
      afterValue: { clusterCode: value || null },
      diffs: [{ field: 'cluster', before: before || undefined, after: value || undefined }],
      label: `${store}${storeName ? ' · ' + storeName : ''} × SG ${spacegroup}${sgName ? ' · ' + sgName : ''}`,
    });
    toast('Προστέθηκε στο Σύνολο Αλλαγών');
  }, [data, canDirectWrite, cart]);

  if (!viewReady) {
    if (!pickerData) return <div className="empty">Φόρτωση…</div>;
    return (
      <StoresViewPicker
        data={pickerData} reference={reference}
        pickDivisions={pickDivisions} setPickDivisions={setPickDivisions}
        pickCategories={pickCategories} setPickCategories={setPickCategories}
        pickFamilies={pickFamilies} setPickFamilies={setPickFamilies}
        pickSgIds={pickSgIds} setPickSgIds={setPickSgIds}
        pickStoreCodes={pickStoreCodes} setPickStoreCodes={setPickStoreCodes}
        onShow={() => setViewReady(true)}
      />
    );
  }

  if (!data) return <div className="empty">Φόρτωση cluster ανά κατάστημα…</div>;

  const totalSgShown = shownGroups.reduce((n, g) => n + g.items.length, 0);
  const hasScope = pickDivisions.size || pickCategories.size || pickFamilies.size ||
    pickSgIds.size || pickStoreCodes.size;

  // Δεξί φίλτρα/toggles panel αντί για τα δύο οριζόντια .toolbar rows — ζητήθηκε ρητά ("να
  // φαίνεται ο πίνακας πιο μεγάλος και πιο άνετος"). Ίδιο ΑΚΡΙΒΩΣ περιεχόμενο/state/handlers,
  // μόνο η θέση άλλαξε (βλ. filterPanelOpen πιο πάνω). "← Πίσω σε Preload Filters" και το
  // ενεργό cluster-filter chip μένουν πάντα ορατά πάνω από τον πίνακα (όχι μέσα στο panel) ώστε
  // να μη χάνονται όταν το panel είναι κλειστό.
  // Φίλτρα/toggles σε modal drawer (ζητήθηκε ρητά — "να βγαίνει πάνω από το περιεχόμενο και
  // όχι να το πιέζει", ΟΧΙ docked sidebar) — ίδιο .drawer-backdrop/.drawer pattern με κάθε άλλο
  // drawer της εφαρμογής, ίδιο ΑΚΡΙΒΩΣ περιεχόμενο/state/handlers με πριν.
  const filterPanel = filterPanelOpen ? (
    <div className="drawer-backdrop" onClick={() => setFilterPanelOpen(false)}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Φίλτρα & προβολή</b>
          <button type="button" className="btn ghost sm" onClick={() => setFilterPanelOpen(false)} aria-label="Κλείσιμο">✕</button>
        </div>
        <div className="drawer-body">
          <div className="field">
            <MultiSelectDropdown label="Division"
              options={toolbarDivisions} selected={pickDivisions}
              onToggle={(v) => setPickDivisions((s) => toggleInSet(s, v))}
              placeholder="Όλα τα divisions" />
          </div>
          <div className="field">
            <MultiSelectDropdown label="Category"
              options={toolbarCategories} selected={pickCategories}
              onToggle={(v) => setPickCategories((s) => toggleInSet(s, v))}
              placeholder="Όλες οι categories" />
          </div>
          <div className="field">
            <MultiSelectDropdown label="Family"
              options={toolbarFamilies} selected={pickFamilies}
              onToggle={(v) => setPickFamilies((s) => toggleInSet(s, v))}
              placeholder="Όλα τα family" />
          </div>
          <div className="field">
            <MultiSelectDropdown label={`Space group (${transposed ? 'φίλτρο γραμμών' : 'φίλτρο στηλών'})`}
              options={sgLabels} selected={pickSgLabels}
              onToggle={(label) => setPickSgIds((s) => toggleInSet(s, sgIdByLabel.get(label)))}
              placeholder="Όλα τα spacegroups" />
          </div>
          <div className="field">
            <MultiSelectDropdown label={`Κατάστημα (${transposed ? 'φίλτρο στηλών' : 'γραμμές'})`}
              options={storeLabels} selected={pickStoreLabels}
              onToggle={(label) => setPickStoreCodes((s) => toggleInSet(s, storeCodeByLabel.get(label)))}
              placeholder="Όλα τα καταστήματα" />
          </div>
          <div className="field">
            <MultiSelectDropdown label="Ομάδα καταστήματος"
              options={STORE_GROUP_LABELS} selected={pickStoreGroups}
              onToggle={(label) => setPickStoreGroups((s) => toggleInSet(s, label))}
              placeholder="Όλες οι ομάδες" />
          </div>
          <div className="field">
            <MultiSelectDropdown label="Χώρα"
              options={['GR', 'CY']} selected={pickCountries}
              onToggle={(label) => setPickCountries((s) => toggleInSet(s, label))}
              placeholder="Ελλάδα & Κύπρος" />
          </div>
          <div className="stores-filter-divider" />
          <div className="stores-filter-toggles">
            <Toggle checked={groupByStoreGroup} onChange={setGroupByStoreGroup} label="Ομαδοποίηση ανά ομάδα καταστήματος" />
            <Toggle checked={showLive} onChange={setShowLive} label="Live" />
            <Toggle checked={showOpen} onChange={setShowOpen} label="Open" />
            <Toggle checked={showBoxMoving} onChange={setShowBoxMoving} label="Box Moving" />
            <Toggle checked={showNonDisplay} onChange={setShowNonDisplay} label="Non-Display" />
            <Toggle checked={showFeed} onChange={setShowFeed} label="Feed" />
            <Toggle checked={transposed} onChange={setTransposed} label="Αντιστροφή αξόνων" />
          </div>
          <div className="field">
            <label>Κατάσταση κελιού</label>
            <div className="seg">
              {[['', 'Όλα'], ['pending', 'Δικό μου'], ['submitted', 'Εκκρεμεί'], ['highlighted', 'Ίδιο cluster']].map(([v, l]) => (
                <button key={v} type="button" className={'seg-btn' + (cellStatusFilter === v ? ' active' : '')}
                  onClick={() => setCellStatusFilter(v)}>{l}</button>
              ))}
            </div>
          </div>
        </div>
        <div className="drawer-foot">
          <button className="btn primary" onClick={() => { applyFilters(); setFilterPanelOpen(false); }}
            title="Εφαρμογή των επιλεγμένων φίλτρων στον πίνακα">
            Εφαρμογή
          </button>
          <button className="btn ghost" onClick={clearAndBlank} disabled={!(hasScope || pickStoreGroups.size || pickCountries.size)}
            title="Άδειασμα φίλτρων — ο πίνακας αδειάζει, πάτα Εφαρμογή για να δεις όλα">
            Καθαρισμός φίλτρων
          </button>
        </div>
      </div>
    </div>
  ) : null;

  return (
    <div className="page stores-page">
      <div className="stores-main-head">
        <div className="stores-main-head-left">
          {hasScope ? (
            <button className="btn ghost sm" onClick={() => setViewReady(false)}>← Πίσω σε Preload Filters</button>
          ) : null}
          {clusterFilter ? (
            <span className="chip filled">
              Cluster: {clusterFilter}
              <button className="btn ghost sm" style={{ marginLeft: 6, padding: '0 4px' }} onClick={() => setClusterFilter('')}>✕</button>
            </span>
          ) : null}
        </div>
        <button type="button" className="btn ghost sm" onClick={() => setFilterPanelOpen(true)}>Φίλτρα & προβολή</button>
      </div>

      <div className="matrix-note">
        Δείχνει {totalSgShown} space groups{!transposed ? ` σε ${shownGroups.length} divisions` : ''} ·{' '}
        {shownStores.length} καταστήματα
        {storePageCount > 1 ? ` (καταστήματα ${storePage * STORE_PAGE_SIZE + 1}–${Math.min((storePage + 1) * STORE_PAGE_SIZE, shownStores.length)})` : ''}.
        {canDirectWrite ? ' Διάλεξε ένα cluster από τη λίστα σε κάθε κελί για αποθήκευση.' : null}
        {canSubmitRequests ? ' Διάλεξε ένα cluster για να προσθέσεις αίτημα αλλαγής στο Σύνολο Αλλαγών.' : null}
        {!canEdit ? ' Μόνο για ανάγνωση.' : null}
      </div>
      {storePageCount > 1 ? (
        <div className="matrix-note" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <button className="btn ghost sm" disabled={storePage === 0} onClick={() => setStorePage((p) => Math.max(0, p - 1))}>← Προηγούμενα</button>
          <span>Σελίδα καταστημάτων {storePage + 1} / {storePageCount}</span>
          <button className="btn ghost sm" disabled={storePage >= storePageCount - 1} onClick={() => setStorePage((p) => Math.min(storePageCount - 1, p + 1))}>Επόμενα →</button>
        </div>
      ) : null}

      {groupedSpacegroups.length > 1 ? (
        <div className="division-filterbar">
          {groupedSpacegroups.map((g) => {
            const active = !visibleDivisions.size || visibleDivisions.has(g.division);
            return (
              <button key={g.division} type="button"
                className={'division-filterbtn ' + DIVISION_CLASS[g.division] + (active ? ' active' : '')}
                onClick={() => setVisibleDivisions((s) => toggleInSet(s, g.division))}>
                {g.division} <span className="divcount">({g.items.length})</span>
              </button>
            );
          })}
          {visibleDivisions.size ? (
            <button type="button" className="btn ghost sm" onClick={() => [...visibleDivisions].forEach((d) => setVisibleDivisions((s) => toggleInSet(s, d)))}>
              Όλα
            </button>
          ) : null}
        </div>
      ) : null}

      <div className="tablewrap">
        {transposed ? (
          <table className="matrix">
            <thead>
              <tr>
                <th className="storehdr">Space Group</th>
                {pagedStores.map((s) => (
                  <th key={s.code} style={s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? { borderTop: `4px solid ${STORE_GROUP_COLOR[s.storeGroup]}` } : undefined}>
                    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
                      {/* Πάντα ρεντεράρεται (invisible όταν δεν υπάρχει group) ώστε ΚΑΘΕ header
                          cell να κρατάει το ίδιο ύψος — αλλιώς καταστήματα χωρίς ομάδα είχαν
                          κοντύτερο header από διπλανά με ομάδα, σπάζοντας το row alignment
                          (ζητήθηκε ρητά "aligned όλα μεταξύ τους τα headers"). */}
                      <span className="chip" style={{
                        background: s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? STORE_GROUP_COLOR[s.storeGroup] : 'transparent',
                        color: s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? '#fff' : 'transparent',
                        marginBottom: 2, visibility: s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? 'visible' : 'hidden',
                      }}>{s.storeGroup || 'X'}</span>
                      <span className="sgid">{s.code}</span>
                      <span className="sgname" style={s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? { color: STORE_GROUP_COLOR[s.storeGroup] } : undefined}>{s.name || '—'}</span>
                    </div>
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {flatSpacegroups.map((sg) => (
                <tr key={sg.id}>
                  <td className="storecell" style={{ borderLeft: `4px solid ${DIVISION_BORDER_COLOR[divisionKey(sg.division)]}` }}>
                    <span className="chip" style={{ background: '#eef0f4', color: '#2a3242' }}>{sg.id}</span>{' '}
                    {sg.name || '—'}
                  </td>
                  {pagedStores.map((s) => {
                    const pending = pendingMap[s.code + '|' + sg.id];
                    const submitted = pendingSubmitted.has(sg.id + '|' + s.code);
                    const liveVal = (data.matrix[s.code] || {})[sg.id] || '';
                    const val = pending ? (pending.afterValue.clusterCode || '') : liveVal;
                    const clusterHighlighted = !pending && !submitted && val && pendingClusterInfoBySg.get(sg.id)?.codes.has(val);
                    const cellClass = pending ? 'pending-cell' : submitted ? 'pending-submitted-cell' : clusterHighlighted ? 'cluster-highlight-cell' : '';
                    const cellStatus = pending ? 'pending' : submitted ? 'submitted' : clusterHighlighted ? 'highlighted' : '';
                    const dimmed = cellStatusFilter && cellStatus !== cellStatusFilter;
                    return (
                      <td key={s.code} className={cellClass + (dimmed ? ' cell-dimmed' : '')}>
                        <div className="cell-row">
                          {canEdit ? (
                            <MatrixCell store={s.code} sg={sg.id} value={val} onCommit={setCell}
                              options={cellClusterOptions(sg.id, s.storeGroup)}
                              comment={data.comments && data.comments[sg.id] ? data.comments[sg.id][val] : null}
                              valuesBySg={data.live ? { live: data.live[sg.id], open: data.open[sg.id], boxMoving: data.boxMoving[sg.id], nonDisplay: data.nonDisplay[sg.id], feed: data.feed[sg.id] } : null} />
                          ) : (
                            <ReadOnlyCell val={val} comment={data.comments && data.comments[sg.id] ? data.comments[sg.id][val] : null} />
                          )}
                          <button className="cell-history-btn" title="Ιστορικό κελιού"
                            onClick={() => setHistoryFor({ title: `${s.code} ${s.name || ''} · SG ${sg.id} ${sg.name || ''}`, url: `/api/cluster-per-store/history?spacegroupId=${encodeURIComponent(sg.id)}&storeCode=${encodeURIComponent(s.code)}` })}>
                            <Icon name="history" />
                          </button>
                        </div>
                        {CLUSTER_5_FIELD_DEFS.map(([field, label]) => {
                          const v = valueFor(field, sg.id, val);
                          const pv = pendingValueFor(field, sg.id, val);
                          if (v == null && pv == null) return null;
                          return (
                            <React.Fragment key={field}>
                              {v != null ? <span className="cell-faces">{v} {label}</span> : null}
                              {pv != null && pv !== v ? <span className="cell-faces cell-faces-pending"> → {pv} {label}</span> : null}
                            </React.Fragment>
                          );
                        })}
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
        ) : (
          <table className="matrix">
            <thead>
              <tr>
                <th className="storehdr" rowSpan={2} ref={storeColRef}>Κατάστημα</th>
                {shownGroups.map((g) => (
                  <th key={g.division} className={'divhdr ' + DIVISION_CLASS[g.division]} colSpan={g.items.length}>
                    {g.division} <span className="divcount">({g.items.length})</span>
                  </th>
                ))}
              </tr>
              <tr>
                {shownGroups.flatMap((g) => g.items.map((sg) => (
                  <th key={sg.id}>
                    <span className="sgid">{sg.id}</span>
                    <span className="sgname">{sg.name || '—'}</span>
                  </th>
                )))}
              </tr>
            </thead>
            <tbody>
              {pagedStores.map((s) => (
                <tr key={s.code}>
                  <td className="storecell" style={s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? { borderLeft: `4px solid ${STORE_GROUP_COLOR[s.storeGroup]}` } : undefined}>
                    {s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? (
                      <span className="chip" style={{ background: STORE_GROUP_COLOR[s.storeGroup], color: '#fff', marginRight: 6 }}>{s.storeGroup}</span>
                    ) : null}
                    <span className="chip" style={{ background: '#eef0f4', color: '#2a3242' }}>{s.code}</span>{' '}
                    <span style={s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? { color: STORE_GROUP_COLOR[s.storeGroup] } : undefined}>{s.name}</span>
                  </td>
                  {shownGroups.flatMap((g) => {
                    return g.items.map((sg) => {
                      const pending = pendingMap[s.code + '|' + sg.id];
                      const submitted = pendingSubmitted.has(sg.id + '|' + s.code);
                      const liveVal = (data.matrix[s.code] || {})[sg.id] || '';
                      const val = pending ? (pending.afterValue.clusterCode || '') : liveVal;
                      const clusterHighlighted = !pending && !submitted && val && pendingClusterInfoBySg.get(sg.id)?.codes.has(val);
                      const cellClass = pending ? 'pending-cell' : submitted ? 'pending-submitted-cell' : clusterHighlighted ? 'cluster-highlight-cell' : '';
                      const cellStatus = pending ? 'pending' : submitted ? 'submitted' : clusterHighlighted ? 'highlighted' : '';
                      const dimmed = cellStatusFilter && cellStatus !== cellStatusFilter;
                      return (
                        <td key={sg.id} className={cellClass + (dimmed ? ' cell-dimmed' : '')}>
                          <div className="cell-row">
                            {canEdit ? (
                              <MatrixCell store={s.code} sg={sg.id} value={val} onCommit={setCell}
                                options={cellClusterOptions(sg.id, s.storeGroup)}
                                comment={data.comments && data.comments[sg.id] ? data.comments[sg.id][val] : null}
                                valuesBySg={data.live ? { live: data.live[sg.id], open: data.open[sg.id], boxMoving: data.boxMoving[sg.id], nonDisplay: data.nonDisplay[sg.id], feed: data.feed[sg.id] } : null} />
                            ) : (
                              <ReadOnlyCell val={val} comment={data.comments && data.comments[sg.id] ? data.comments[sg.id][val] : null} />
                            )}
                            <button className="cell-history-btn" title="Ιστορικό κελιού"
                              onClick={() => setHistoryFor({ title: `${s.code} ${s.name || ''} · SG ${sg.id} ${sg.name || ''}`, url: `/api/cluster-per-store/history?spacegroupId=${encodeURIComponent(sg.id)}&storeCode=${encodeURIComponent(s.code)}` })}>
                              <Icon name="history" />
                            </button>
                          </div>
                          {CLUSTER_5_FIELD_DEFS.map(([field, label]) => {
                            const v = valueFor(field, sg.id, val);
                            const pv = pendingValueFor(field, sg.id, val);
                            if (v == null && pv == null) return null;
                            return (
                              <React.Fragment key={field}>
                                {v != null ? <span className="cell-faces">{v} {label}</span> : null}
                                {pv != null && pv !== v ? <span className="cell-faces cell-faces-pending"> → {pv} {label}</span> : null}
                              </React.Fragment>
                            );
                          })}
                        </td>
                      );
                    });
                  })}
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>

      {filterPanel}

      {historyFor ? (
        <HistoryDrawer title={historyFor.title} url={historyFor.url} fields={CELL_HISTORY_FIELDS} onClose={() => setHistoryFor(null)} />
      ) : null}
    </div>
  );
}

// Controlled select — όχι πια ελεύθερο typing (μόνο τιμές από reference.clusterCodes).
// Πριν ήταν ένα native <select> με ~40 <option> ΑΝΑ ΚΕΛΙ — σε ένα division με 204
// spacegroups × 66 καταστήματα αυτό είναι >13.000 selects, >540.000 option nodes σε ΕΝΑ
// render (πραγματικό αίτιο του lag στο expand/collapse, όχι bug συγκεκριμένου commit).
// Εδώ δείχνουμε μόνο κείμενο/chip + ✎ — η λίστα επιλογών (με typeahead, ίδιο pattern με
// MultiSelectDropdown) mount-άρεται ΜΟΝΟ όσο είναι ανοιχτό ΕΝΑ κελί τη φορά.
function MatrixCell({ store, sg, value, onCommit, options, valuesBySg, comment }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [pos, setPos] = useState(null); // { top, left } σε viewport συντεταγμένες | null
  const triggerRef = useRef(null);
  const popoverRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) {
      if (triggerRef.current && triggerRef.current.contains(e.target)) return;
      if (popoverRef.current && popoverRef.current.contains(e.target)) return;
      setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  // Το .tablewrap έχει overflow:auto (scroll container) — ένα position:absolute popover
  // ΜΕΣΑ σε αυτό κόβεται στο ύψος της σειράς, ό,τι content style κι αν έχει (το overflow
  // κόβει ΟΛΑ τα descendants, position:absolute ή όχι). Λύση: portal το popover στο
  // document.body με position:fixed, υπολογισμένο από το πραγματικό screen rect του
  // trigger — έτσι "ξεφεύγει" οπτικά από το scroll container.
  function openPicker() {
    const r = triggerRef.current.getBoundingClientRect();
    setPos({ top: r.bottom + 4, left: r.left });
    setOpen(true);
  }

  const shown = useMemo(() => {
    const opts = options || [];
    const nq = query.trim().toLowerCase();
    return nq ? opts.filter((o) => o.toLowerCase().includes(nq)) : opts;
  }, [options, query]);

  function pick(v) {
    onCommit(store, sg, v);
    setOpen(false);
    setQuery('');
  }

  // Δίπλα σε κάθε cluster code, με απαλά γράμματα, δείχνει και τα 5 ανεξάρτητα πεδία
  // (Live/Open/Box Moving/Non-Display/Feed) ΤΟΥ ΣΥΓΚΕΚΡΙΜΕΝΟΥ cluster σε αυτό το spacegroup
  // (όχι του κελιού) — ώστε να ξέρεις τι ακριβώς αλλάζεις ΠΡΙΝ διαλέξεις, χωρίς να χρειάζεται
  // να κλείσεις το popover. Πάντα ορατό εδώ (ανεξάρτητο από τα 5 show* toggles του matrix,
  // που ελέγχουν μόνο την εξωτερική σημείωση κάτω από το κελί).
  function faceNote(code) {
    if (!valuesBySg) return null;
    const parts = [];
    for (const [field, label] of CLUSTER_5_FIELD_DEFS) {
      const m = valuesBySg[field];
      const v = m ? m[code] : null;
      if (v != null) parts.push(`${v} ${label.toLowerCase()}`);
    }
    return parts.length ? parts.join(' · ') : null;
  }

  return (
    <div className="cellpick">
      <button type="button" ref={triggerRef} className={'cellpick-trigger' + (value ? ' filled' : '')}
        title="Επεξεργασία" onClick={() => (open ? setOpen(false) : openPicker())}>
        {value || '—'}
      </button>
      {value ? <CommentInfoPopover text={comment} /> : null}
      {open && pos ? ReactDOM.createPortal(
        <div className="msd-popover cellpick-popover" ref={popoverRef} style={{ position: 'fixed', top: pos.top, left: pos.left }}>
          <input className="msd-search" autoFocus placeholder="Αναζήτηση…" value={query}
            onChange={(e) => setQuery(e.target.value)} />
          <div className="msd-list">
            <div className={'cellpick-opt' + (!value ? ' active' : '')} onClick={() => pick('')}>—</div>
            {shown.length === 0 ? <div className="msd-empty">Καμία επιλογή</div> : shown.map((o) => (
              <div key={o} className={'cellpick-opt' + (o === value ? ' active' : '')} onClick={() => pick(o)}>
                {o}
                {faceNote(o) ? <span className="cellpick-opt-note"> {faceNote(o)}</span> : null}
              </div>
            ))}
          </div>
        </div>,
        document.body,
      ) : null}
    </div>
  );
}

/* ============================ Request cart drawer (pm) ============================ */
function DiffRow({ d }) {
  return (
    <div className="diff-row">
      <span className="diff-field">{d.field}</span>
      <span className="diff-before">{d.before === undefined || d.before === null || d.before === '' ? '—' : String(d.before)}</span>
      <span className="diff-arrow">→</span>
      <span className="diff-after">{d.after === undefined || d.after === null || d.after === '' ? '—' : String(d.after)}</span>
    </div>
  );
}

function RequestCartDrawer({ reference, onClose, onSubmitted }) {
  const cart = useRequestCart();
  const [busy, setBusy] = useState(false);
  const storeNames = (reference && reference.storeNames) || {};
  function storeLabel(code) { return storeNames[code] ? `${code} · ${storeNames[code]}` : code; }

  // Ποια καταστήματα επηρεάζονται από κάθε πρόταση αλλαγή πάνω σε ένα ήδη-υπάρχον cluster
  // row (entity='cluster', action='update') — ΟΧΙ μόνο rename cluster code, ΚΑΘΕ αλλαγή
  // (faces/non-display/κτλ) πάνω σε αυτή τη γραμμή επηρεάζει τα ίδια καταστήματα που έχουν
  // ήδη αυτό το (spacegroup, cluster code) σήμερα — χρησιμοποιεί το currentClusterCode που
  // αποθηκεύεται στο cart item ανεξάρτητα από το ποιο πεδίο άλλαξε (βλ. stageForRequest).
  // Φορτώνεται ΞΕΧΩΡΙΣΤΑ από τα cart items γιατί χρειάζεται server round-trip
  // (store_cluster_assignments), δεν είναι κάτι που ξέρει ήδη το client-side cart.
  const [affectedByKey, setAffectedByKey] = useState({});
  useEffect(() => {
    const targets = cart.items
      .filter((i) => i.entity === 'cluster' && i.action === 'update' && i.currentClusterCode)
      .map((i) => ({ spaceGroupId: i.spaceGroupId, beforeClusterCode: i.currentClusterCode }));
    if (!targets.length) { setAffectedByKey({}); return; }
    api.send('POST', '/api/change-requests/preview-affected-stores', targets).then(setAffectedByKey).catch(() => {});
  }, [cart.items]);

  async function submit() {
    setBusy(true);
    try {
      const payload = cart.items.map((i) => ({
        entity: i.entity, action: i.action, targetId: i.targetId,
        spaceGroupId: i.spaceGroupId, storeCode: i.storeCode, afterValue: i.afterValue,
        domain: i.domain,
      }));
      await api.send('POST', '/api/change-requests', payload);
      toast(`Υποβλήθηκαν ${payload.length} αιτήματα`);
      cart.clear();
      bumpPendingRefs();
      onSubmitted();
    } catch (e) { toastErr(e.message); }
    setBusy(false);
  }

  return (
    <div className="drawer-backdrop" onClick={onClose}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Σύνολο Αλλαγών ({cart.items.length})</b>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        <div className="drawer-body">
          {cart.items.length === 0 ? (
            <div className="empty">Δεν έχεις προσθέσει αλλαγές ακόμα.</div>
          ) : cart.items.map((item) => {
            const affectedKey = item.entity === 'cluster' && item.currentClusterCode ? `${item.spaceGroupId}|${item.currentClusterCode}` : null;
            const affectedStores = affectedKey ? (affectedByKey[affectedKey] || null) : null;
            return (
              <div className="cart-item" key={item.localId}>
                <div className="cart-item-head">
                  <b>{item.label}</b>
                  <button className="btn ghost sm" onClick={() => cart.remove(item.localId)}>✕</button>
                </div>
                {item.diffs.map((d) => <DiffRow d={d} key={d.field} />)}
                {affectedStores ? (
                  affectedStores.length ? (
                    <div className="request-row3 muted">
                      Επηρεάζει {affectedStores.length} καταστήματα: {affectedStores.map(storeLabel).join('  ·  ')}
                    </div>
                  ) : (
                    <div className="request-row3 muted">Δεν επηρεάζει κανένα κατάστημα αυτή τη στιγμή.</div>
                  )
                ) : null}
              </div>
            );
          })}
        </div>
        <div className="drawer-foot">
          <button className="btn primary" disabled={busy || !cart.items.length} onClick={submit}>
            {busy ? 'Υποβολή…' : `Υποβολή ${cart.items.length || ''} αιτημάτων`}
          </button>
          <button className="btn ghost" onClick={onClose}>Κλείσιμο</button>
        </div>
      </div>
    </div>
  );
}

/* ============================ Requests page (submit history / approval queue) ============================ */
const REQUEST_STATUS_LABELS = { pending: 'Εκκρεμεί', approved: 'Εγκρίθηκε', rejected: 'Απορρίφθηκε' };
// Ίδια τιμή με BULK_REVIEW_MAX_IDS στο server.js — μόνο για client-side guard/μηνύματα πριν καν φτάσει το request.
const BULK_REVIEW_MAX_IDS = 50;

function requestSgLabel(r) {
  return `SG ${r.spacegroupId}${r.spacegroupName ? ' ' + r.spacegroupName : ''}`;
}

// Απλό, μονογραμμικό, "επαγγελματικό" κείμενο (ζητήθηκε ρητά — όχι τεχνική ορολογία σαν
// "SGID"/"Νέα γραμμή") — προηγείται πάντα ό,τι έχει νόημα για τον άνθρωπο (cluster code/
// κατάστημα), το spacegroup ακολουθεί σαν πλαίσιο.
function describeRequest(r, storeLabel) {
  if (r.entity === 'spacegroup') return `Νέο Spacegroup · ${requestSgLabel(r)}`;
  if (r.entity === 'cluster') {
    const clusterCode = (r.afterValue && r.afterValue.cluster) || (r.beforeValue && r.beforeValue.cluster);
    const action = r.action === 'create' ? 'Νέο Cluster' : 'Επεξεργασία Cluster';
    return `${action}${clusterCode ? ' ' + clusterCode : ''} · ${requestSgLabel(r)}`;
  }
  const storeText = storeLabel ? storeLabel(r.storeCode) : r.storeCode;
  return `Κατάστημα ${storeText} · ${requestSgLabel(r)}`;
}

// Ρητό label "τύπου αιτήματος" — ζητήθηκε ρητά να φαίνεται σε ΚΑΘΕ κάρτα (πριν μόνο τα
// entity='cluster' αιτήματα έδειχναν pill Display/Feed, τα store_assignment/spacegroup δεν
// είχαν ΚΑΝΕΝΑ ρητό type indicator, μόνο το αχνό χρώμα του αριστερού border). Συνδυάζει
// domain+entity σε ένα κατανοητό ελληνικό label, ίδιο σε ΟΛΑ τα entities.
function requestTypeLabel(r) {
  if (r.entity === 'spacegroup') return 'Νέο Spacegroup';
  const domainText = r.domain === 'feed' ? 'Feed' : 'Display';
  if (r.entity === 'store_assignment') return domainText + ' · Κατάστημα';
  return domainText + ' · Cluster';
}

const REQUEST_ENTITY_CLASS = { cluster: 'entity-cluster', store_assignment: 'entity-store', spacegroup: 'entity-spacegroup' };
// domain-* δίνει το ΚΥΡΙΟ χρώμα (μπλε=display, πορτοκαλί=feed), entity-* δίνει την απόχρωση
// (ανοιχτή=cluster, σκούρη=store) — τα δύο μαζί, βλ. .request-card.domain-*.entity-* στο
// styles.css. entity-spacegroup δεν έχει domain variant (πάντα πράσινο, ξεχωριστή κατηγορία).
const REQUEST_DOMAIN_CLASS = { display: 'domain-display', feed: 'domain-feed' };

function requestDiffs(r) {
  if (r.entity === 'store_assignment') {
    return [{ field: 'cluster', before: r.beforeValue ? r.beforeValue.clusterCode : undefined, after: r.afterValue ? r.afterValue.clusterCode : undefined }];
  }
  if (r.entity === 'spacegroup') {
    const av = r.afterValue || {};
    return [
      { field: 'id', before: undefined, after: av.id },
      { field: 'name', before: undefined, after: av.name },
      { field: 'category', before: undefined, after: av.category || '—' },
    ];
  }
  const before = r.beforeValue || {};
  const after = r.afterValue || {};
  return Object.keys(after)
    .map((f) => ({ field: f, before: before[f], after: after[f] }))
    .filter((d) => String(d.before ?? '') !== String(d.after ?? ''));
}

// requested_by/date πάνω σε entity='cluster': ο server τα ξαναγράφει ΠΑΝΤΑ σε κάθε αίτημα
// (audit-trail στήλες στην ίδια clusters γραμμή — "ποιος/πότε άγγιξε τελευταία αυτή τη
// γραμμή", βλ. server.js:1287-1288 "requested_by/date: ο server τα ορίζει πάντα") — ΔΕΝ
// είναι κάτι που επέλεξε να αλλάξει ο αιτών, εμφανίζονται σαν diff σε ΚΑΘΕ αίτημα ασχέτως τι
// πραγματικά άλλαξε. Ξεχωριστό label + πλήρης εξήγηση εδώ ώστε να μη διαβάζεται σαν "άλλαξε
// ιδιοκτήτη το αίτημα" — βλ. DiffLine.
const AUDIT_STAMP_FIELDS = new Set(['requested_by', 'requestedBy', 'date']);

const REQUEST_TYPE_LABELS = { cluster: 'Clusters', store_assignment: 'Καταστήματα', spacegroup: 'Νέα Spacegroups' };
// after_value αποθηκεύεται snake_case για entity='cluster' (toSnakeRow στο submit) αλλά
// camelCase για store_assignment/spacegroup (φτιάχνονται απευθείας, όχι μέσω toSnakeRow) —
// καλύπτουμε και τις δύο μορφές ώστε να μη διαρρεύσει ποτέ ωμό snake_case στο UI.
// Πλήρη, αυτο-εξηγούμενα labels (όχι συντομογραφίες) — ζητήθηκε ρητά "να γράφονται στα
// αιτήματα όλα αναλυτικά... ποιοι τίτλοι και ποιες τιμές" ώστε να μην χρειάζεται να ξέρει
// κανείς το data model από έξω για να καταλάβει τι διαβάζει.
const REQUEST_FIELD_LABELS = {
  faces: 'Faces (εμφανιζόμενα)', date: 'Ημερομηνία', status: 'Κατάσταση (Status)', cluster: 'Cluster code', key: 'Key',
  non_display: 'Non-Display (μη εμφανιζόμενα)', nonDisplay: 'Non-Display (μη εμφανιζόμενα)',
  requested_by: 'Αιτών (τελευταίος που άγγιξε τη γραμμή)', requestedBy: 'Αιτών (τελευταίος που άγγιξε τη γραμμή)',
  pmm_spacegroup_id: 'PMM', pmmSpacegroupId: 'PMM',
  space_group: 'Space Group', spaceGroup: 'Space Group',
  clusterCode: 'Cluster code', id: 'ID', name: 'Όνομα', category: 'Category',
};

// requested_by/date πάνω σε entity='cluster': βλ. σχόλιο στο AUDIT_STAMP_FIELDS — ο server τα
// ξαναγράφει ΠΑΝΤΑ, εμφανίζονται σε ΚΑΘΕ αίτημα ασχέτως τι πραγματικά ζήτησε να αλλάξει ο
// αιτών. Ξεχωριστό, ήπιο (μη κόκκινο/μη "αυτό είναι η ουσία του αιτήματος") στυλ ώστε να μη
// διαβάζεται σαν προθέσεις του αιτούντος.
function DiffLine({ d }) {
  const before = d.before === undefined || d.before === null || d.before === '' ? '—' : String(d.before);
  const after = d.after === undefined || d.after === null || d.after === '' ? '—' : String(d.after);
  const label = REQUEST_FIELD_LABELS[d.field] || d.field;
  const isAudit = AUDIT_STAMP_FIELDS.has(d.field);
  return (
    <span style={isAudit ? { color: 'var(--muted)' } : undefined} title={isAudit ? 'Ενημερώνεται αυτόματα σε κάθε αίτημα πάνω σε αυτή τη γραμμή — δεν είναι κάτι που επέλεξε να αλλάξει ο αιτών.' : undefined}>
      {label}: {before} → {after}
    </span>
  );
}

// entity DB τιμή -> "matrix"/"clusters" UI label που ζήτησε ο χρήστης — internal naming
// (store_assignment) διαφέρει σκόπιμα από το UI vocabulary, ίδιο μοτίβο με το store-group/
// cluster-code naming collision αλλού σε αυτό το αρχείο.
const REQUEST_BADGE_ROWS = [
  { entity: 'store_assignment', label: 'Matrix' },
  { entity: 'cluster', label: 'Clusters' },
];
// onFilterClick(entity, status): κλικ πάνω σε ένα από τα 3 pills (εκκρεμούν/εγκρίθηκαν/
// απορρίφθηκαν) ενός entity — ζητήθηκε ρητά "clickable ... να μου εμφανίζει τα αντίστοιχα
// αιτήματα" — φιλτράρει τη λίστα από κάτω στο ίδιο entity+status αντί να χρειάζεται να τα
// διαλέξει χειροκίνητα από το drawer Φίλτρα.
function RequestBadgesRow({ onFilterClick }) {
  const rc = useRequestCounts();
  if (!rc) return null;
  const { counts } = rc;
  return (
    <div className="matrix-note" style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
      {REQUEST_BADGE_ROWS.map(({ entity, label }) => {
        const c = counts[entity];
        return (
          <span key={entity}>
            <b>{label}:</b>{' '}
            <button type="button" className="pill wait pill-btn" onClick={() => onFilterClick(entity, 'pending')}>{c.pending} εκκρεμούν</button>{' '}
            <button type="button" className="pill ok pill-btn" onClick={() => onFilterClick(entity, 'approved')}>{c.approved} εγκρίθηκαν</button>{' '}
            <button type="button" className="pill no pill-btn" onClick={() => onFilterClick(entity, 'rejected')}>{c.rejected} απορρίφθηκαν</button>
          </span>
        );
      })}
    </div>
  );
}

// Μία κάρτα αιτήματος — ίδιο rendering σε RequestsPage ΚΑΙ NewRequestNotification (πριν το
// notification popup έδειχνε μόνο το terse describeRequest() κείμενο, χωρίς τύπο/κατάστημα/
// διαχωρισμό, ζητήθηκε ρητά "να φαίνεται ξεκάθαρα τι αίτημα έχει και τι επηρεάζει"). compact
// αφαιρεί τα action buttons/reviewer metadata (το notification είναι passive preview, όχι το
// σημείο έγκρισης — ο χρήστης πάει στο "Άνοιγμα Αιτημάτων" για να ενεργήσει).
function RequestCard({ r, storeLabel, compact, canReview, onApprove, onReject, selected, onToggleSelect }) {
  const diffs = requestDiffs(r);
  const stores = r.affectedStores || [];
  // store_assignment: το ίδιο το αίτημα ΕΙΝΑΙ ένα συγκεκριμένο κατάστημα (r.storeCode) — δείξε
  // αυτό ρητά, όχι το (άσχετο εδώ) affectedStores που αφορά ΑΛΛΑ καταστήματα με το ίδιο cluster
  // code. cluster/spacegroup: affectedStores δείχνει ΠΟΙΑ καταστήματα θα επηρεαστούν αν εγκριθεί.
  const storesText = r.entity === 'store_assignment'
    ? storeLabel(r.storeCode)
    : stores.length
      ? stores.slice(0, 5).map(storeLabel).join('  ·  ') + (stores.length > 5 ? `  ·  +${stores.length - 5} ακόμα` : '')
      : null;
  return (
    <div id={'request-' + r.id} className={'request-card ' + (REQUEST_DOMAIN_CLASS[r.domain] || '') + ' ' + (REQUEST_ENTITY_CLASS[r.entity] || '')}>
      <div className="request-row1">
        {!compact && canReview && r.status === 'pending' && onToggleSelect ? (
          <input type="checkbox" className="request-select" checked={!!selected}
            onChange={() => onToggleSelect(r.id)} aria-label="Επιλογή αιτήματος" />
        ) : null}
        {/* Κοινός κωδικός — ζητήθηκε ρητά "κοινό ID" ώστε δύο άνθρωποι (π.χ. space_manager +
            on_demand_manager) να μπορούν να αναφέρονται στο ΙΔΙΟ αίτημα δημιουργίας μεταξύ
            τους, ακόμα κι όταν το καθένα μισό (display/feed) έχει το δικό του, διαφορετικό
            εσωτερικό id — βλ. requestCode στο server (GET /api/change-requests). */}
        <span className="request-code" title="Κωδικός αιτήματος">#{r.requestCode}</span>
        <span className="request-title-text">{describeRequest(r, storeLabel)}</span>
        <span className={'pill domain-pill ' + (r.entity === 'spacegroup' ? 'spacegroup' : r.domain === 'feed' ? 'feed' : 'display')}>
          {requestTypeLabel(r)}
        </span>
        {!compact ? (
          <span className={'pill ' + (r.status === 'approved' ? 'ok' : r.status === 'rejected' ? 'no' : 'wait')}>
            {REQUEST_STATUS_LABELS[r.status]}
          </span>
        ) : null}
        <span className="spacer" />
        {!compact && canReview && r.status === 'pending' ? (
          <div className="request-actions">
            <button className="btn primary sm" onClick={() => onApprove(r)}>Έγκριση</button>
            <button className="btn ghost sm" onClick={() => onReject(r)}>Απόρριψη</button>
          </div>
        ) : !compact ? (
          <span className="request-meta">{new Date(r.createdAt).toLocaleString('el')}</span>
        ) : null}
      </div>
      {!compact ? (
        <div className="request-row2">
          Υπέβαλε το αίτημα: <UserNameLink user={r.requestedByUser} fallback={r.requestedBy} /> · Ιδιοκτήτης spacegroup:{' '}
          {r.pmGr ? <UserNameLink user={r.pmGr} /> : '— (κανείς ακόμα)'}
          {canReview && r.status === 'pending' ? ` · Υποβλήθηκε: ${new Date(r.createdAt).toLocaleString('el')}` : ''}
        </div>
      ) : null}
      {storesText ? (
        <div className="request-row3 muted">
          {r.entity === 'store_assignment' ? 'Κατάστημα: ' : `Επηρεάζει ${stores.length} καταστήματα: `}{storesText}
        </div>
      ) : null}
      {diffs.length ? (
        <div className="request-row3">
          {diffs.map((d, i) => (
            <React.Fragment key={d.field}>
              {i > 0 ? '  ·  ' : ''}
              <DiffLine d={d} />
            </React.Fragment>
          ))}
        </div>
      ) : null}
      {!compact && r.reviewedBy ? (
        <div className="request-row3 muted">
          {r.status === 'approved' ? 'Εγκρίθηκε' : 'Απορρίφθηκε'} από <UserNameLink user={r.reviewedByUser} fallback={r.reviewedBy} /> · {new Date(r.reviewedAt).toLocaleString('el')}
          {r.reviewNote ? ` · ${r.reviewNote}` : ''}
        </div>
      ) : null}
    </div>
  );
}

// Μία κάρτα ΓΙΑ ΟΛΗ την πρόταση δημιουργίας cluster (display+feed μαζί) — ζητήθηκε ρητά
// "πάλι είδα 2 διαφορετικές γραμμές σε αυτόν που έκανε το αίτημα": ο κοινός requestCode από
// μόνος του δεν αρκούσε, ήθελε ΜΙΑ οπτική κάρτα, όχι δύο ξεχωριστές δίπλα-δίπλα. Κάθε domain
// (display/feed) εμφανίζεται σαν δική του γραμμή ΜΕΣΑ στην κάρτα, με το δικό του pill
// κατάστασης και τα δικά του κουμπιά Έγκριση/Απόρριψη (κάθε domain εγκρίνεται ανεξάρτητα,
// server-side δύο ξεχωριστές change_requests γραμμές παραμένουν — βλ. create_group_id).
function GroupedRequestCard({ members, storeLabel, canReviewRequest, onApprove, onReject }) {
  const primary = members[0];
  return (
    <div className="request-card request-card-group entity-cluster">
      <div className="request-row1">
        <span className="request-code" title="Κωδικός αιτήματος">#{primary.requestCode}</span>
        <span className="request-title-text">{describeRequest(primary, storeLabel)}</span>
        <span className="spacer" />
      </div>
      <div className="request-row2">
        Υπέβαλε το αίτημα: <UserNameLink user={primary.requestedByUser} fallback={primary.requestedBy} /> · Ιδιοκτήτης spacegroup:{' '}
        {primary.pmGr ? <UserNameLink user={primary.pmGr} /> : '— (κανείς ακόμα)'}
      </div>
      <div className="request-group-domains">
        {members.map((r) => {
          const diffs = requestDiffs(r);
          const canReview = canReviewRequest(r);
          return (
            <div key={r.id} id={'request-' + r.id} className={'request-domain-row ' + (REQUEST_DOMAIN_CLASS[r.domain] || '')}>
              <span className={'pill domain-pill ' + (r.domain === 'feed' ? 'feed' : 'display')}>{requestTypeLabel(r)}</span>
              <span className={'pill ' + (r.status === 'approved' ? 'ok' : r.status === 'rejected' ? 'no' : 'wait')}>
                {REQUEST_STATUS_LABELS[r.status]}
              </span>
              {diffs.length ? (
                <span className="request-domain-diffs">
                  {diffs.map((d, i) => (
                    <React.Fragment key={d.field}>
                      {i > 0 ? '  ·  ' : ''}
                      <DiffLine d={d} />
                    </React.Fragment>
                  ))}
                </span>
              ) : null}
              <span className="spacer" />
              {canReview && r.status === 'pending' ? (
                <div className="request-actions">
                  <button className="btn primary sm" onClick={() => onApprove(r)}>Έγκριση</button>
                  <button className="btn ghost sm" onClick={() => onReject(r)}>Απόρριψη</button>
                </div>
              ) : r.reviewedBy ? (
                <span className="request-meta">
                  {r.status === 'approved' ? 'Εγκρίθηκε' : 'Απορρίφθηκε'} από <UserNameLink user={r.reviewedByUser} fallback={r.reviewedBy} />{r.reviewNote ? ` · ${r.reviewNote}` : ''}
                </span>
              ) : (
                <span className="request-meta">Υποβλήθηκε {new Date(r.createdAt).toLocaleString('el')}</span>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

function RequestsPage({ reference }) {
  const storeNames = (reference && reference.storeNames) || {};
  function storeLabel(code) { return storeNames[code] ? `${code} · ${storeNames[code]}` : code; }

  const { role } = useAuth();
  // Per-αίτημα gate — όλοι οι 6 νέοι ρόλοι βλέπουν όλα τα αιτήματα (ρητά ζητήθηκε, βλ.
  // GET /api/change-requests) αλλά μόνο ο σωστός Manager βλέπει κουμπιά Έγκριση/Απόρριψη —
  // space_manager μόνο display αιτήματα, on_demand_manager μόνο feed, moderator/admin/super_admin και τα δύο.
  function canReviewRequest(r) {
    return r.domain === 'feed' ? CAN_REVIEW_FEED_REQUESTS(role) : CAN_REVIEW_DISPLAY_REQUESTS(role);
  }
  const [requests, setRequests] = useState(null);
  // Deep-link από Clusters/Stores tab (#/requests?highlight=ID, βλ. κουμπί "Δες αίτημα" στη
  // γραμμή ενός cluster με pending αίτημα) — παρακάμπτει το default status='pending' filter
  // ΜΟΝΟ την πρώτη φόρτωση, ώστε το στοχευμένο αίτημα να είναι σίγουρα ορατό ασχέτως αν
  // εγκρίθηκε/απορρίφθηκε ήδη πριν προλάβει να το δει ο χρήστης.
  const highlightId = useMemo(() => {
    const raw = parseHashQuery().highlight;
    return raw ? Number(raw) : null;
  }, []);
  // Persisted ανά χρήστη (ζητήθηκε ρητά "να αποθηκεύεται στο προφίλ ... reload/refresh/forward-
  // back να φέρνει τα ίδια από την τελευταία ενεργή συνεδρεία") — ίδιο useViewPrefs/
  // usePersistedField μηχανισμό με ClustersPage/StoresPage (server-side, PUT
  // /api/users/me/view-prefs, in-memory cache ανάμεσα σε in-app tab αλλαγές ώστε να μη χρειάζεται
  // καν round-trip για να επιβιώσει μια γρήγορη εναλλαγή tab).
  const viewPrefs = useViewPrefs('requests');
  // highlightId deep-link: παρακάμπτει το αποθηκευμένο statusFilter ΜΟΝΟ στην αρχική φόρτωση
  // (ώστε το στοχευμένο αίτημα να είναι σίγουρα ορατό) — ΧΩΡΙΣ να αντικαταστήσει μόνιμα την
  // αποθηκευμένη προτίμηση του χρήστη με αυτό το προσωρινό ''. Ο ref κρατάει "ήταν αυτό το
  // πρώτο, highlight-driven render" — flip-άρει μετά το πρώτο effect run, οπότε οποιαδήποτε
  // ΜΕΤΕΠΕΙΤΑ αλλαγή (ακόμα και σε ίδια σελίδα με highlight) αποθηκεύεται κανονικά.
  const highlightOverrideRef = useRef(!!highlightId);
  const [statusFilter, setStatusFilter] = useState(() => {
    if (highlightId) return '';
    return viewPrefs.initial.statusFilter !== undefined ? viewPrefs.initial.statusFilter : 'pending';
  });
  useEffect(() => {
    if (highlightOverrideRef.current) { highlightOverrideRef.current = false; return; }
    viewPrefs.save({ statusFilter });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [statusFilter]);
  const [sgFilter, setSgFilter] = usePersistedField(viewPrefs, 'sgFilter', '');
  const [storeFilter, setStoreFilter] = usePersistedField(viewPrefs, 'storeFilter', '');
  const [typeFilter, setTypeFilter] = usePersistedField(viewPrefs, 'typeFilter', '');
  const anyFilterActive = statusFilter || sgFilter || storeFilter || typeFilter;
  function clearFilters() { setStatusFilter(''); setSgFilter(''); setStoreFilter(''); setTypeFilter(''); }
  // Φίλτρα σε modal drawer αντί για οριζόντιο .toolbar (ζητήθηκε ρητά, ίδιο pattern παντού).
  const [filtersOpen, setFiltersOpen] = useState(false);

  // reloadSeq: ίδιο stale-response guard με το ClustersPage.reload (βλ. σχόλιο εκεί) — εδώ
  // χρειάζεται γιατί το query ΕΙΝΑΙ παραμετροποιημένο (status), σε αντίθεση με τα άλλα reload()
  // αυτής της σελίδας-τύπου· γρήγορη εναλλαγή του φίλτρου Κατάσταση, ή ένα approve/reject που
  // ξανακαλεί reload() ενώ ένα προηγούμενο GET (άλλου status) είναι ακόμα in-flight, θα μπορούσε
  // αλλιώς να αφήσει τη λίστα να δείχνει αποτελέσματα για λάθος φίλτρο.
  const reloadSeq = useRef(0);
  const reload = useCallback(() => {
    const seq = ++reloadSeq.current;
    api.get('/api/change-requests' + qs({ status: statusFilter }))
      .then((rows) => { if (seq === reloadSeq.current) setRequests(rows); })
      .catch((e) => { if (seq === reloadSeq.current) toastErr(e.message); });
  }, [statusFilter]);
  useEffect(() => { reload(); }, [reload]);

  // Scroll-to + προσωρινό visual pulse στην κάρτα-στόχο, μόλις φορτώσουν τα requests. Καθαρίζει
  // το #highlight από το hash μετά (δεν ξαναπυροδοτεί σε κάθε re-render/επόμενη επίσκεψη).
  useEffect(() => {
    if (!highlightId || !requests) return;
    const el = document.getElementById('request-' + highlightId);
    if (el) {
      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
      el.classList.add('request-card-pulse');
      const t = setTimeout(() => el.classList.remove('request-card-pulse'), 2200);
      history.replaceState(null, '', location.hash.split('?')[0]);
      return () => clearTimeout(t);
    }
  }, [highlightId, requests]);

  // Client-side — η λίστα δεν είναι server-paginated (μέχρι 1000 rows ήδη), οπότε οι
  // επιλογές των dropdown βγαίνουν από ό,τι πραγματικά υπάρχει στην ήδη-φορτωμένη λίστα,
  // ίδιο "εξαίρεσε τον εαυτό σου" cascading convention με τα υπόλοιπα tabs.
  function matchesRequest(r, { skipSg, skipStore, skipType } = {}) {
    // spacegroupId είναι πλέον integer (server response) — sgFilter είναι πάντα string
    // (native <select> value), String(...) και στις δύο πλευρές για σωστή σύγκριση.
    if (!skipSg && sgFilter && String(r.spacegroupId) !== sgFilter) return false;
    if (!skipType && typeFilter && r.entity !== typeFilter) return false;
    if (!skipStore && storeFilter) {
      const hit = r.storeCode === storeFilter || (r.affectedStores && r.affectedStores.includes(storeFilter));
      if (!hit) return false;
    }
    return true;
  }

  const sgOptions = useMemo(() => {
    const map = new Map();
    for (const r of (requests || []).filter((r) => matchesRequest(r, { skipSg: true }))) {
      if (r.spacegroupId != null && !map.has(String(r.spacegroupId))) map.set(String(r.spacegroupId), r.spacegroupName);
    }
    return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
  }, [requests, storeFilter, typeFilter]);

  const storeOptions = useMemo(() => {
    const set = new Set();
    for (const r of (requests || []).filter((r) => matchesRequest(r, { skipStore: true }))) {
      if (r.storeCode) set.add(r.storeCode);
      (r.affectedStores || []).forEach((s) => set.add(s));
    }
    return [...set].sort();
  }, [requests, sgFilter, typeFilter]);

  const typeOptions = useMemo(() => {
    const set = new Set((requests || []).filter((r) => matchesRequest(r, { skipType: true })).map((r) => r.entity));
    return Object.keys(REQUEST_TYPE_LABELS).filter((t) => set.has(t));
  }, [requests, sgFilter, storeFilter]);

  const shownRequests = useMemo(
    () => (requests || []).filter((r) => matchesRequest(r)),
    [requests, sgFilter, storeFilter, typeFilter],
  );

  // Ομαδοποίηση των 2 domain-μισών μιας πρότασης δημιουργίας cluster σε ΜΙΑ κάρτα (βλ.
  // GroupedRequestCard) — ζητήθηκε ρητά μετά το κοινό requestCode, "πάλι είδα 2 διαφορετικές
  // γραμμές σε αυτόν που έκανε το αίτημα": ο κοινός κωδικός από μόνος του δεν αρκούσε.
  // Διατηρεί τη θέση του ΠΡΩΤΟΥ μέλους που συναντάται μέσα στη ΗΔΗ ταξινομημένη (created_at
  // desc, βλ. server) λίστα, ώστε η σειρά της σελίδας να μη χρειάζεται ξαναϋπολογισμό.
  // Ομάδα με μόνο 1 μέλος ορατό (π.χ. φίλτρο status=pending ενώ το άλλο domain ήδη
  // εγκρίθηκε/απορρίφθηκε) εμφανίζεται κανονικά, απλά με 1 γραμμή μέσα στην κάρτα.
  const renderItems = useMemo(() => {
    const items = [];
    const groupIndex = new Map();
    for (const r of shownRequests) {
      const isCreateGroup = r.entity === 'cluster' && r.action === 'create' && r.createGroupId;
      if (!isCreateGroup) { items.push({ type: 'single', key: 'r' + r.id, r }); continue; }
      if (groupIndex.has(r.createGroupId)) {
        items[groupIndex.get(r.createGroupId)].members.push(r);
      } else {
        groupIndex.set(r.createGroupId, items.length);
        items.push({ type: 'group', key: 'g' + r.createGroupId, members: [r] });
      }
    }
    return items;
  }, [shownRequests]);

  // Μαζική επιλογή — ΜΟΝΟ single-domain αιτήματα (type==='single'), ΠΟΤΕ GroupedRequestCard
  // (τα 2 μισά μιας πρότασης δημιουργίας cluster) — το create_group_id cascade behavior στο
  // reject προσθέτει πραγματικό ρίσκο σε ένα batch, βλ. σχόλιο στο reviewChangeRequest
  // (server.js). Λειτουργεί πάνω στην ΤΡΕΧΟΥΣΑ φιλτραρισμένη/ορατή λίστα (renderItems ήδη
  // περνάει από τα φίλτρα sgFilter/storeFilter/typeFilter/statusFilter πιο πάνω).
  const selectableIds = useMemo(
    () => renderItems.filter((it) => it.type === 'single' && canReviewRequest(it.r) && it.r.status === 'pending').map((it) => it.r.id),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [renderItems, role],
  );
  const [selectedIds, setSelectedIds] = useState(() => new Set());
  const [bulkBusy, setBulkBusy] = useState(false);
  // Κλαδεύει stale επιλογές κάθε φορά που αλλάζει η ορατή/επιλέξιμη λίστα (φίλτρο, reload) —
  // ώστε ένα ήδη-μη-ορατό id να μη μείνει αθόρυβα "επιλεγμένο".
  useEffect(() => {
    setSelectedIds((prev) => {
      const next = new Set([...prev].filter((id) => selectableIds.includes(id)));
      return next.size === prev.size ? prev : next;
    });
  }, [selectableIds]);

  function toggleSelect(id) {
    setSelectedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) { next.delete(id); return next; }
      if (next.size >= BULK_REVIEW_MAX_IDS) { toastErr(`Μέγιστο ${BULK_REVIEW_MAX_IDS} επιλεγμένα ταυτόχρονα`); return prev; }
      next.add(id);
      return next;
    });
  }
  function toggleSelectAllVisible() {
    setSelectedIds((prev) => {
      const allSelected = selectableIds.length > 0 && selectableIds.every((id) => prev.has(id));
      if (allSelected) return new Set();
      const capped = selectableIds.slice(0, BULK_REVIEW_MAX_IDS);
      if (capped.length < selectableIds.length) toastErr(`Επιλέχθηκαν τα πρώτα ${BULK_REVIEW_MAX_IDS} — περιόρισε τα φίλτρα για τα υπόλοιπα`);
      return new Set(capped);
    });
  }

  function summarizeBulkResult(summary, results, action) {
    const doneKey = action === 'approve' ? 'approved' : 'rejected';
    const doneWord = action === 'approve' ? 'εγκρίθηκαν' : 'απορρίφθηκαν';
    const parts = [];
    if (summary[doneKey]) parts.push(`${summary[doneKey]} ${doneWord}`);
    if (summary.skipped) parts.push(`${summary.skipped} παραλείφθηκαν (ήδη κρίθηκαν)`);
    if (summary.failed) {
      const reasons = new Set(results.filter((r) => r.outcome === 'failed').map((r) => r.message));
      parts.push(reasons.size === 1 ? `${summary.failed} απέτυχε — ${[...reasons][0]}` : `${summary.failed} απέτυχαν`);
    }
    return parts.join(' · ') || 'Καμία αλλαγή';
  }
  async function bulkApprove() {
    const ids = [...selectedIds];
    if (!ids.length || bulkBusy) return;
    if (!confirm(`Έγκριση ${ids.length} αιτημάτων;`)) return;
    setBulkBusy(true);
    try {
      const { results, summary } = await api.send('POST', '/api/change-requests/bulk-review', { ids, action: 'approve' });
      toast(summarizeBulkResult(summary, results, 'approve'));
      setSelectedIds(new Set());
      bumpPendingRefs();
      reload();
    } catch (e) { toastErr(e.message); }
    finally { setBulkBusy(false); }
  }
  async function bulkReject() {
    const ids = [...selectedIds];
    if (!ids.length || bulkBusy) return;
    // Άκυρο (Cancel) ακυρώνει ΟΛΟΚΛΗΡΟ το batch εδώ — σκόπιμα ΔΙΑΦΟΡΕΤΙΚΟ από το μεμονωμένο
    // reject() παρακάτω, όπου Άκυρο="OK με άδειο σχόλιο" και πάντα προχωράει· μεγαλύτερο blast
    // radius σε bulk (ζητήθηκε ρητά ξεκάθαρο guard).
    const reviewNote = prompt('Σχόλιο απόρριψης (προαιρετικό) — θα εφαρμοστεί σε όλα τα επιλεγμένα:');
    if (reviewNote === null) return;
    setBulkBusy(true);
    try {
      const { results, summary } = await api.send('POST', '/api/change-requests/bulk-review', { ids, action: 'reject', reviewNote });
      toast(summarizeBulkResult(summary, results, 'reject'));
      setSelectedIds(new Set());
      bumpPendingRefs();
      reload();
    } catch (e) { toastErr(e.message); }
    finally { setBulkBusy(false); }
  }

  async function approve(r) {
    try {
      await api.send('PUT', `/api/change-requests/${r.id}/approve`);
      toast('Εγκρίθηκε');
      bumpPendingRefs();
      reload();
    } catch (e) { toastErr(e.message); }
  }
  async function reject(r) {
    const reviewNote = prompt('Σχόλιο απόρριψης (προαιρετικό):') || '';
    try {
      await api.send('PUT', `/api/change-requests/${r.id}/reject`, { reviewNote });
      toast('Απορρίφθηκε');
      bumpPendingRefs();
      reload();
    } catch (e) { toastErr(e.message); }
  }

  return (
    <div className="page">
      <RequestBadgesRow onFilterClick={(entity, status) => {
        setTypeFilter(entity); setStatusFilter(status); setSgFilter(''); setStoreFilter('');
      }} />
      <div className="stores-main-head">
        <div className="stores-main-head-left">
          <b>{role === 'pm' ? 'Τα αιτήματά μου' : 'Αιτήματα'}</b>
          {selectableIds.length ? (
            <label className="requests-selectall">
              <input type="checkbox" checked={selectableIds.length > 0 && selectableIds.every((id) => selectedIds.has(id))}
                onChange={toggleSelectAllVisible} />
              Επιλογή όλων ({selectableIds.length})
            </label>
          ) : null}
        </div>
        <button type="button" className="btn ghost sm" onClick={() => setFiltersOpen(true)}>
          Φίλτρα{anyFilterActive ? <span className="cartbadge">•</span> : null}
        </button>
      </div>

      {filtersOpen ? (
        <div className="drawer-backdrop" onClick={() => setFiltersOpen(false)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Φίλτρα</b>
              <button className="btn ghost sm" onClick={() => setFiltersOpen(false)} aria-label="Κλείσιμο">✕</button>
            </div>
            <div className="drawer-body">
              <div className="drawer-field">
                <label>Κατάσταση</label>
                <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
                  <option value="">Όλα</option>
                  <option value="pending">Εκκρεμούν</option>
                  <option value="approved">Εγκρίθηκαν</option>
                  <option value="rejected">Απορρίφθηκαν</option>
                </select>
              </div>
              <div className="drawer-field">
                <label>Τύπος</label>
                <select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
                  <option value="">Όλα</option>
                  {typeOptions.map((t) => <option key={t} value={t}>{REQUEST_TYPE_LABELS[t]}</option>)}
                </select>
              </div>
              <div className="drawer-field">
                <label>Spacegroup</label>
                <select value={sgFilter} onChange={(e) => setSgFilter(e.target.value)}>
                  <option value="">Όλα</option>
                  {sgOptions.map(([id, name]) => <option key={id} value={id}>{id}{name ? ' · ' + name : ''}</option>)}
                </select>
              </div>
              <div className="drawer-field">
                <label>Κατάστημα</label>
                <select value={storeFilter} onChange={(e) => setStoreFilter(e.target.value)}>
                  <option value="">Όλα</option>
                  {storeOptions.map((s) => <option key={s} value={s}>{storeLabel(s)}</option>)}
                </select>
              </div>
            </div>
            <div className="drawer-foot">
              <button className="btn ghost" onClick={clearFilters} disabled={!anyFilterActive}>Καθαρισμός φίλτρων</button>
            </div>
          </div>
        </div>
      ) : null}

      {!requests ? (
        <div className="empty">Φόρτωση…</div>
      ) : shownRequests.length === 0 ? (
        <div className="empty">Δεν υπάρχουν αιτήματα.</div>
      ) : (
        <div className="requests-list compact">
          {renderItems.map((item) => item.type === 'single' ? (
            <RequestCard key={item.key} r={item.r} storeLabel={storeLabel}
              canReview={canReviewRequest(item.r)} onApprove={approve} onReject={reject}
              selected={selectedIds.has(item.r.id)} onToggleSelect={toggleSelect} />
          ) : (
            <GroupedRequestCard key={item.key} members={item.members} storeLabel={storeLabel}
              canReviewRequest={canReviewRequest} onApprove={approve} onReject={reject} />
          ))}
        </div>
      )}

      {selectedIds.size > 0 ? (
        <div className="bulk-action-bar">
          <b>{selectedIds.size} επιλεγμένα</b>
          <span className="spacer" />
          <button type="button" className="btn ghost sm" disabled={bulkBusy} onClick={() => setSelectedIds(new Set())}>Καθαρισμός</button>
          <button type="button" className="btn ghost sm" disabled={bulkBusy} onClick={bulkReject}>{bulkBusy ? 'Επεξεργασία…' : 'Απόρριψη επιλεγμένων'}</button>
          <button type="button" className="btn primary sm" disabled={bulkBusy} onClick={bulkApprove}>{bulkBusy ? 'Επεξεργασία…' : 'Έγκριση επιλεγμένων'}</button>
        </div>
      ) : null}
    </div>
  );
}

// Inline-editable Display Name κελί — local state ώστε το typing να μην κολλάει περιμένοντας
// reload μετά από κάθε keystroke (commit μόνο σε blur, ίδιο πνεύμα με κάθε άλλο "save on blur"
// πεδίο σε αυτή την εφαρμογή).
function DisplayNameCell({ user, editable, onSave }) {
  const [value, setValue] = useState(user.displayName);
  useEffect(() => { setValue(user.displayName); }, [user.displayName]);
  if (!editable) return <span>{user.displayName}</span>;
  return <input value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => onSave(value)} />;
}

// Ίδιο inline-edit-on-blur pattern με το DisplayNameCell παραπάνω — κενή τιμή επιτρέπεται εδώ
// (βλ. saveTitle), σε αντίθεση με το displayName που δεν επιτρέπεται ποτέ κενό.
function TitleCell({ user, editable, onSave }) {
  const [value, setValue] = useState(user.title || '');
  useEffect(() => { setValue(user.title || ''); }, [user.title]);
  if (!editable) return <span>{user.title || <span className="muted">—</span>}</span>;
  return <input value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => onSave(value)} placeholder="—" />;
}

// Ίδιο pattern με το TitleCell — δικό του component (ΟΧΙ γενικευμένο μαζί) ίδιο convention με
// DisplayNameCell/TitleCell, ίδια λογική κενή-τιμή-επιτρέπεται.
function PhoneCell({ user, editable, onSave }) {
  const [value, setValue] = useState(user.phone || '');
  useEffect(() => { setValue(user.phone || ''); }, [user.phone]);
  if (!editable) return <span>{user.phone || <span className="muted">—</span>}</span>;
  return <input value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => onSave(value)} placeholder="—" />;
}

function EmailCell({ user, editable, onSave }) {
  const [value, setValue] = useState(user.email || '');
  useEffect(() => { setValue(user.email || ''); }, [user.email]);
  if (!editable) return <span>{user.email || <span className="muted">—</span>}</span>;
  return <input value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => onSave(value)} placeholder="—" />;
}

function TeamEmailCell({ user, editable, onSave }) {
  const [value, setValue] = useState(user.teamEmail || '');
  useEffect(() => { setValue(user.teamEmail || ''); }, [user.teamEmail]);
  if (!editable) return <span>{user.teamEmail || <span className="muted">—</span>}</span>;
  return <input value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => onSave(value)} placeholder="—" />;
}

/* ============================ Users admin page ============================ */
function UsersAdminPage() {
  const me = useAuth();
  const isSuperAdmin = me.role === 'super_admin';
  // Team ρόλος (space_manager/on_demand_manager/space_user/on_demand_user) — η λίστα `users`
  // έρχεται ήδη σωστά φιλτραρισμένη server-side (GET /api/users, βλ. TEAM_VIEW_ROLES) σε αυτή
  // την περίπτωση, ΠΛΑΤΥΤΕΡΗ από όσα μπορεί πραγματικά να επεξεργαστεί (ζητήθηκε ρητά "να
  // βλέπει και την άλλη ομάδα ακόμα και αν δεν μπορεί να το επεξεργαστεί") — άρα η επεξεργασία
  // ελέγχεται ΑΝΑ ΓΡΑΜΜΗ (βλ. canEditRow παρακάτω), όχι σε επίπεδο σελίδας.
  const myTeamRoles = TEAM_MANAGED_ROLES[me.role] || [];
  const canCreateInTeam = myTeamRoles.length > 0;
  function canEditRow(u) { return CAN_MANAGE_USERS(me.role) || myTeamRoles.includes(u.role); }
  const [users, setUsers] = useState(null);
  // form: { username, role, tempPassword, displayName } | null — "εξ αρχής" δημιουργία μόνο,
  // super_admin only (η "ενεργοποίηση" ενός ήδη υπάρχοντος role='pm' row χωρίς username γίνεται
  // πλέον ξεχωριστά, βλ. activateFor παρακάτω).
  const [form, setForm] = useState(null);
  const [busy, setBusy] = useState(false);
  const [fUsername, setFUsername] = useState('');
  const [fDisplayName, setFDisplayName] = useState('');
  const [fRole, setFRole] = useState('');
  const [fStatus, setFStatus] = useState(''); // '' | 'active' | 'inactive' | 'pending_pw'
  const [sortBy, setSortBy] = useState('username');
  const [sortDir, setSortDir] = useState('asc');

  const reload = useCallback(() => {
    api.get('/api/users').then(setUsers).catch((e) => toastErr(e.message));
  }, []);
  useEffect(() => { reload(); }, [reload]);

  function statusBucket(u) {
    if (!u.active) return 'inactive';
    if (u.mustChangePassword) return 'pending_pw';
    return 'active';
  }

  function matchesUser(u, { skipUsername, skipDisplayName, skipRole, skipStatus } = {}) {
    if (!skipUsername && fUsername && !(u.username || '').toLowerCase().includes(fUsername.trim().toLowerCase())) return false;
    if (!skipDisplayName && fDisplayName && !(u.displayName || '').toLowerCase().includes(fDisplayName.trim().toLowerCase())) return false;
    if (!skipRole && fRole && u.role !== fRole) return false;
    if (!skipStatus && fStatus && statusBucket(u) !== fStatus) return false;
    return true;
  }

  // Cascading — client-side εδώ αφού το Users tab είναι ήδη πλήρως φορτωμένο στη μνήμη
  // (μικρός πίνακας, χωρίς pagination) — ίδιο "εξαίρεσε τον εαυτό σου" convention με το
  // GET /api/clusters/facets, απλά υπολογισμένο εδώ αντί σε round-trip στον server.
  const roleOptions = useMemo(() => {
    const present = new Set((users || []).filter((u) => matchesUser(u, { skipRole: true })).map((u) => u.role));
    if (fRole) present.add(fRole);
    return Object.keys(ROLE_LABELS).filter((r) => present.has(r));
  }, [users, fUsername, fDisplayName, fStatus, fRole]);
  const statusOptions = useMemo(() => {
    const present = new Set((users || []).filter((u) => matchesUser(u, { skipStatus: true })).map(statusBucket));
    if (fStatus) present.add(fStatus);
    return ['active', 'pending_pw', 'inactive'].filter((s) => present.has(s));
  }, [users, fUsername, fDisplayName, fRole, fStatus]);

  const shownUsers = useMemo(() => {
    const list = (users || []).filter((u) => matchesUser(u));
    const dir = sortDir === 'desc' ? -1 : 1;
    return list.slice().sort((a, b) => {
      const av = a[sortBy], bv = b[sortBy];
      if (av == null && bv == null) return 0;
      if (av == null) return 1;
      if (bv == null) return -1;
      // spacegroupCount είναι αριθμός — string localeCompare θα έβαζε "10" πριν το "2".
      if (sortBy === 'spacegroupCount') return (av - bv) * dir;
      return String(av).localeCompare(String(bv)) * dir;
    });
  }, [users, fUsername, fDisplayName, fRole, fStatus, sortBy, sortDir]);

  function toggleSort(field) {
    if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    else { setSortBy(field); setSortDir('asc'); }
  }
  const anyFilterActive = fUsername || fDisplayName || fRole || fStatus;
  function clearFilters() { setFUsername(''); setFDisplayName(''); setFRole(''); setFStatus(''); }

  async function createUser(e) {
    e.preventDefault();
    setBusy(true);
    try {
      await api.send('POST', '/api/users', {
        username: form.username, role: form.role, tempPassword: form.tempPassword,
        displayName: form.displayName, title: form.title,
      });
      toast('Δημιουργήθηκε ο χρήστης');
      setForm(null);
      reload();
    } catch (e) { toastErr(e.message); }
    setBusy(false);
  }

  async function changeRole(u, role) {
    try {
      await api.send('PUT', '/api/users/' + u.id, { role });
      toast('Ενημερώθηκε ο ρόλος');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  async function toggleActive(u) {
    try {
      await api.send('PUT', '/api/users/' + u.id, { active: !u.active });
      toast(u.active ? 'Απενεργοποιήθηκε' : 'Ενεργοποιήθηκε');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  async function saveDisplayName(u, value) {
    const trimmed = value.trim();
    if (!trimmed || trimmed === u.displayName) return;
    try {
      await api.send('PUT', '/api/users/' + u.id, { displayName: trimmed });
      toast('Ενημερώθηκε το όνομα');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  // Κενό title/phone/email επιτρέπεται (ΟΧΙ σαν το displayName παραπάνω) — "καθάρισμα"
  // title σημαίνει επιστροφή στην εμφάνιση του role, ζητήθηκε ρητά ("αν δεν συμπληρωθεί να
  // κρατάει το role όπως τώρα").
  async function saveTitle(u, value) {
    const trimmed = value.trim();
    if (trimmed === (u.title || '')) return;
    try {
      await api.send('PUT', '/api/users/' + u.id, { title: trimmed });
      toast('Ενημερώθηκε ο τίτλος');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  async function savePhone(u, value) {
    const trimmed = value.trim();
    if (trimmed === (u.phone || '')) return;
    try {
      await api.send('PUT', '/api/users/' + u.id, { phone: trimmed });
      toast('Ενημερώθηκε το τηλέφωνο');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  async function saveEmail(u, value) {
    const trimmed = value.trim();
    if (trimmed === (u.email || '')) return;
    try {
      await api.send('PUT', '/api/users/' + u.id, { email: trimmed });
      toast('Ενημερώθηκε το email');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  async function saveTeamEmail(u, value) {
    const trimmed = value.trim();
    if (trimmed === (u.teamEmail || '')) return;
    try {
      await api.send('PUT', '/api/users/' + u.id, { teamEmail: trimmed });
      toast('Ενημερώθηκε το team email');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  async function resetPassword(u) {
    const tempPassword = prompt('Νέο προσωρινό password για ' + u.username + ':');
    if (!tempPassword) return;
    try {
      await api.send('PUT', '/api/users/' + u.id + '/reset-password', { tempPassword });
      toast('Έγινε reset το password — θα ζητηθεί αλλαγή στο επόμενο login');
    } catch (e) { toastErr(e.message); }
  }

  // Πριν διαγράψει, πάντα φέρνει πρώτα το πλήρες breakdown (read-only, GET /api/users/:id/
  // references) — χρειάστηκε αφού παραλίγο να σβηστεί ιδιοκτησία πραγματικών PM σε
  // spacegroups επειδή το username τους έμοιαζε με test account. Αν δεν υπάρχει ΤΙΠΟΤΑ που
  // να δείχνει σε αυτόν, γρήγορο μονοπάτι (plain confirm + DELETE, ίδιο με πριν) — αλλιώς
  // ανοίγει το drawer με το detail ώστε ο super_admin να αποφασίσει Απενεργοποίηση ή Force delete.
  const [deleteFor, setDeleteFor] = useState(null); // { user, refs } | null
  const [deleteConfirmText, setDeleteConfirmText] = useState('');

  async function deleteUser(u) {
    try {
      const refs = await api.get('/api/users/' + u.id + '/references');
      const hasAnything = refs.spacegroupsOwned.length ||
        refs.requestsAuthored.length || refs.requestsReviewed.length || refs.usersCreated.length;
      if (!hasAnything) {
        if (!confirm(`Διαγραφή του χρήστη ${u.displayName};`)) return;
        await api.send('DELETE', '/api/users/' + u.id);
        toast('Διαγράφηκε');
        reload();
        return;
      }
      setDeleteConfirmText('');
      setDeleteFor({ user: u, refs });
    } catch (e) { toastErr(e.message); }
  }

  async function forceDeleteUser() {
    if (!deleteFor || deleteConfirmText !== deleteFor.user.displayName) return;
    setBusy(true);
    try {
      await api.send('DELETE', '/api/users/' + deleteFor.user.id + '?force=true');
      toast('Διαγράφηκε (force) — spacegroups ιδιοκτησίας του έμειναν χωρίς PM');
      setDeleteFor(null);
      reload();
    } catch (e) { toastErr(e.message); }
    setBusy(false);
  }

  // "Ενεργοποίηση λογαριασμού" — δίνει username+password σε ένα ήδη υπάρχον role='pm' row
  // χωρίς username ακόμα (χειροκίνητα από import ή από το one-time roster migration). Ο PM
  // ΕΙΝΑΙ ήδη το users row (display name κ.λπ.) — αυτό απλά του δίνει credentials.
  const [activateFor, setActivateFor] = useState(null); // user row | null
  const [activateUsername, setActivateUsername] = useState('');
  const [activateTempPassword, setActivateTempPassword] = useState('');
  const [activateTitle, setActivateTitle] = useState('');

  function openActivate(u) {
    setActivateFor(u);
    setActivateUsername('');
    setActivateTempPassword('');
    setActivateTitle(u.title || '');
  }
  async function submitActivate(e) {
    e.preventDefault();
    setBusy(true);
    try {
      await api.send('PUT', '/api/users/' + activateFor.id + '/activate', {
        username: activateUsername, tempPassword: activateTempPassword, title: activateTitle,
      });
      toast('Ενεργοποιήθηκε');
      setActivateFor(null);
      reload();
    } catch (e) { toastErr(e.message); }
    setBusy(false);
  }

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow"><b>Χρήστες</b></div>
        <button className="btn ghost sm" onClick={clearFilters} disabled={!anyFilterActive}>Καθαρισμός φίλτρων</button>
        {isSuperAdmin || canCreateInTeam ? (
          <button className="btn primary" onClick={() => setForm({ username: '', displayName: '', title: '', role: isSuperAdmin ? 'viewer' : myTeamRoles[0], tempPassword: '' })}>
            + Νέος χρήστης
          </button>
        ) : null}
      </div>

      <div className="tablewrap">
        <table>
          <thead>
            <tr>
              <th></th>
              <SortableTh field="displayName" label="Display Name" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="title" label="Τίτλος" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="phone" label="Τηλέφωνο" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="email" label="Email" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="teamEmail" label="Team Email" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="username" label="Username" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="role" label="Ρόλος" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="spacegroupCount" label="Spacegroups" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <th>Κατάσταση</th>
              <SortableTh field="createdAt" label="Δημιουργήθηκε" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="createdBy" label="Από" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <th>Ενέργειες</th>
            </tr>
            <tr className="filter-row">
              <th></th>
              <th><input value={fDisplayName} onChange={(e) => setFDisplayName(e.target.value)} placeholder="…" /></th>
              <th></th>
              <th></th>
              <th></th>
              <th></th>
              <th><input value={fUsername} onChange={(e) => setFUsername(e.target.value)} placeholder="…" /></th>
              <th>
                <select value={fRole} onChange={(e) => setFRole(e.target.value)}>
                  <option value="">Όλοι</option>
                  {roleOptions.map((r) => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
                </select>
              </th>
              <th></th>
              <th>
                <select value={fStatus} onChange={(e) => setFStatus(e.target.value)}>
                  <option value="">Όλα</option>
                  {statusOptions.includes('active') ? <option value="active">Ενεργός</option> : null}
                  {statusOptions.includes('pending_pw') ? <option value="pending_pw">Εκκρεμεί αλλαγή pw</option> : null}
                  {statusOptions.includes('inactive') ? <option value="inactive">Ανενεργός</option> : null}
                </select>
              </th>
              <th></th><th></th><th></th>
            </tr>
          </thead>
          <tbody>
            {!users ? (
              <tr><td colSpan={13} className="empty-cell">Φόρτωση…</td></tr>
            ) : shownUsers.map((u) => (
              <tr key={u.id}>
                <td><Avatar userId={u.id} name={u.displayName} hasAvatar={!!u.avatarPath} size={28} /></td>
                <td><DisplayNameCell user={u} editable={canEditRow(u)} onSave={(v) => saveDisplayName(u, v)} /></td>
                <td><TitleCell user={u} editable={canEditRow(u)} onSave={(v) => saveTitle(u, v)} /></td>
                <td><PhoneCell user={u} editable={canEditRow(u)} onSave={(v) => savePhone(u, v)} /></td>
                <td><EmailCell user={u} editable={canEditRow(u)} onSave={(v) => saveEmail(u, v)} /></td>
                <td><TeamEmailCell user={u} editable={canEditRow(u)} onSave={(v) => saveTeamEmail(u, v)} /></td>
                <td>{u.username || <span className="muted">— (μη ενεργοποιημένος)</span>}</td>
                <td>
                  {isSuperAdmin ? (
                    <select value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
                      {Object.keys(ROLE_LABELS).map((r) => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
                    </select>
                  ) : ROLE_LABELS[u.role] || u.role}
                </td>
                <td>{u.role === 'pm' ? u.spacegroupCount : <span className="muted">—</span>}</td>
                <td>
                  {!u.active ? <span className="pill no">Ανενεργός</span> :
                    u.mustChangePassword ? <span className="pill wait">Εκκρεμεί αλλαγή Pwd</span> :
                    <span className="pill ok">Ενεργός</span>}
                </td>
                <td>{u.createdAt ? new Date(u.createdAt).toLocaleDateString('el') : ''}</td>
                <td>{u.createdBy ? <UserNameLink user={u.createdByUser} fallback={u.createdBy} /> : '—'}</td>
                <td className="actions">
                  {isSuperAdmin ? (
                    <>
                      {u.role === 'pm' && !u.username ? (
                        <button className="btn sm" onClick={() => openActivate(u)}>Ενεργοποίηση</button>
                      ) : (
                        <>
                          <button className="btn sm" onClick={() => resetPassword(u)}>Reset</button>
                          <button className={'btn ghost sm icon-only' + (u.active ? '' : ' success')}
                            title={u.active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'} onClick={() => toggleActive(u)}>
                            <Icon name="power" size={14} />
                          </button>
                        </>
                      )}
                      <button className="btn ghost sm icon-only danger" title="Διαγραφή" onClick={() => deleteUser(u)}>
                        <Icon name="trash" size={14} />
                      </button>
                    </>
                  ) : myTeamRoles.includes(u.role) ? (
                    // Team ρόλοι: ΟΧΙ reset password (δεν ζητήθηκε), ΟΧΙ διαγραφή (παραμένει
                    // super_admin only) — ΚΑΙ μόνο για γραμμές μέσα στο δικό τους ΣΤΕΝΟ edit
                    // scope (π.χ. ΟΧΙ πάνω σε μια moderator γραμμή που απλά βλέπουν read-only).
                    // myTeamRoles ΕΠΙΤΗΔΕΣ όχι canEditRow εδώ — canEditRow περιλαμβάνει και
                    // CAN_MANAGE_USERS(admin), που ΔΕΝ πρέπει να πάρει αυτό το κουμπί (ο plain
                    // admin δεν είχε ποτέ status toggle, παραμένει έτσι). Ίδιο pending-pm split
                    // με τον super_admin branch από πάνω — ένας manager που διαχειρίζεται πλέον
                    // pm λογαριασμούς (βλ. TEAM_MANAGED_ROLES) χρειάζεται το ίδιο "Ενεργοποίηση"
                    // κουμπί για μια pending (χωρίς username) pm γραμμή, όχι ένα άκυρο power-toggle
                    // πάνω σε λογαριασμό που δεν έχει καν ενεργοποιηθεί ποτέ.
                    u.role === 'pm' && !u.username ? (
                      <button className="btn sm" onClick={() => openActivate(u)}>Ενεργοποίηση</button>
                    ) : (
                      <button className={'btn ghost sm icon-only' + (u.active ? '' : ' success')}
                        title={u.active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'} onClick={() => toggleActive(u)}>
                        <Icon name="power" size={14} />
                      </button>
                    )
                  ) : null}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {form ? (
        <div className="drawer-backdrop" onClick={() => setForm(null)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head"><b>Νέος χρήστης</b><button className="btn ghost sm" onClick={() => setForm(null)}>✕</button></div>
            <form className="drawer-body" onSubmit={createUser}>
              <div className="drawer-field">
                <label>Username</label>
                <input autoFocus value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
              </div>
              <div className="drawer-field">
                <label>Display Name</label>
                <input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })}
                  placeholder={form.username || '(προεπιλογή: username)'} />
              </div>
              <div className="drawer-field">
                <label>Τίτλος (προαιρετικό)</label>
                <input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
                  placeholder="π.χ. Senior Merchandiser — αν κενό, δείχνει τον ρόλο" />
              </div>
              <div className="drawer-field">
                <label>Ρόλος</label>
                <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
                  {(isSuperAdmin ? Object.keys(ROLE_LABELS) : myTeamRoles).map((r) => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
                </select>
              </div>
              <div className="drawer-field">
                <label>Προσωρινό password</label>
                <input value={form.tempPassword} onChange={(e) => setForm({ ...form, tempPassword: e.target.value })} />
              </div>
            </form>
            <div className="drawer-foot">
              <button className="btn primary" disabled={busy || !form.username || !form.tempPassword} onClick={createUser}>
                {busy ? 'Δημιουργία…' : 'Δημιουργία'}
              </button>
              <button className="btn ghost" onClick={() => setForm(null)}>Άκυρο</button>
            </div>
          </div>
        </div>
      ) : null}

      {activateFor ? (
        <div className="drawer-backdrop" onClick={() => setActivateFor(null)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Ενεργοποίηση λογαριασμού: {activateFor.displayName}</b>
              <button className="btn ghost sm" onClick={() => setActivateFor(null)}>✕</button>
            </div>
            <form className="drawer-body" onSubmit={submitActivate}>
              <div className="drawer-field">
                <label>Username</label>
                <input autoFocus value={activateUsername} onChange={(e) => setActivateUsername(e.target.value)} />
              </div>
              <div className="drawer-field">
                <label>Προσωρινό password</label>
                <input value={activateTempPassword} onChange={(e) => setActivateTempPassword(e.target.value)} />
              </div>
              <div className="drawer-field">
                <label>Τίτλος (προαιρετικό)</label>
                <input value={activateTitle} onChange={(e) => setActivateTitle(e.target.value)}
                  placeholder="π.χ. Senior Merchandiser — αν κενό, δείχνει τον ρόλο" />
              </div>
            </form>
            <div className="drawer-foot">
              <button className="btn primary" disabled={busy || !activateUsername || !activateTempPassword} onClick={submitActivate}>
                {busy ? 'Ενεργοποίηση…' : 'Ενεργοποίηση'}
              </button>
              <button className="btn ghost" onClick={() => setActivateFor(null)}>Άκυρο</button>
            </div>
          </div>
        </div>
      ) : null}

      {deleteFor ? (
        <div className="drawer-backdrop" onClick={() => setDeleteFor(null)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Διαγραφή {deleteFor.user.displayName}</b>
              <button className="btn ghost sm" onClick={() => setDeleteFor(null)}>✕</button>
            </div>
            <div className="drawer-body">
              <div className="docs-warn">
                Αυτός ο χρήστης έχει δεδομένα που δείχνουν πάνω του — η απλή διαγραφή μπλοκάρεται.
                Δες παρακάτω ΤΙ ακριβώς πριν αποφασίσεις.
              </div>
              {deleteFor.refs.spacegroupsOwned.length ? (
                <div className="drawer-field">
                  <label>Spacegroups που ανήκουν σε αυτόν ({deleteFor.refs.spacegroupsOwned.length})</label>
                  <div className="docs-refbox">
                    {deleteFor.refs.spacegroupsOwned.map((sg, i) => (
                      <div key={i} className="docs-refrow">
                        {sg.id} · {sg.name || '(χωρίς όνομα)'}
                      </div>
                    ))}
                  </div>
                </div>
              ) : null}
              {deleteFor.refs.requestsAuthored.length ? (
                <div className="drawer-field">
                  <label>Αιτήματα που υπέβαλε ({deleteFor.refs.requestsAuthored.length})</label>
                  <div className="docs-refbox">
                    {deleteFor.refs.requestsAuthored.map((r) => (
                      <div key={r.id} className="docs-refrow">#{r.id} · {r.entity}/{r.action} · {r.status}</div>
                    ))}
                  </div>
                </div>
              ) : null}
              {deleteFor.refs.requestsReviewed.length ? (
                <div className="drawer-field">
                  <label>Αιτήματα που έκρινε ({deleteFor.refs.requestsReviewed.length})</label>
                  <div className="docs-refbox">
                    {deleteFor.refs.requestsReviewed.map((r) => (
                      <div key={r.id} className="docs-refrow">#{r.id} · {r.entity}/{r.action} · {r.status}</div>
                    ))}
                  </div>
                </div>
              ) : null}
              {deleteFor.refs.usersCreated.length ? (
                <div className="drawer-field">
                  <label>Χρήστες που δημιούργησε</label>
                  {deleteFor.refs.usersCreated.map((u) => <div key={u.id} className="docs-refrow">{u.displayName || u.username}</div>)}
                </div>
              ) : null}

              <div className="drawer-field">
                <label>Force delete — γράψε <span className="mono">{deleteFor.user.displayName}</span> για επιβεβαίωση</label>
                <input value={deleteConfirmText} onChange={(e) => setDeleteConfirmText(e.target.value)} placeholder={deleteFor.user.displayName} />
                <span className="docs-refhint">
                  Force delete: διαγράφει οριστικά τα αιτήματα/ιστορικό παραπάνω. Τα spacegroups
                  ιδιοκτησίας του ΜΕΝΟΥΝ χωρίς PM (δεν αναδιατίθενται πουθενά) — μη αναστρέψιμο.
                </span>
              </div>
            </div>
            <div className="drawer-foot">
              <button className="btn ghost" onClick={async () => { await toggleActive(deleteFor.user); setDeleteFor(null); }}>
                Απενεργοποίηση αντ' αυτού
              </button>
              <button className="btn danger" disabled={busy || deleteConfirmText !== deleteFor.user.displayName} onClick={forceDeleteUser}>
                {busy ? 'Διαγραφή…' : 'Force delete'}
              </button>
              <button className="btn ghost" onClick={() => setDeleteFor(null)}>Άκυρο</button>
            </div>
          </div>
        </div>
      ) : null}
    </div>
  );
}

/* ============================ Spacegroups admin page (PM ownership) ============================ */
function pmmOptionLabel(p) {
  if (!p.username) return p.displayName + ' — μη ενεργοποιημένος';
  return p.displayName + ` — ${p.username}` + (p.active ? '' : ' (ανενεργός)');
}

const emptySgFilters = () => ({
  id: '', name: '', category: '', spacegroupCode: '', pmGrId: '',
  division: '', family: '', locked: '',
});

// Wrapper γύρω από το MultiSelectDropdown για ένα filter πεδίο που ζει σαν comma-separated
// string στο filters object (ίδιο format με ό,τι ήδη περιμένει το qs()/reload() — καμία
// αλλαγή στο state shape, μόνο στο πώς αναπαρίσταται μέσα στο dropdown ένα CSV string <->
// Set). value: labels (π.χ. pmm names) -> raw values (π.χ. pmm ids) μέσω toValue/fromValue
// όταν χρειάζεται (βλ. PM όπου η επιλογή γίνεται με όνομα αλλά αποθηκεύεται id).
function MultiSelectFilterTh({ options, value, onChange, placeholder }) {
  const selected = useMemo(() => new Set(String(value || '').split(',').filter(Boolean)), [value]);
  return (
    <th>
      <MultiSelectDropdown options={options} selected={selected} placeholder={placeholder}
        onToggle={(v) => onChange([...toggleInSet(selected, v)].join(','))} />
    </th>
  );
}

// Ίδιο value<->Set(comma-joined string) convention με το MultiSelectFilterTh, αλλά μέσα σε
// ένα .field div (drawer) αντί για <th> (table header) — για τα Φίλτρα drawers των
// ClustersPage/SpacegroupsAdminPage, που μετακόμισαν εκεί τα per-column φίλτρα τους.
function MultiSelectFilterField({ label, options, value, onChange, placeholder }) {
  const selected = useMemo(() => new Set(String(value || '').split(',').filter(Boolean)), [value]);
  return (
    <div className="field">
      <MultiSelectDropdown label={label} options={options} selected={selected} placeholder={placeholder}
        onToggle={(v) => onChange([...toggleInSet(selected, v)].join(','))} />
    </div>
  );
}

// Στήλες του πίνακα Spacegroups admin (πέρα από το πάντα-ορατό ID) — για το "Στήλες"
// toggle (useColumnVisibility). division/family/spacegroupCode είναι νέες,
// καθαρά πληροφοριακές (βλ. plan §9/§10) — χωρίς δικό τους filter dropdown, ίδιο επίπεδο
// με name/category που ήδη υπάρχουν.
const SPACEGROUP_ADMIN_COLS = [
  ['name', 'Space Group'], ['division', 'Division'],
  ['family', 'Family'], ['category', 'Category'], ['spacegroupCode', 'Spacegroup ID'],
  ['pmGr', 'PM'],
  ['locked', 'Locked'],
];

// Compact αναπαράσταση των 5 ανεξάρτητων λουκέτων σε ΜΙΑ στήλη — ζητήθηκε ρητά "να μην
// έχουμε επιπλέον στήλες χωρίς λόγο", με σύντομες τιμές (π.χ. "Locked OPN/LV/BM") αντί για
// ξεχωριστή στήλη ανά πεδίο. Οι συντομογραφίες είναι ακριβώς όπως ζητήθηκαν (LV/OPN/BM/ND/Feed).
const LOCK_ABBR = [
  ['blockLiveEdit', 'LV'], ['blockOpenEdit', 'OPN'], ['blockBoxMovingEdit', 'BM'],
  ['blockNonDisplayEdit', 'ND'], ['blockFeedEdit', 'Feed'],
];
function lockedFieldsSummary(sg) {
  return LOCK_ABBR.filter(([field]) => sg[field]).map(([, abbr]) => abbr);
}

function SpacegroupsAdminPage({ reference }) {
  const { role } = useAuth();
  const canManageRows = role === 'super_admin'; // rename/add/delete — πιο ριψοκίνδυνα από το ownership-assignment
  // category/PM assignment + lock toggle: λιγότερο ριψοκίνδυνα από rename/add/delete,
  // αλλά ΠΑΝΤΑ write actions — πριν ζούσαν αφύλαχτα γιατί μόνο CAN_DIRECT_WRITE ρόλοι έφταναν
  // καν σε αυτή τη σελίδα· τώρα που η σελίδα ανοίγει και σε viewer/moderator (βλ.
  // CAN_VIEW_SPACEGROUPS) χρειάζονται ρητό guard, αλλιώς θα έβλεπαν επεξεργάσιμα <select>
  // που απλά θα έσκαγαν 403 στο click.
  // + CAN_MANAGE_TEAM (space_manager/on_demand_manager) — ζητήθηκε ρητά, ώστε να μπορούν να
  // ολοκληρώσουν την ανάθεση PM αφού πλέον δημιουργούν/ενεργοποιούν κι αυτοί pm λογαριασμούς
  // (server-side ίδιο gate, βλ. PUT /api/spacegroups/:id).
  const canAssignOwnership = CAN_DIRECT_WRITE(role) || CAN_MANAGE_TEAM(role);
  // Lock modal — display/feed ανεξάρτητα κλειδώματα, ζητήθηκε ρητά "modal με 2 επιλογές" αντί
  // για το παλιό μονό 🔒/🔓 toggle button. Κάθε Manager βλέπει/αλλάζει ΜΟΝΟ το δικό του
  // checkbox (space_manager: display, on_demand_manager: feed) — admin/super_admin και τα δύο.
  const canEditDisplayLock = CAN_DIRECT_WRITE_DISPLAY(role);
  const canEditFeedLock = CAN_DIRECT_WRITE_FEED(role);
  const [lockFor, setLockFor] = useState(null); // sg row | null
  // 5 ανεξάρτητα λουκέτα (Live/Open/Box Moving/Non-Display/Feed) αντί για τα παλιά 2
  // (block_display_edit/block_feed_edit) — ζητήθηκε ρητά per-field κλείδωμα, όχι πια
  // per-domain. canEditDisplayLock συνεχίζει να ελέγχει και τα 4 display πεδία μαζί
  // (space_manager-tier), canEditFeedLock το feed (on_demand_manager-tier) — ίδιο role-tier
  // gating με πριν, απλά περισσότερα checkboxes στην ίδια κατηγορία.
  const [lockDraft, setLockDraft] = useState({
    blockLiveEdit: false, blockOpenEdit: false, blockBoxMovingEdit: false,
    blockNonDisplayEdit: false, blockFeedEdit: false,
  });
  const [lockBusy, setLockBusy] = useState(false);
  function openLockModal(sg) {
    setLockFor(sg);
    setLockDraft({
      blockLiveEdit: !!sg.blockLiveEdit, blockOpenEdit: !!sg.blockOpenEdit,
      blockBoxMovingEdit: !!sg.blockBoxMovingEdit, blockNonDisplayEdit: !!sg.blockNonDisplayEdit,
      blockFeedEdit: !!sg.blockFeedEdit,
    });
  }
  async function submitLock() {
    setLockBusy(true);
    try {
      const patch = {};
      if (canEditDisplayLock) {
        patch.blockLiveEdit = lockDraft.blockLiveEdit;
        patch.blockOpenEdit = lockDraft.blockOpenEdit;
        patch.blockBoxMovingEdit = lockDraft.blockBoxMovingEdit;
        patch.blockNonDisplayEdit = lockDraft.blockNonDisplayEdit;
      }
      if (canEditFeedLock) patch.blockFeedEdit = lockDraft.blockFeedEdit;
      const { spacegroup } = await api.send('PUT', '/api/spacegroups/' + encodeURIComponent(lockFor.id), patch);
      setRows((list) => list.map((s) => (s.id === lockFor.id ? { ...s, ...spacegroup } : s)));
      toast('Ενημερώθηκε το κλείδωμα');
      setLockFor(null);
    } catch (e) { toastErr(e.message); }
    setLockBusy(false);
  }

  const [rows, setRows] = useState(null);
  const [total, setTotal] = useState(0);
  const [pmm, setPmm] = useState(null);
  const [facets, setFacets] = useState({});
  // q/filters/sortBy/sortDir: θυμάται το τελευταίο view ανά χρήστη (server, users.view_prefs,
  // βλ. useViewPrefs/usePersistedField) — ίδιο "μη χρειάζεται να ξαναφτιάξεις τα φίλτρα σε
  // κάθε επίσκεψη, server-side ώστε να ακολουθεί cross-device" convention με ClustersPage/
  // StoresPage, ζητήθηκε ρητά να καλύψει ΚΑΙ αυτή τη σελίδα.
  const viewPrefs = useViewPrefs('spacegroups');
  const [q, setQ] = usePersistedField(viewPrefs, 'q', '');
  const [qDebounced, setQDebounced] = useState(q);
  const [filters, setFilters] = usePersistedField(viewPrefs, 'filters', emptySgFilters());
  const [filtersDebounced, setFiltersDebounced] = useState(filters);
  const [page, setPage] = useState(0);
  const [sortBy, setSortBy] = usePersistedField(viewPrefs, 'sortBy', 'id');
  const [sortDir, setSortDir] = usePersistedField(viewPrefs, 'sortDir', 'asc');
  const [editingId, setEditingId] = useState(null); // sg.id σε mode μετονομασίας
  const [editingIdValue, setEditingIdValue] = useState('');
  const [addOpen, setAddOpen] = useState(false);
  const [addForm, setAddForm] = useState({ id: '', name: '', category: '' });
  const [busy, setBusy] = useState(false);
  const perPage = 50;
  const { isHidden, toggle: toggleColumn, resetToDefault: resetColumns } = useColumnVisibility('spacegroups');

  function setFilter(field, value) { setFilters((f) => ({ ...f, [field]: value })); }
  function toggleSort(field) {
    if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    else { setSortBy(field); setSortDir('asc'); }
  }
  const anyFilterActive = q || Object.values(filters).some(Boolean);
  function clearFilters() { setQ(''); setFilters(emptySgFilters()); }
  const anyStructuredFilterActive = Object.values(filters).some(Boolean);
  const [filtersOpen, setFiltersOpen] = useState(false);

  useEffect(() => {
    const t = setTimeout(() => setQDebounced(q), 300);
    return () => clearTimeout(t);
  }, [q]);

  useEffect(() => {
    const t = setTimeout(() => setFiltersDebounced(filters), 300);
    return () => clearTimeout(t);
  }, [filters]);

  useEffect(() => { setPage(0); }, [qDebounced, filtersDebounced, sortBy, sortDir]);

  useEffect(() => {
    // /api/pmm είναι super_admin/admin/moderator-only server-side — roles χωρίς
    // canAssignOwnership (π.χ. space_manager/on_demand_manager) δεν τη χρειάζονται καν εδώ
    // (μόνο για το owner-assignment dropdown), οπότε skip το fetch αντί να δείξουμε ένα
    // ακατανόητο "GET /api/pmm → 403" toast σε κάποιον που απλά βλέπει τη σελίδα read-only.
    if (!canAssignOwnership) return;
    api.get('/api/pmm').then(setPmm).catch((e) => toastErr(e.message));
  }, [canAssignOwnership]);

  // reloadSeq: ίδιο stale-response guard με το ClustersPage.reload (βλ. σχόλιο εκεί) — αγνοεί
  // απαντήσεις από requests που δεν είναι πια το πιο πρόσφατο (φίλτρο/σελίδα άλλαξε στο μεταξύ).
  const reloadSeq = useRef(0);
  const reload = useCallback(() => {
    const seq = ++reloadSeq.current;
    setRows(null);
    api.get('/api/spacegroups' + qs({ q: qDebounced, ...filtersDebounced, page, pageSize: perPage, sortBy, sortDir }))
      .then((d) => { if (seq === reloadSeq.current) { setRows(d.rows); setTotal(d.total); } })
      .catch((e) => { if (seq === reloadSeq.current) toastErr(e.message); });
  }, [qDebounced, filtersDebounced, page, sortBy, sortDir]);

  useEffect(() => { reload(); }, [reload]);

  useEffect(() => {
    api.get('/api/spacegroups/facets' + qs({ q: qDebounced, ...filtersDebounced }))
      .then(setFacets).catch(() => {});
  }, [qDebounced, filtersDebounced]);

  const pageCount = Math.max(1, Math.ceil(total / perPage));
  const pmGrOptions = pmm || [];

  // βλ. optionsFor στο ClustersPage — ίδιο "εξαίρεσε τον εαυτό σου" cascading convention.
  // filters[field] είναι comma-separated (multi-select) — οι ήδη επιλεγμένες τιμές πρέπει
  // να μένουν στη λίστα options ακόμα κι αν το facet response (φιλτραρισμένο από τα ΑΛΛΑ
  // φίλτρα) δεν τις περιλαμβάνει πια, αλλιώς θα εξαφανίζονταν αθόρυβα από το dropdown.
  function facetOptions(field, fallback) {
    const base = (facets[field] || fallback || []).map(String);
    const cur = String(filters[field] || '').split(',').filter(Boolean);
    const missing = cur.filter((v) => !base.includes(v));
    return [...missing, ...base].filter((v) => v !== '#N/A');
  }
  function categoryOptions() {
    return facetOptions('category', reference && reference.categories);
  }
  function pmFacetOptions(param, roster) {
    const ids = facets[param];
    if (!ids) return roster;
    const idSet = new Set(ids.map(Number));
    for (const id of pmIdList(filters[param])) idSet.add(id);
    return roster.filter((p) => idSet.has(p.id));
  }
  // filters.pmGrId ζει σαν comma-separated ids (ίδιο CSV convention με τα
  // υπόλοιπα multi-select filters) — το MultiSelectDropdown όμως δουλεύει με ονόματα
  // (πιο αναγνώσιμο dropdown από γυμνά ids), άρα εδώ κάνουμε το μετασχηματισμό.
  function pmIdList(raw) { return String(raw || '').split(',').filter(Boolean).map(Number); }
  function pmIdsToNames(raw, roster) {
    const byId = new Map(roster.map((p) => [p.id, p.displayName]));
    return pmIdList(raw).map((id) => byId.get(id)).filter(Boolean);
  }
  function toggleNameInIdList(raw, name, roster) {
    const p = roster.find((x) => x.displayName === name);
    if (!p) return raw;
    const ids = new Set(pmIdList(raw));
    if (ids.has(p.id)) ids.delete(p.id); else ids.add(p.id);
    return [...ids].join(',');
  }

  async function assign(sg, field, value) {
    try {
      const { spacegroup } = await api.send('PUT', '/api/spacegroups/' + encodeURIComponent(sg.id), { [field]: value || null });
      setRows((list) => list.map((s) => (s.id === sg.id ? { ...s, ...spacegroup } : s)));
      toast('Ενημερώθηκε');
    } catch (e) { toastErr(e.message); }
  }


  function startEditId(sg) { setEditingId(sg.id); setEditingIdValue(String(sg.id)); }

  async function commitEditId(sg) {
    const newId = editingIdValue.trim();
    setEditingId(null);
    // spacegroups.id είναι integer — String(sg.id) στη σύγκριση, αλλιώς number !== string
    // πάντα και το guard ποτέ δεν κόβει ένα no-op rename.
    if (!newId || newId === String(sg.id)) return;
    if (!/^\d+$/.test(newId)) { toastErr('Το ID πρέπει να είναι αριθμός'); return; }
    try {
      await api.send('PUT', '/api/spacegroups/' + encodeURIComponent(sg.id), { newId });
      toast('Μετονομάστηκε σε ' + newId);
      reload();
    } catch (e) { toastErr(e.message); }
  }

  // Γενικό inline-edit για τα "δομικά" πεδία (name/division/family/
  // spacegroupCode) — ίδιο click-to-edit pattern με το startEditId/commitEditId παραπάνω,
  // αλλά ένα μόνο state pair αντί για ξεχωριστό ζευγάρι ανά πεδίο (4 πεδία θα σήμαιναν 8
  // useState calls). Μόνο super_admin (canManageRows) βλέπει/χρησιμοποιεί αυτό — ζητήθηκε
  // ρητά "σαν super admin θέλω να μπορώ να κάνω αλλαγές σε όλα τα πεδία".
  const [editingField, setEditingField] = useState(null); // { id, field } | null
  const [editingFieldValue, setEditingFieldValue] = useState('');
  function startEditField(sg, field) { setEditingField({ id: sg.id, field }); setEditingFieldValue(sg[field] || ''); }
  async function commitEditField(sg) {
    if (!editingField) return;
    const { field } = editingField;
    const value = editingFieldValue.trim();
    setEditingField(null);
    if ((sg[field] || '') === value) return; // no-op, καμία αλλαγή
    if (field === 'name' && !value) { toastErr('Το όνομα δεν μπορεί να είναι κενό'); return; }
    try {
      const { spacegroup } = await api.send('PUT', '/api/spacegroups/' + encodeURIComponent(sg.id), { [field]: value || null });
      setRows((list) => list.map((s) => (s.id === sg.id ? { ...s, ...spacegroup } : s)));
      toast('Ενημερώθηκε');
    } catch (e) { toastErr(e.message); }
  }

  // Render helper για τα 4 inline-editable πεδία (name/division/family/
  // spacegroupCode) — μοιράζεται το editingField/startEditField/commitEditField state από
  // πάνω, εξοικονομεί την επανάληψη του click-to-edit JSX pattern 4 φορές.
  function editableCell(sg, field) {
    const editing = editingField && editingField.id === sg.id && editingField.field === field;
    if (canManageRows && editing) {
      return (
        <input autoFocus value={editingFieldValue} onChange={(e) => setEditingFieldValue(e.target.value)}
          onBlur={() => commitEditField(sg)}
          onKeyDown={(e) => { if (e.key === 'Enter') commitEditField(sg); if (e.key === 'Escape') setEditingField(null); }} />
      );
    }
    return (
      <span onClick={() => canManageRows && startEditField(sg, field)}
        style={canManageRows ? { cursor: 'pointer', borderBottom: '1px dashed var(--line)' } : undefined}>
        {sg[field] || '—'}
      </span>
    );
  }

  async function removeSpacegroup(sg) {
    if (!confirm(`Διαγραφή του spacegroup ${sg.id} (${sg.name});`)) return;
    try {
      await api.send('DELETE', '/api/spacegroups/' + encodeURIComponent(sg.id));
      toast('Διαγράφηκε');
      reload();
    } catch (e) { toastErr(e.message); }
  }

  async function submitAdd(e) {
    e.preventDefault();
    // spacegroups.id είναι integer — validate εδώ, όχι μόνο server-side.
    if (!/^\d+$/.test(addForm.id.trim())) { toastErr('Το ID πρέπει να είναι αριθμός'); return; }
    setBusy(true);
    try {
      await api.send('POST', '/api/spacegroups', addForm);
      toast('Δημιουργήθηκε το ' + addForm.id);
      setAddOpen(false);
      setAddForm({ id: '', name: '', category: '' });
      reload();
    } catch (e) { toastErr(e.message); }
    setBusy(false);
  }

  const colCount = 1 + SPACEGROUP_ADMIN_COLS.filter(([f]) => !isHidden(f)).length + 1; // +1 id, +1 Ενέργειες (πάντα ορατή)

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow">
          <label>Αναζήτηση</label>
          <input className="search" placeholder="id, όνομα, category…" value={q} onChange={(e) => setQ(e.target.value)} />
        </div>
        <button className="btn ghost sm" onClick={clearFilters} disabled={!anyFilterActive}>Καθαρισμός φίλτρων</button>
        <button type="button" className="btn ghost" onClick={() => setFiltersOpen(true)}>
          Φίλτρα{anyStructuredFilterActive ? <span className="cartbadge">•</span> : null}
        </button>
        <ColumnsToggleButton fields={SPACEGROUP_ADMIN_COLS} isHidden={isHidden} toggle={toggleColumn} resetToDefault={resetColumns} />
        {canManageRows ? (
          <button className="btn primary" onClick={() => setAddOpen(true)}>+ Νέα γραμμή</button>
        ) : null}
      </div>
      <div className="count" style={{ marginBottom: 8 }}>{rows ? total.toLocaleString('el') : '…'} spacegroups</div>

      {filtersOpen ? (
        <div className="drawer-backdrop" onClick={() => setFiltersOpen(false)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Φίλτρα</b>
              <button className="btn ghost sm" onClick={() => setFiltersOpen(false)} aria-label="Κλείσιμο">✕</button>
            </div>
            <div className="drawer-body">
              <MultiSelectFilterField label="ID" options={facetOptions('id')} value={filters.id}
                onChange={(v) => setFilter('id', v)} placeholder="Όλα" />
              {!isHidden('name') ? (
                <MultiSelectFilterField label="Space Group" options={facetOptions('name')} value={filters.name}
                  onChange={(v) => setFilter('name', v)} placeholder="Όλα" />
              ) : null}
              {!isHidden('division') ? (
                <MultiSelectFilterField label="Division" options={facetOptions('division')} value={filters.division}
                  onChange={(v) => setFilter('division', v)} placeholder="Όλα" />
              ) : null}
              {!isHidden('family') ? (
                <MultiSelectFilterField label="Family" options={facetOptions('family')} value={filters.family}
                  onChange={(v) => setFilter('family', v)} placeholder="Όλα" />
              ) : null}
              {!isHidden('category') ? (
                <MultiSelectFilterField label="Category" options={categoryOptions()} value={filters.category}
                  onChange={(v) => setFilter('category', v)} placeholder="Όλα" />
              ) : null}
              {!isHidden('spacegroupCode') ? (
                <MultiSelectFilterField label="Spacegroup ID" options={facetOptions('spacegroupCode')} value={filters.spacegroupCode}
                  onChange={(v) => setFilter('spacegroupCode', v)} placeholder="Όλα" />
              ) : null}
              {!isHidden('pmGr') ? (
                <div className="field">
                  <MultiSelectDropdown label="PM"
                    options={pmFacetOptions('pmGrId', pmGrOptions).map((p) => p.displayName)}
                    selected={new Set(pmIdsToNames(filters.pmGrId, pmGrOptions))}
                    placeholder="Όλοι"
                    onToggle={(name) => setFilter('pmGrId', toggleNameInIdList(filters.pmGrId, name, pmGrOptions))}
                  />
                </div>
              ) : null}
              {!isHidden('locked') ? (
                <div className="drawer-field">
                  <label>Locked</label>
                  <select value={filters.locked} onChange={(e) => setFilter('locked', e.target.value)}>
                    <option value="">Όλα</option>
                    <option value="true">Κλειδωμένα (οποιοδήποτε πεδίο)</option>
                    <option value="false">Ξεκλείδωτα (κανένα πεδίο)</option>
                  </select>
                </div>
              ) : null}
            </div>
          </div>
        </div>
      ) : null}

      <div className="tablewrap">
        <table>
          <thead>
            <tr>
              <SortableTh field="id" label="ID" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              {!isHidden('name') ? <SortableTh field="name" label="Space Group" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} /> : null}
              {!isHidden('division') ? <th>Division</th> : null}
              {!isHidden('family') ? <th>Family</th> : null}
              {!isHidden('category') ? <SortableTh field="category" label="Category" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} /> : null}
              {!isHidden('spacegroupCode') ? <SortableTh field="spacegroupCode" label="Spacegroup ID" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} /> : null}
              {!isHidden('pmGr') ? <th>PM</th> : null}
              {!isHidden('locked') ? <th>Locked</th> : null}
              <th className="sticky-actions">Ενέργειες</th>
            </tr>
          </thead>
          <tbody>
            {!rows ? (
              <tr><td colSpan={colCount} className="empty-cell">Φόρτωση…</td></tr>
            ) : rows.length === 0 ? (
              <tr><td colSpan={colCount} className="empty-cell">Καμία γραμμή</td></tr>
            ) : rows.map((sg) => (
              <tr key={sg.id}>
                <td className="code">
                  {canManageRows && editingId === sg.id ? (
                    <input autoFocus inputMode="numeric" value={editingIdValue} onChange={(e) => setEditingIdValue(e.target.value)}
                      onBlur={() => commitEditId(sg)}
                      onKeyDown={(e) => { if (e.key === 'Enter') commitEditId(sg); if (e.key === 'Escape') setEditingId(null); }} />
                  ) : (
                    <span onClick={() => canManageRows && startEditId(sg)} style={canManageRows ? { cursor: 'pointer', borderBottom: '1px dashed var(--line)' } : undefined}>
                      {sg.id}
                    </span>
                  )}
                </td>
                {!isHidden('name') ? <td>{editableCell(sg, 'name')}</td> : null}
                {!isHidden('division') ? <td>{editableCell(sg, 'division')}</td> : null}
                {!isHidden('family') ? <td>{editableCell(sg, 'family')}</td> : null}
                {!isHidden('category') ? (
                  <td>
                    {canManageRows ? (
                      <select value={sg.category || ''} onChange={(e) => assign(sg, 'category', e.target.value)}>
                        <option value="">—</option>
                        {categoryOptions().map((v) => <option key={v} value={v}>{v}</option>)}
                      </select>
                    ) : (sg.category || '—')}
                  </td>
                ) : null}
                {!isHidden('spacegroupCode') ? <td>{editableCell(sg, 'spacegroupCode')}</td> : null}
                {!isHidden('pmGr') ? (
                  <td>
                    {canAssignOwnership ? (
                      <select value={(sg.pmGr && sg.pmGr.id) || ''} onChange={(e) => assign(sg, 'pmGrId', e.target.value)}>
                        <option value="">— κανείς —</option>
                        {pmGrOptions.map((p) => <option key={p.id} value={p.id}>{pmmOptionLabel(p)}</option>)}
                      </select>
                    ) : (sg.pmGr ? <UserNameLink user={sg.pmGr} label={pmmOptionLabel(sg.pmGr)} /> : '—')}
                  </td>
                ) : null}
                {!isHidden('locked') ? (
                  <td>
                    {(() => {
                      const abbrs = lockedFieldsSummary(sg);
                      return abbrs.length
                        ? <span className="pill no" title={abbrs.join('/')}>Locked {abbrs.join('/')}</span>
                        : <span className="pill ok">Ανοιχτό</span>;
                    })()}
                  </td>
                ) : null}
                <td className="sticky-actions actions">
                  {canEditDisplayLock || canEditFeedLock ? (
                    <button className="btn ghost sm" title="Κλείδωμα πεδίων"
                      onClick={() => openLockModal(sg)}>
                      <Icon name={lockedFieldsSummary(sg).length ? 'lock' : 'unlock'} />
                    </button>
                  ) : null}
                  <button className="btn ghost sm" title="Κανόνες cluster ανά ομάδα καταστήματος (tab Clusters ανά Store Tier)"
                    onClick={() => { location.hash = '#/cluster-tiers?sg=' + encodeURIComponent(sg.id); }}>
                    <Icon name="layers" />
                  </button>
                  {canManageRows ? (
                    <button className="btn ghost sm danger" title="Διαγραφή" onClick={() => removeSpacegroup(sg)}>✕</button>
                  ) : null}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <Pager page={page} total={total} perPage={perPage} pageCount={pageCount} onPageChange={setPage} />

      {addOpen ? (
        <div className="drawer-backdrop" onClick={() => setAddOpen(false)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Νέο spacegroup</b>
              <button className="btn ghost sm" onClick={() => setAddOpen(false)}>✕</button>
            </div>
            <form className="drawer-body" onSubmit={submitAdd}>
              <div className="drawer-field">
                <label>ID</label>
                <input autoFocus inputMode="numeric" value={addForm.id} onChange={(e) => setAddForm({ ...addForm, id: e.target.value })} />
              </div>
              <div className="drawer-field">
                <label>Space Group (όνομα)</label>
                <input value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })} />
              </div>
              <div className="drawer-field">
                <label>Category</label>
                <select value={addForm.category} onChange={(e) => setAddForm({ ...addForm, category: e.target.value })}>
                  <option value="">—</option>
                  {categoryOptions().map((v) => <option key={v} value={v}>{v}</option>)}
                </select>
              </div>
            </form>
            <div className="drawer-foot">
              <button className="btn primary" disabled={busy || !addForm.id || !addForm.name} onClick={submitAdd}>
                {busy ? 'Δημιουργία…' : 'Δημιουργία'}
              </button>
              <button className="btn ghost" onClick={() => setAddOpen(false)}>Άκυρο</button>
            </div>
          </div>
        </div>
      ) : null}

      {lockFor ? (
        <div className="drawer-backdrop" onClick={() => setLockFor(null)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Κλείδωμα · {lockFor.name}</b>
              <button className="btn ghost sm" onClick={() => setLockFor(null)}>✕</button>
            </div>
            <div className="drawer-body">
              <p className="muted">
                Κλειδωμένο πεδίο = κανένας εμπορικός δεν μπορεί να προτείνει αλλαγή σε αυτό,
                ξεχωριστά ανά πεδίο.
              </p>
              <label className="picker-check">
                <input type="checkbox" disabled={!canEditDisplayLock}
                  checked={lockDraft.blockLiveEdit}
                  onChange={(e) => setLockDraft((d) => ({ ...d, blockLiveEdit: e.target.checked }))} />
                <span className="chk-box"><Icon name="check" size={11} /></span>
                <span>Κλείδωμα Live {!canEditDisplayLock ? <span className="muted">(δεν έχεις δικαίωμα)</span> : ''}</span>
              </label>
              <label className="picker-check">
                <input type="checkbox" disabled={!canEditDisplayLock}
                  checked={lockDraft.blockOpenEdit}
                  onChange={(e) => setLockDraft((d) => ({ ...d, blockOpenEdit: e.target.checked }))} />
                <span className="chk-box"><Icon name="check" size={11} /></span>
                <span>Κλείδωμα Open {!canEditDisplayLock ? <span className="muted">(δεν έχεις δικαίωμα)</span> : ''}</span>
              </label>
              <label className="picker-check">
                <input type="checkbox" disabled={!canEditDisplayLock}
                  checked={lockDraft.blockBoxMovingEdit}
                  onChange={(e) => setLockDraft((d) => ({ ...d, blockBoxMovingEdit: e.target.checked }))} />
                <span className="chk-box"><Icon name="check" size={11} /></span>
                <span>Κλείδωμα Box Moving {!canEditDisplayLock ? <span className="muted">(δεν έχεις δικαίωμα)</span> : ''}</span>
              </label>
              <label className="picker-check">
                <input type="checkbox" disabled={!canEditDisplayLock}
                  checked={lockDraft.blockNonDisplayEdit}
                  onChange={(e) => setLockDraft((d) => ({ ...d, blockNonDisplayEdit: e.target.checked }))} />
                <span className="chk-box"><Icon name="check" size={11} /></span>
                <span>Κλείδωμα Non-Display {!canEditDisplayLock ? <span className="muted">(δεν έχεις δικαίωμα)</span> : ''}</span>
              </label>
              <label className="picker-check">
                <input type="checkbox" disabled={!canEditFeedLock}
                  checked={lockDraft.blockFeedEdit}
                  onChange={(e) => setLockDraft((d) => ({ ...d, blockFeedEdit: e.target.checked }))} />
                <span className="chk-box"><Icon name="check" size={11} /></span>
                <span>Κλείδωμα Feed {!canEditFeedLock ? <span className="muted">(δεν έχεις δικαίωμα)</span> : ''}</span>
              </label>
            </div>
            <div className="drawer-foot">
              <button className="btn primary" disabled={lockBusy} onClick={submitLock}>
                {lockBusy ? 'Αποθήκευση…' : 'Αποθήκευση'}
              </button>
              <button className="btn ghost" onClick={() => setLockFor(null)}>Άκυρο</button>
            </div>
          </div>
        </div>
      ) : null}
    </div>
  );
}

/* ============================ Clusters ανά Store Tier (tier rules) ============================ */
// "Ποιοι cluster κωδικοί επιτρέπονται ανά ομάδα καταστήματος" (Orange/Orange+/White/White+) για
// ΕΝΑ spacegroup — reusable, ζητήθηκε ρητά ξεχωριστό tab (πριν ζούσε σαν modal μέσα στο
// SpacegroupsAdminPage). Ταξινομημένη λίστα ΑΝΟΙΧΤΟΥ μήκους ανά ομάδα (ΟΧΙ σταθερό min/mid/max
// grid — "4-5 cluster ανά ομάδα" να είναι δυνατό). Ο ίδιος κωδικός επιτρέπεται ελεύθερα σε
// πολλές ομάδες μαζί — μόνο ΜΕΣΑ σε μία ομάδα δεν έχει νόημα να επαναλαμβάνεται.
function ClusterTierEditor({ spacegroupId, editable, showInfo, onSaved }) {
  const [draft, setDraft] = useState({});
  const [clusterOptions, setClusterOptions] = useState([]);
  const [clusterInfoByCode, setClusterInfoByCode] = useState({});
  const [loading, setLoading] = useState(true);
  const [busy, setBusy] = useState(false);

  const load = useCallback(async () => {
    setLoading(true);
    try {
      const [{ tiers }, clustersRes] = await Promise.all([
        api.get('/api/spacegroups/' + encodeURIComponent(spacegroupId) + '/cluster-tiers'),
        api.get('/api/clusters' + qs({ spaceGroupId: spacegroupId, pageSize: 500 })),
      ]);
      const d = {};
      for (const g of STORE_GROUP_LABELS) d[g] = [];
      for (const t of tiers) (d[t.storeGroup] ??= []).push(t.clusterCode);
      setDraft(d);
      const rows = clustersRes.rows || [];
      setClusterOptions([...new Set(rows.map((r) => r.cluster).filter(Boolean))].sort());
      // faces+comment ανά κωδικό — ήδη μέσα στο ίδιο fetch (GET /api/clusters), καμία επιπλέον
      // κλήση χρειάζεται για το hover/inline info (ζητήθηκε ρητά "mousehover να φέρνει faces
      // ... και σχόλια").
      const infoByCode = {};
      for (const r of rows) {
        if (r.cluster) infoByCode[r.cluster] = { live: r.live, open: r.open, boxMoving: r.boxMoving, nonDisplay: r.nonDisplay, feed: r.feed, comment: r.comment };
      }
      setClusterInfoByCode(infoByCode);
    } catch (e) { toastErr(e.message); }
    setLoading(false);
  }, [spacegroupId]);
  useEffect(() => { load(); }, [load]);

  function addRow(g) { setDraft((d) => ({ ...d, [g]: [...(d[g] || []), ''] })); }
  function setRow(g, i, code) { setDraft((d) => ({ ...d, [g]: (d[g] || []).map((c, idx) => (idx === i ? code : c)) })); }
  function removeRow(g, i) { setDraft((d) => ({ ...d, [g]: (d[g] || []).filter((_, idx) => idx !== i) })); }

  async function submit() {
    const tiers = [];
    for (const g of STORE_GROUP_LABELS) {
      const codes = (draft[g] || []).filter(Boolean);
      if (new Set(codes).size !== codes.length) { toastErr(`Ο ίδιος κωδικός εμφανίζεται δύο φορές στην ομάδα "${g}"`); return; }
      (draft[g] || []).forEach((code, i) => { if (code) tiers.push({ storeGroup: g, tierOrder: i + 1, clusterCode: code }); });
    }
    setBusy(true);
    try {
      await api.send('PUT', '/api/spacegroups/' + encodeURIComponent(spacegroupId) + '/cluster-tiers', { tiers });
      toast('Ενημερώθηκαν οι κανόνες cluster');
      onSaved && onSaved();
    } catch (e) { toastErr(e.message); }
    setBusy(false);
  }

  if (loading) return <p className="muted">Φόρτωση…</p>;
  return (
    <div className="tier-editor">
      <p className="muted">
        Κενή ομάδα = χωρίς περιορισμό (όλοι οι κωδικοί του spacegroup επιτρέπονται εκεί, ίδια
        συμπεριφορά με σήμερα). Ο ίδιος κωδικός μπορεί ελεύθερα να συμμετέχει σε παραπάνω από 1
        ομάδα.
      </p>
      {STORE_GROUP_LABELS.map((g) => (
        <div key={g} className="tier-group">
          <div className="tier-group-head" style={{ color: STORE_GROUP_COLOR[g] }}>{g}</div>
          {(draft[g] || []).map((code, i) => {
            const dupe = (draft[g] || []).filter((c) => c && c === code).length > 1;
            const info = code ? clusterInfoByCode[code] : null;
            return (
              <div key={i} className={showInfo && info ? 'tier-row-wrap has-info' : 'tier-row-wrap'}>
                <div className="tier-row">
                  <select value={code} disabled={!editable} onChange={(e) => setRow(g, i, e.target.value)} className={dupe ? 'danger' : ''}>
                    <option value="">— επίλεξε cluster —</option>
                    {clusterOptions.map((c) => <option key={c} value={c}>{c}</option>)}
                  </select>
                  {!showInfo ? <ClusterFacesPopover info={info} /> : null}
                  {editable ? (
                    <button type="button" className="btn ghost sm icon-only" title="Αφαίρεση" onClick={() => removeRow(g, i)}>✕</button>
                  ) : null}
                </div>
                {showInfo ? <ClusterFacesInline info={info} /> : null}
              </div>
            );
          })}
          {editable ? (
            <button type="button" className="btn ghost sm" onClick={() => addRow(g)}>+ Προσθήκη κωδικού</button>
          ) : !(draft[g] || []).length ? (
            <div className="muted" style={{ fontSize: 12 }}>Χωρίς κανόνα (όλοι οι κωδικοί επιτρέπονται)</div>
          ) : null}
        </div>
      ))}
      {editable ? (
        <button className="btn primary" disabled={busy} onClick={submit}>{busy ? 'Αποθήκευση…' : 'Αποθήκευση'}</button>
      ) : null}
    </div>
  );
}

// Επιλογέας spacegroup με typeahead αναζήτηση (SG ID ή όνομα) — ξαναχρησιμοποιεί
// GET /api/spacegroups?q=... (ίδιο endpoint με το SpacegroupsAdminPage), δεν χρειάζεται νέο.
function SpacegroupPicker({ selected, onSelect }) {
  const [q, setQ] = useState('');
  const [qDebounced, setQDebounced] = useState('');
  const [results, setResults] = useState([]);
  const [open, setOpen] = useState(false);
  useEffect(() => { const t = setTimeout(() => setQDebounced(q), 250); return () => clearTimeout(t); }, [q]);
  useEffect(() => {
    if (!qDebounced.trim()) { setResults([]); return; }
    api.get('/api/spacegroups' + qs({ q: qDebounced, pageSize: 15 })).then((d) => setResults(d.rows || [])).catch(() => {});
  }, [qDebounced]);
  return (
    <div className="tier-sg-picker">
      <input placeholder="Αναζήτηση spacegroup (id ή όνομα)…" value={q}
        onChange={(e) => { setQ(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} />
      {open && results.length ? (
        <div className="tier-sg-picker-list">
          {results.map((sg) => (
            <div key={sg.id} className="tier-sg-picker-item"
              onClick={() => { onSelect(sg); setQ(''); setResults([]); setOpen(false); }}>
              <span className="chip" style={{ background: '#eef0f4', color: '#2a3242' }}>{sg.id}</span> {sg.name}
            </div>
          ))}
        </div>
      ) : null}
      {selected ? (
        <div className="tier-sg-picker-selected">
          Επιλεγμένο: <b>{selected.id} · {selected.name}</b>
          <button className="btn ghost sm" onClick={() => onSelect(null)}>Αλλαγή</button>
        </div>
      ) : null}
    </div>
  );
}

function ClusterTiersPage() {
  const { role } = useAuth();
  const canEdit = CAN_REVIEW_DISPLAY_REQUESTS(role);
  const viewPrefs = useViewPrefs('cluster-tiers');
  const [mode, setMode] = usePersistedField(viewPrefs, 'mode', 'bySpacegroup');
  // showInfo: "ένα toggle που να ανοιγοκλείνω την πληροφορία μόνιμα" — ζητήθηκε ρητά, persisted
  // ίδιο με το mode (θυμάται την προτίμηση cross-device). Off = μόνο hover (ClusterFacesPopover),
  // On = πάντα ορατό inline (ClusterFacesInline) — ίδιος διακόπτης και στα δύο mode.
  const [showInfo, setShowInfo] = usePersistedField(viewPrefs, 'showInfo', false);
  const [selectedSg, setSelectedSg] = useState(null);
  // Πολλαπλή επιλογή ομάδας καταστήματος με Ctrl/Cmd+click (ζητήθηκε ρητά "να τα βλέπω όλα μαζί
  // σε ξεχωριστές στήλες") — απλό click επιλέγει ΜΟΝΟ αυτή (αντικαθιστά), Ctrl/Cmd+click
  // προσθαφαιρεί από το ήδη υπάρχον σύνολο (ίδιο interaction convention με Finder/Photos multi-
  // select). Πάντα τουλάχιστον 1 επιλεγμένη — Ctrl/Cmd+click στην τελευταία δεν την αφαιρεί.
  const [selectedGroups, setSelectedGroups] = useState(() => new Set([STORE_GROUP_LABELS[0]]));
  function toggleGroup(g, e) {
    const multi = e.metaKey || e.ctrlKey;
    setSelectedGroups((cur) => {
      if (!multi) return new Set([g]);
      const next = new Set(cur);
      if (next.has(g)) { if (next.size > 1) next.delete(g); } else next.add(g);
      return next;
    });
  }
  const orderedSelectedGroups = STORE_GROUP_LABELS.filter((g) => selectedGroups.has(g));

  // Deep-link (#/cluster-tiers?sg=224, βλ. κουμπί στο SpacegroupsAdminPage) — φορτώνει ΜΙΑ
  // φορά στο mount το συγκεκριμένο spacegroup και μεταβαίνει σε mode "bySpacegroup". Ή
  // #/cluster-tiers?mode=violations (βλ. dashboard widget tierViolations) — πάει κατευθείαν
  // στην καρτέλα "Παραβιάσεις", χωρίς spacegroup lookup.
  useEffect(() => {
    const q = parseHashQuery();
    if (q.mode === 'violations') { setMode('violations'); return; }
    const sgId = q.sg;
    if (!sgId) return;
    api.get('/api/spacegroups' + qs({ id: sgId, pageSize: 1 })).then((d) => {
      if (d.rows && d.rows[0]) { setSelectedSg(d.rows[0]); setMode('bySpacegroup'); }
    }).catch(() => {});
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Mode "Ανά Ομάδα Καταστήματος" — bulk fetch: όλοι οι κανόνες ΚΑΙ faces/comment ανά κωδικό
  // (ελαφρύ, βλ. GET /api/cluster-tiers) + όλα τα spacegroups (ίδιο "μικρός/μεσαίος πίνακας ->
  // φέρε τα όλα μια φορά" convention με το PmmAdminPage) — client-side search πάνω σε ήδη
  // φορτωμένα δεδομένα.
  const [allTiers, setAllTiers] = useState(null);
  const [allSpacegroups, setAllSpacegroups] = useState(null);
  const [groupSearch, setGroupSearch] = useState('');
  useEffect(() => {
    if (mode !== 'byGroup' || allTiers) return;
    api.get('/api/cluster-tiers').then(setAllTiers).catch((e) => toastErr(e.message));
    api.get('/api/spacegroups' + qs({ pageSize: 500, sortBy: 'name' })).then((d) => setAllSpacegroups(d.rows || [])).catch((e) => toastErr(e.message));
  }, [mode, allTiers]);

  const groupRows = useMemo(() => {
    if (!allSpacegroups) return [];
    const nq = groupSearch.trim().toLowerCase();
    const bySpacegroup = (allTiers && allTiers.bySpacegroup) || {};
    const clusterInfo = (allTiers && allTiers.clusterInfo) || {};
    return allSpacegroups
      .filter((sg) => !nq || String(sg.id).includes(nq) || (sg.name || '').toLowerCase().includes(nq))
      .map((sg) => ({
        sg,
        // codesByGroup: μία στήλη ανά επιλεγμένη ομάδα (βλ. orderedSelectedGroups) — infoByCode
        // είναι κοινό (ίδιο cluster μπορεί να εμφανίζεται σε >1 ομάδα, ίδια faces/comment).
        codesByGroup: Object.fromEntries(orderedSelectedGroups.map((g) => [g, (bySpacegroup[sg.id] && bySpacegroup[sg.id][g]) || []])),
        infoByCode: clusterInfo[sg.id] || {},
      }));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allSpacegroups, allTiers, selectedGroups, groupSearch]);

  function jumpToSpacegroup(sg) {
    setSelectedSg(sg);
    setMode('bySpacegroup');
  }

  // Mode "Παραβιάσεις" — καταστήματα με ήδη ανατεθειμένο κωδικό εκτός του δηλωμένου κανόνα tier
  // τους (rules δεν εφαρμόζονται retroactively, βλ. GET /api/cluster-tiers/violations). Δικό
  // του, ξεχωριστό fetch (όχι μέρος του byGroup fetch) ώστε το byGroup mode να μην πληρώνει το
  // κόστος ενός fetchAll πάνω σε store_cluster_assignments (~19k γραμμές) κάθε φορά που ανοίγει.
  const [violations, setViolations] = useState(null);
  const [violationSearch, setViolationSearch] = useState('');
  useEffect(() => {
    if (mode !== 'violations' || violations) return;
    api.get('/api/cluster-tiers/violations').then((d) => setViolations(d.violations || [])).catch((e) => toastErr(e.message));
  }, [mode, violations]);
  const filteredViolations = useMemo(() => {
    if (!violations) return [];
    const nq = violationSearch.trim().toLowerCase();
    if (!nq) return violations;
    return violations.filter((v) =>
      v.storeCode.toLowerCase().includes(nq) || (v.storeName || '').toLowerCase().includes(nq) ||
      (v.spacegroupName || '').toLowerCase().includes(nq) || String(v.spacegroupId).includes(nq));
  }, [violations, violationSearch]);

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow"><b>Clusters ανά Store Tier</b></div>
        <label className="picker-check" style={{ marginRight: 8 }}>
          <input type="checkbox" checked={showInfo} onChange={(e) => setShowInfo(e.target.checked)} />
          <span className="chk-box"><Icon name="check" size={11} /></span>
          <span>Εμφάνιση faces &amp; σχολίων</span>
        </label>
        <div className="seg">
          <button type="button" className={'seg-btn' + (mode === 'bySpacegroup' ? ' active' : '')} onClick={() => setMode('bySpacegroup')}>Ανά Spacegroup</button>
          <button type="button" className={'seg-btn' + (mode === 'byGroup' ? ' active' : '')} onClick={() => setMode('byGroup')}>Ανά Ομάδα Καταστήματος</button>
          <button type="button" className={'seg-btn' + (mode === 'violations' ? ' active' : '')} onClick={() => setMode('violations')}>Παραβιάσεις</button>
        </div>
      </div>
      <p className="muted" style={{ margin: '0 0 16px' }}>
        Ορίζει, ανά spacegroup και ανά ομάδα καταστήματος (Orange/Orange+/White/White+), ποιοι
        cluster κωδικοί επιτρέπονται στο matrix "Cluster ανά κατάστημα" — spacegroup χωρίς
        κανόνες συνεχίζει να δουλεύει όπως πριν (κανένας περιορισμός). Πέρασε το ποντίκι πάνω
        από το <Icon name="info" size={11} /> δίπλα σε έναν κωδικό (ή ενεργοποίησε "Εμφάνιση
        faces &amp; σχολίων" παραπάνω) για να δεις τα faces/feed και το comment του.
        {mode === 'byGroup' ? ' Κράτα Ctrl (ή Cmd σε Mac) και κάνε click σε παραπάνω από 1 ομάδα καταστήματος για να τις δεις μαζί, σε ξεχωριστές στήλες.' : ''}
        {!canEdit ? ' Μόνο προβολή — η επεξεργασία επιτρέπεται σε moderator/space_manager/admin/super_admin.' : ''}
      </p>

      {mode === 'bySpacegroup' ? (
        <div className="tablewrap" style={{ padding: 16, overflow: 'visible', maxHeight: 'none' }}>
          <SpacegroupPicker selected={selectedSg} onSelect={setSelectedSg} />
          {selectedSg ? (
            <div style={{ marginTop: 16 }}>
              <ClusterTierEditor spacegroupId={selectedSg.id} editable={canEdit} showInfo={showInfo} />
            </div>
          ) : <p className="muted">Διάλεξε ένα spacegroup παραπάνω.</p>}
        </div>
      ) : mode === 'violations' ? (
        <div className="tablewrap" style={{ padding: 16, overflow: 'visible', maxHeight: 'none' }}>
          <input placeholder="Αναζήτηση κατάστημα ή spacegroup…" value={violationSearch}
            onChange={(e) => setViolationSearch(e.target.value)} />
          {!violations ? <p className="muted" style={{ marginTop: 12 }}>Φόρτωση…</p> : !violations.length ? (
            <p className="muted" style={{ marginTop: 12 }}>Καμία παραβίαση εντοπίστηκε.</p>
          ) : (
            <div className="import-errors" style={{ marginTop: 12 }}>
              <b>⚠ {filteredViolations.length} παραβίαση{filteredViolations.length === 1 ? '' : 'εις'}</b> — καταστήματα με ήδη ανατεθειμένο κωδικό εκτός του δηλωμένου κανόνα tier τους.
              <div className="tablewrap" style={{ marginTop: 8, maxHeight: 420, overflow: 'auto' }}>
                <table>
                  <thead>
                    <tr><th>Spacegroup</th><th>Κατάστημα</th><th>Ομάδα</th><th>Τρέχων κωδικός</th><th>Επιτρεπτοί κωδικοί</th><th></th></tr>
                  </thead>
                  <tbody>
                    {filteredViolations.map((v) => (
                      <tr key={v.spacegroupId + '|' + v.storeCode}>
                        <td>{v.spacegroupId}{v.spacegroupName ? ' · ' + v.spacegroupName : ''}</td>
                        <td>{v.storeCode}{v.storeName ? ' · ' + v.storeName : ''}</td>
                        <td style={{ color: STORE_GROUP_COLOR[v.storeGroup] }}>{v.storeGroup}</td>
                        <td className="sqft-mismatch">{v.clusterCode}</td>
                        <td>{v.allowedCodes.join(', ')}</td>
                        <td className="actions">
                          <button className="btn ghost sm" onClick={() => { location.hash = '#/stores?sg=' + encodeURIComponent(v.spacegroupId); }}>Διόρθωση</button>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}
        </div>
      ) : (
        <div className="tablewrap" style={{ padding: 16, overflow: 'visible', maxHeight: 'none' }}>
          <div className="tier-group-tabs">
            {STORE_GROUP_LABELS.map((g) => (
              <button key={g} type="button" className={'tier-group-tab' + (selectedGroups.has(g) ? ' active' : '')}
                style={selectedGroups.has(g) ? { borderColor: STORE_GROUP_COLOR[g], color: STORE_GROUP_COLOR[g] } : undefined}
                onClick={(e) => toggleGroup(g, e)} title="Ctrl/Cmd+click για πολλαπλή επιλογή">{g}</button>
            ))}
          </div>
          <input style={{ margin: '12px 0' }} placeholder="Αναζήτηση spacegroup (id ή όνομα)…"
            value={groupSearch} onChange={(e) => setGroupSearch(e.target.value)} />
          {!allSpacegroups ? <p className="muted">Φόρτωση…</p> : (
            <table>
              <thead>
                <tr>
                  <th>SG ID</th><th>Space Group</th>
                  {orderedSelectedGroups.map((g) => (
                    <th key={g} className="cluster-tier-col" style={{ color: STORE_GROUP_COLOR[g] }}>Κωδικοί ({g})</th>
                  ))}
                  <th></th>
                </tr>
              </thead>
              <tbody>
                {groupRows.map(({ sg, codesByGroup, infoByCode }) => (
                  <tr key={sg.id}>
                    <td>{sg.id}</td>
                    <td>{sg.name}</td>
                    {orderedSelectedGroups.map((g) => {
                      const codes = codesByGroup[g] || [];
                      return (
                        <td key={g} className="cluster-tier-col">
                          {codes.length ? codes.map((c) => (
                            <div key={c} className={'cluster-tier-pill' + (showInfo ? ' has-info' : '')}>
                              <span className="pill">{c}</span>
                              {!showInfo ? <ClusterFacesPopover info={infoByCode[c]} /> : null}
                              {showInfo ? <ClusterFacesInline info={infoByCode[c]} /> : null}
                            </div>
                          )) : <span className="muted">— κανένας κανόνας —</span>}
                        </td>
                      );
                    })}
                    <td className="actions">
                      <button className="btn ghost sm" onClick={() => jumpToSpacegroup(sg)}>{canEdit ? 'Επεξεργασία' : 'Προβολή'}</button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
      )}
    </div>
  );
}

/* ============================ Stores admin page ============================ */
// Λίστα + διαγραφή καταστημάτων — μικρός πίνακας (~66 σήμερα), καμία ανάγκη για
// server-side pagination/facets, ίδιο "μικρός πίνακας -> φέρε τα όλα" convention με
// PmmAdminPage. Η διαγραφή μπλοκάρεται server-side αν υπάρχουν ήδη matrix assignments/
// αιτήματα/drafts πάνω σε αυτό το κατάστημα (ίδιο convention με spacegroups delete) — καμία
// cascade, ρητά ζητήθηκε.
function StoresAdminPage() {
  const { role } = useAuth();
  const canManage = role === 'super_admin';
  const [stores, setStores] = useState(null);
  const [q, setQ] = useState('');
  const [busy, setBusy] = useState(false);
  const [addOpen, setAddOpen] = useState(false);
  const [addForm, setAddForm] = useState({ code: '', name: '' });
  const [expandedCode, setExpandedCode] = useState(null); // store.code | null — δείχνει τα στοιχεία (τετραγωνικά/διεύθυνση/Store Manager/SOM) από το JSON import

  const reload = useCallback(() => {
    api.get('/api/stores').then(setStores).catch((e) => toastErr(e.message));
  }, []);
  useEffect(() => { reload(); }, [reload]);

  const shown = useMemo(() => {
    if (!stores) return [];
    const nq = q.trim().toLowerCase();
    if (!nq) return stores;
    return stores.filter((s) => s.code.toLowerCase().includes(nq) || (s.name || '').toLowerCase().includes(nq));
  }, [stores, q]);

  async function setGroup(store, group) {
    try {
      const rows = [{ code: store.code, group: group || '' }];
      // Το store-groups import ΔΕΝ δέχεται κενό GROUP (πάντα απαιτεί μία από τις 4 τιμές) —
      // "αφαίρεση ομάδας" εδώ σημαίνει απευθείας PUT στο stores row, όχι μέσω του import
      // endpoint. Χρησιμοποιούμε το ίδιο apply endpoint μόνο όταν διαλέγεται πραγματική ομάδα.
      if (!group) { toastErr('Χρησιμοποίησε ένα import για να αφαιρέσεις ομάδα (ή διάλεξε άλλη ομάδα)'); return; }
      const { applied, results } = await api.send('POST', '/api/import/store-groups/apply', { rows });
      if (!applied) { toastErr((results[0] && results[0].errors[0]) || 'Αποτυχία'); return; }
      setStores((list) => list.map((s) => (s.code === store.code ? { ...s, storeGroup: group } : s)));
      toast('Ενημερώθηκε');
    } catch (e) { toastErr(e.message); }
  }

  // Χειροκίνητα συντηρούμενο πεδίο — καμία πηγή JSON δεν έχει "χώρα καταστήματος" σήμερα
  // (βλ. sql/archive/migrate_store_country.sql), άμεσο PUT αντί για import, ίδιο μοτίβο με το
  // group set παραπάνω αλλά χωρίς το "import πρώτα" constraint (πάντα έχει ήδη μία τιμή).
  async function setCountry(store, country) {
    try {
      await api.send('PUT', '/api/stores/' + encodeURIComponent(store.code) + '/country', { country });
      setStores((list) => list.map((s) => (s.code === store.code ? { ...s, country } : s)));
      toast('Ενημερώθηκε');
    } catch (e) { toastErr(e.message); }
  }

  // Πριν τη διαγραφή, δείχνει ΤΙ αναφέρεται ακόμα σε αυτό το κατάστημα (matrix κελιά/
  // αιτήματα/προσωρινές αλλαγές) — ζητήθηκε ρητά "να πετάει μήνυμα με τα ref που έχει
  // ενεργά και να επιλέγω εαν θέλω ή όχι να το διαγράψω καθολικά". Χωρίς references, ένα
  // απλό confirm αρκεί (τίποτα να χαθεί πέρα από το ίδιο το store row).
  async function removeStore(store) {
    setBusy(true);
    try {
      const refs = await api.get('/api/stores/' + encodeURIComponent(store.code) + '/references');
      const hasRefs = refs.assignments || refs.requests || refs.drafts;
      let cascade = false;
      if (hasRefs) {
        const parts = [];
        if (refs.assignments) parts.push(`${refs.assignments} κελιά matrix`);
        if (refs.requests) parts.push(`${refs.requests} αιτήματα (εκκρεμή + ιστορικό)`);
        if (refs.drafts) parts.push(`${refs.drafts} προσωρινές αλλαγές (Σύνολο Αλλαγών)`);
        cascade = confirm(
          `Το κατάστημα ${store.code} (${store.name}) αναφέρεται ακόμα σε:\n` +
          parts.map((p) => '· ' + p).join('\n') +
          `\n\nΟΛΑ αυτά θα διαγραφούν ΟΡΙΣΤΙΚΑ μαζί με το κατάστημα. Συνέχεια;`,
        );
        if (!cascade) { setBusy(false); return; }
      } else if (!confirm(`Διαγραφή καταστήματος ${store.code} (${store.name});`)) {
        setBusy(false); return;
      }
      await api.send('DELETE', '/api/stores/' + encodeURIComponent(store.code) + (cascade ? '?cascade=true' : ''));
      toast('Διαγράφηκε');
      setStores((list) => list.filter((s) => s.code !== store.code));
    } catch (e) { toastErr(e.message); }
    setBusy(false);
  }

  // Ξαναχρησιμοποιεί το ήδη-υπάρχον POST /api/import/stores/apply (ένα-γραμμής payload) —
  // ίδιο create/update path με το CSV import, όχι ξεχωριστό POST /api/stores endpoint (θα
  // ήταν διπλή λογική για την ίδια πράξη).
  async function addStore(e) {
    e.preventDefault();
    const code = addForm.code.trim(), name = addForm.name.trim();
    if (!code || !name) { toastErr('Χρειάζεται κωδικός και όνομα'); return; }
    setBusy(true);
    try {
      const { applied, results } = await api.send('POST', '/api/import/stores/apply', { rows: [{ code, name }] });
      if (!applied) { toastErr((results[0] && results[0].errors[0]) || 'Αποτυχία'); setBusy(false); return; }
      toast('Προστέθηκε');
      setAddOpen(false);
      setAddForm({ code: '', name: '' });
      reload();
    } catch (e2) { toastErr(e2.message); }
    setBusy(false);
  }

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow">
          <label>Αναζήτηση</label>
          <input className="search" placeholder="κωδικός ή όνομα καταστήματος…"
            value={q} onChange={(e) => setQ(e.target.value)} />
        </div>
        <button className="btn ghost sm" onClick={() => setQ('')} disabled={!q}>Καθαρισμός φίλτρων</button>
        {canManage ? (
          <button className="btn primary" onClick={() => setAddOpen(true)}>+ Νέο κατάστημα</button>
        ) : null}
      </div>
      {addOpen ? (
        <div className="drawer-backdrop" onClick={() => setAddOpen(false)}>
          <form className="drawer" onClick={(e) => e.stopPropagation()} onSubmit={addStore}>
            <div className="drawer-head">
              <b>Νέο κατάστημα</b>
              <button type="button" className="btn ghost sm" onClick={() => setAddOpen(false)}>✕</button>
            </div>
            <div className="drawer-body">
              <div className="drawer-field">
                <label>Κωδικός</label>
                <input autoFocus value={addForm.code} onChange={(e) => setAddForm((f) => ({ ...f, code: e.target.value }))} />
              </div>
              <div className="drawer-field">
                <label>Όνομα</label>
                <input value={addForm.name} onChange={(e) => setAddForm((f) => ({ ...f, name: e.target.value }))} />
              </div>
            </div>
            <div className="drawer-foot">
              <button type="submit" className="btn primary" disabled={busy}>Δημιουργία</button>
              <button type="button" className="btn ghost" onClick={() => setAddOpen(false)}>Άκυρο</button>
            </div>
          </form>
        </div>
      ) : null}

      <div className="count" style={{ marginBottom: 8 }}>
        {stores ? shown.length.toLocaleString('el') : '…'} καταστήματα
      </div>

      <div className="tablewrap">
        <table>
          <thead>
            <tr>
              <th>Κωδικός</th>
              <th>Όνομα</th>
              <th>Ομάδα</th>
              <th>Χώρα</th>
              <th>Στοιχεία</th>
              <th className="sticky-actions">Ενέργειες</th>
            </tr>
          </thead>
          <tbody>
            {!stores ? (
              <tr><td colSpan={6} className="empty-cell">Φόρτωση…</td></tr>
            ) : shown.length === 0 ? (
              <tr><td colSpan={6} className="empty-cell">Κανένα κατάστημα</td></tr>
            ) : shown.map((s) => (
              <React.Fragment key={s.code}>
                <tr>
                  <td className="code">{s.code}</td>
                  <td style={s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? { color: STORE_GROUP_COLOR[s.storeGroup] } : undefined}>{s.name}</td>
                  <td>
                    {canManage ? (
                      <select value={s.storeGroup || ''} onChange={(e) => setGroup(s, e.target.value)}>
                        <option value="">—</option>
                        {STORE_GROUP_LABELS.map((g) => <option key={g} value={g}>{g}</option>)}
                      </select>
                    ) : (
                      s.storeGroup ? (
                        <span className="chip" style={{ background: STORE_GROUP_COLOR[s.storeGroup], color: '#fff' }}>{s.storeGroup}</span>
                      ) : '—'
                    )}
                  </td>
                  <td>
                    {canManage ? (
                      <select value={s.country || 'GR'} onChange={(e) => setCountry(s, e.target.value)}>
                        <option value="GR">GR</option>
                        <option value="CY">CY</option>
                      </select>
                    ) : (s.country || 'GR')}
                  </td>
                  <td>
                    {s.netSpaceTotal != null || s.address ? (
                      <button type="button" className="info-btn" title="Στοιχεία καταστήματος" aria-label="Στοιχεία καταστήματος"
                        onClick={() => setExpandedCode((c) => (c === s.code ? null : s.code))}>
                        ⓘ
                      </button>
                    ) : <span style={{ color: 'var(--muted)' }}>—</span>}
                  </td>
                  <td className="sticky-actions actions">
                    {canManage ? (
                      <button className="btn ghost sm danger" disabled={busy} title="Διαγραφή" onClick={() => removeStore(s)}>✕</button>
                    ) : null}
                  </td>
                </tr>
                {expandedCode === s.code ? (
                  <tr>
                    <td colSpan={6}>
                      <div className="matrix-note" style={{ margin: '4px 0' }}>
                        {s.address ? <div><b>Διεύθυνση:</b> {s.address}{s.postalCode ? ` (ΤΚ ${s.postalCode})` : ''}{s.geoTag ? ` · ${s.geoTag}` : ''}</div> : null}
                        {s.netSpaceTotal != null ? <div><b>Net χώρος:</b> {formatSqm(s.netSpaceTotal)} m²{s.grossSpaceTotal != null ? ` (Gross ${formatSqm(s.grossSpaceTotal)} m²)` : ''}</div> : null}
                        {s.storeManagerName ? <div><b>Store Manager:</b> {s.storeManagerName}{s.storeManagerMobile ? ` · ${s.storeManagerMobile}` : ''}{s.storeManagerMail ? ` · ${s.storeManagerMail}` : ''}</div> : null}
                        {s.somName ? <div><b>SOM:</b> {s.somName}{s.somMobile ? ` · ${s.somMobile}` : ''}{s.somMail ? ` · ${s.somMail}` : ''}</div> : null}
                        {!s.address && s.netSpaceTotal == null && !s.storeManagerName && !s.somName ? 'Χωρίς εισαγμένα στοιχεία.' : null}
                      </div>
                    </td>
                  </tr>
                ) : null}
              </React.Fragment>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

/* ============================ Τετραγωνικά (sqft comparative list) ============================ */
// key -> [React-safe identifier, JSON floor key (αποθηκεύεται σε net_space_floors/
// gross_space_floors jsonb), ανθρώπινο όνομα σε κεφαλαία] — 10 όροφοι, -3 έως 6 (ζητήθηκε
// ρητά "μέχρι τον 6ο όροφο πάνω και -3 κάτω", ελληνικό κείμενο, capitalized) — πριν ήταν 7
// (GF/-1/M/1/2/3/4). Backend (sumFloors) είναι πλήρως agnostic σε αυτή τη λίστα — απλά αθροίζει
// ό,τι numeric keys υπάρχουν στο floors object, άρα το άθροισμα δουλεύει αυτόματα.
const SQFT_FLOORS = [
  ['B3', '-3', 'ΥΠΟΓΕΙΟ -3'],
  ['B2', '-2', 'ΥΠΟΓΕΙΟ -2'],
  ['B1', '-1', 'ΥΠΟΓΕΙΟ -1'],
  ['GF', '0', 'ΙΣΟΓΕΙΟ'],
  ['F1', '1', '1ΟΣ ΟΡΟΦΟΣ'],
  ['F2', '2', '2ΟΣ ΟΡΟΦΟΣ'],
  ['F3', '3', '3ΟΣ ΟΡΟΦΟΣ'],
  ['F4', '4', '4ΟΣ ΟΡΟΦΟΣ'],
  ['F5', '5', '5ΟΣ ΟΡΟΦΟΣ'],
  ['F6', '6', '6ΟΣ ΟΡΟΦΟΣ'],
];
const emptySqftFilters = () => ({ q: '', storeGroups: new Set(), netMin: '', netMax: '', grossMin: '', grossMax: '' });
const SQFT_SORT_OPTIONS = [
  ['netTotal', 'Σύνολο Net'],
  ['grossTotal', 'Σύνολο Gross'],
  ['code', 'Κατάστημα'],
  ['storeGroup', 'Ομάδα'],
];
const SQFT_STORE_PAGE_SIZE = 100;

// Τμήματα ΜΕΣΑ σε κάθε όροφο (ζητήθηκε ρητά, ίδια λίστα/σειρά με τον χρήστη) — key -> [React-
// safe identifier + JSON key στο store_details.department_space_floors, ανθρώπινο όνομα].
// ΣΚΟΠΙΜΑ σταθερή, ΠΑΝΤΑ πλήρης λίστα σε ΚΑΘΕ όροφο κάθε καταστήματος (ζητήθηκε ρητά "να μην
// ξεφύγει κάτι... κάθε όροφος να έχει όλα τα τμήματα") — βλ. SqftDepartmentsPerStoreView, ΔΕΝ
// φιλτράρεται ποτέ ανά κατάστημα/όροφο, μόνο οι ΤΙΜΕΣ είναι προαιρετικές (κενό = δεν υπάρχει
// αυτό το τμήμα σε αυτόν τον όροφο).
const DEPARTMENTS = [
  ['MDA', 'MDA'],
  ['FDA', 'FDA'],
  ['SDA', 'SDA'],
  ['STATIONERY', 'STATIONERY'],
  ['TOYS', 'TOYS'],
  ['GIFTING', 'GIFTING'],
  ['BOOKS', 'BOOKS'],
  ['MOBILE', 'MOBILE'],
  ['IT', 'IT'],
  ['TV_HOME_ENT', 'TV/HOME ENTERTAINMENT'],
  ['GAMING', 'GAMING'],
  ['CLIMA_SEASONAL', 'CLIMA/SEASONAL'],
  ['SERVICE', 'SERVICE'],
  ['CASHIERS', 'CASHIERS'],
  ['WAREHOUSE', 'ΑΠΟΘΗΚΗ'],
  ['OTHER_AREAS', 'ΛΟΙΠΟΙ ΧΩΡΟΙ'],
  ['STAFF_AREAS', 'ΧΩΡΟΙ ΠΡΟΣΩΠΙΚΟΥ'],
];

// Το καταχωρημένο Net m² ΔΕΝ περιλαμβάνει Αποθήκη/Χώρους Προσωπικού (ζητήθηκε ρητά, επιχειρησιακός
// κανόνας) — αυτά τα 2 τμήματα ΕΞΑΙΡΟΥΝΤΑΙ από κάθε άθροισμα/ποσοστό που συγκρίνεται με το Net
// (crosscheck ΚΑΙ ποσοστά στο Σύνοψη τμημάτων), παρόλο που παραμένουν κανονικά επεξεργάσιμα
// τμήματα (η ΤΙΜΗ τους καταχωρείται, απλά δεν "μετράει" προς το Net).
const SQFT_NET_EXCLUDED_DEPTS = new Set(['WAREHOUSE', 'STAFF_AREAS']);

function netComparableFloorSum(departmentSpaceFloors, floorKey) {
  const floorDepts = (departmentSpaceFloors && departmentSpaceFloors[floorKey]) || {};
  return DEPARTMENTS.reduce((sum, [dk]) => (SQFT_NET_EXCLUDED_DEPTS.has(dk) ? sum : sum + (Number(floorDepts[dk]) || 0)), 0);
}
function netComparableStoreSum(store) {
  const deptFloors = store.departmentSpaceFloors || {};
  return Object.keys(deptFloors).reduce((sum, floorKey) => sum + netComparableFloorSum(deptFloors, floorKey), 0);
}
// Gross = ΟΛΟΙ οι χώροι, καμία εξαίρεση (σε αντίθεση με το Net παραπάνω) — ζητήθηκε ρητά "net
// με net πρέπει να συμπέφτει, gross με gross πρέπει να συμπέφτει", πριν υπήρχε ΜΟΝΟ crosscheck
// για το Net, το Gross απλά δεν ελεγχόταν καθόλου.
function grossComparableFloorSum(departmentSpaceFloors, floorKey) {
  const floorDepts = (departmentSpaceFloors && departmentSpaceFloors[floorKey]) || {};
  return DEPARTMENTS.reduce((sum, [dk]) => sum + (Number(floorDepts[dk]) || 0), 0);
}
function grossComparableStoreSum(store) {
  const deptFloors = store.departmentSpaceFloors || {};
  return Object.keys(deptFloors).reduce((sum, floorKey) => sum + grossComparableFloorSum(deptFloors, floorKey), 0);
}

// Ελληνική μορφή αριθμού — κόμμα για δεκαδικά, τελεία για χιλιάδες (ζητήθηκε ρητά) —
// χρησιμοποιείται ΠΑΝΤΟΥ που δείχνουμε τετραγωνικά (read-only κελιά, ΣΥΝΟΛΟ, και το display
// state του SqftFloorCell). maximumFractionDigits:2 -> κόβει τυχόν floating-point ουρές
// (π.χ. 123.10000000000001) σε καθαρό "123,1".
function formatSqm(n) { return n.toLocaleString('el', { maximumFractionDigits: 2 }); }

// Επεξεργάσιμο κελί ορόφου (SqftPage, super_admin/space_manager/space_user — βλ.
// CAN_EDIT_SQFT) — τοπικό draft state ώστε το πληκτρολόγημα να μη στέλνει request ανά
// χαρακτήρα, commit μόνο σε blur/Enter, ίδιο "type freely, commit on blur" pattern με τα
// υπόλοιπα inline-edit inputs του αρχείου. useEffect συγχρονίζει το draft όταν η τιμή αλλάξει
// απ' έξω (π.χ. μετά από επιτυχές commit, το `value` prop ενημερώνεται από το optimistic
// setStores του γονιού).
// type="text" (ΟΧΙ number) — το native number input δέχεται ΜΟΝΟ τελεία ως δεκαδικό (καμία
// browser locale δεν επιτρέπει κόμμα εκεί), ζητήθηκε ρητά κόμμα. Δεχόμαστε ΚΑΙ τα δύο κατά
// την πληκτρολόγηση (','/'.' -> κόμμα μετατρέπεται σε τελεία πριν το Number()), και δείχνουμε
// την πλήρως μορφοποιημένη ελληνική τιμή (formatSqm) όταν το κελί ΔΕΝ είναι focused — σε focus
// γυρνάει στην ωμή τιμή (χωρίς χιλιάδες) για εύκολη επεξεργασία.
function SqftFloorCell({ value, onCommit }) {
  const [focused, setFocused] = useState(false);
  const [draft, setDraft] = useState('');
  const savedStr = value != null ? String(value) : '';
  const shown = focused ? draft : (value != null ? formatSqm(value) : '');
  return (
    <input type="text" inputMode="decimal" className="sqft-cell-input" value={shown}
      onFocus={() => { setDraft(savedStr); setFocused(true); }}
      onChange={(e) => setDraft(e.target.value)}
      onBlur={() => {
        setFocused(false);
        const normalized = draft.trim().replace(',', '.');
        if (normalized !== savedStr) onCommit(normalized);
      }}
      onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); else if (e.key === 'Escape') { setDraft(savedStr); e.currentTarget.blur(); } }} />
  );
}

// Τμήματα ΑΝΑ ΚΑΤΑΣΤΗΜΑ (2ο view μέσα στο Τετραγωνικά, ζητήθηκε ρητά "ανά εγκατάσταση
// ξεχωριστά") — διάλεξε ΕΝΑ κατάστημα, βλέπεις τμήματα (γραμμές, ΠΑΝΤΑ όλα τα 16 — βλ.
// DEPARTMENTS) × ορόφους ΠΟΥ ΕΧΕΙ αυτό το κατάστημα (στήλες, βλ. Επισκόπηση/net_space_floors).
// "ΣΥΝΟΛΟ ΤΜΗΜΑΤΩΝ" row + "Net m² καταχωρημένο" row από κάτω για σύγκριση — καθαρά οπτικό
// βοήθημα (ζητήθηκε έμμεσα "να μην ξεφύγει κάτι"), ΔΕΝ μπλοκάρει την καταχώρηση σε καμία
// περίπτωση, μόνο highlight όταν δεν ταιριάζουν (βλ. .sqft-mismatch).
// tolerance 0.01 -> απορροφά μόνο floating-point θόρυβο (π.χ. 0.1+0.2), ΟΧΙ πραγματική
// απόκλιση — ζητήθηκε ρητά "να είναι ΑΚΡΙΒΩΣ το ίδιο", άρα καμία μεγαλύτερη ανοχή.
const SQFT_MATCH_EPSILON = 0.01;

function SqftDepartmentsPerStoreView({ stores, setStores, canEditSqft }) {
  // Επιλεγμένο κατάστημα — per-account, server-side (users.view_prefs, tab 'sqftDepartments'),
  // ζητήθηκε ρητά "ό,τι κατάστημα επιλέξω να μένει στο φίλτρο σε επίπεδο λογαριασμού". Ίδιο
  // useViewPrefs/usePersistedField hook με κάθε άλλο persisted φίλτρο του αρχείου.
  const viewPrefs = useViewPrefs('sqftDepartments');
  const [storeCode, setStoreCode] = usePersistedField(viewPrefs, 'storeCode', '');
  useEffect(() => { if (!storeCode && stores.length) setStoreCode(stores[0].code); }, [stores, storeCode, setStoreCode]);
  const store = stores.find((s) => s.code === storeCode);

  async function setDeptCell(floorKey, deptKey, rawValue) {
    try {
      const { departmentSpaceFloors } = await api.send('PUT', `/api/stores/${encodeURIComponent(storeCode)}/departments`, { floorKey, department: deptKey, value: rawValue });
      setStores((list) => list.map((s) => (s.code === storeCode ? { ...s, departmentSpaceFloors } : s)));
    } catch (e) { toastErr(e.message); }
  }

  // Μόνο όροφοι που το κατάστημα ΕΧΕΙ (net ή gross ήδη καταχωρημένο στο Επισκόπηση view) —
  // δεν βγάζει νόημα να μοιράζεις τμήματα σε έναν όροφο που δεν υπάρχει καν.
  const floorRows = useMemo(() => {
    if (!store) return [];
    return [...SQFT_FLOORS].reverse().filter(([, floorKey]) => (store.netSpaceFloors || {})[floorKey] != null || (store.grossSpaceFloors || {})[floorKey] != null);
  }, [store]);
  const deptFloors = (store && store.departmentSpaceFloors) || {};
  const hasAnyDeptData = (floorKey) => Object.keys(deptFloors[floorKey] || {}).length > 0;

  // deptFloorSum: άθροισμα ΕΝΟΣ τμήματος σε όλους τους (ορατούς) ορόφους — δικό του ΣΥΝΟΛΟ
  // column, ΧΩΡΙΣ εξαίρεση (δείχνει την πραγματική τιμή του τμήματος, ό,τι κι αν είναι).
  // netFloorSum/netGrandSum: το άθροισμα που ΣΥΓΚΡΙΝΕΤΑΙ με το Net m² — εξαιρεί Αποθήκη/
  // Χώρους Προσωπικού (βλ. netComparableFloorSum, ζητήθηκε ρητά). grossFloorSum/grossGrandSum:
  // ΧΩΡΙΣ καμία εξαίρεση (Gross = ΟΛΟΙ οι χώροι, άρα ΟΛΑ τα τμήματα μετράνε) — ζητήθηκε ρητά
  // "net με net πρέπει να συμπέφτει, gross με gross πρέπει να συμπέφτει", πριν υπήρχε ΜΟΝΟ το
  // Net crosscheck, το Gross απλά δεν ελεγχόταν καθόλου.
  const deptFloorSum = (dk) => floorRows.reduce((sum, [, floorKey]) => sum + (Number((deptFloors[floorKey] || {})[dk]) || 0), 0);
  const netFloorSum = (floorKey) => netComparableFloorSum(deptFloors, floorKey);
  const netGrandSum = floorRows.reduce((sum, [, floorKey]) => sum + netFloorSum(floorKey), 0);
  const grossFloorSum = (floorKey) => grossComparableFloorSum(deptFloors, floorKey);
  const grossGrandSum = floorRows.reduce((sum, [, floorKey]) => sum + grossFloorSum(floorKey), 0);

  // Crosscheck: πόσοι όροφοι ΔΕΝ ταιριάζουν ακριβώς (ζητήθηκε ρητά "στόχος να είναι ΑΚΡΙΒΩΣ το
  // ίδιο") — ΜΟΝΟ όροφοι που έχουν ΚΑΠΟΙΑ δεδομένα τμημάτων ήδη (αλλιώς κάθε άθικτος όροφος θα
  // εμφανιζόταν σαν "απόκλιση" πριν καν ξεκινήσει η συμπλήρωση). Net/Gross ξεχωριστά, ένα
  // κατάστημα μπορεί να έχει μόνο το ένα από τα δύο εκτός συμφωνίας. Τροφοδοτεί ΚΑΙ το badge
  // στο toolbar ΚΑΙ το highlight στις γραμμές "Διαφορά" παρακάτω, ίδιος υπολογισμός παντού.
  const netMismatchedFloors = store ? floorRows.filter(([, floorKey]) => {
    const net = (store.netSpaceFloors || {})[floorKey];
    return hasAnyDeptData(floorKey) && net != null && Math.abs(net - netFloorSum(floorKey)) > SQFT_MATCH_EPSILON;
  }).length : 0;
  const grossMismatchedFloors = store ? floorRows.filter(([, floorKey]) => {
    const gross = (store.grossSpaceFloors || {})[floorKey];
    return hasAnyDeptData(floorKey) && gross != null && Math.abs(gross - grossFloorSum(floorKey)) > SQFT_MATCH_EPSILON;
  }).length : 0;
  const hasAnyDeptDataAtAll = Object.keys(deptFloors).length > 0;
  const totalNetMismatch = store && hasAnyDeptDataAtAll && store.netSpaceTotal != null && Math.abs(store.netSpaceTotal - netGrandSum) > SQFT_MATCH_EPSILON;
  const totalGrossMismatch = store && hasAnyDeptDataAtAll && store.grossSpaceTotal != null && Math.abs(store.grossSpaceTotal - grossGrandSum) > SQFT_MATCH_EPSILON;
  const anyMismatch = netMismatchedFloors || grossMismatchedFloors || totalNetMismatch || totalGrossMismatch;

  return (
    <>
      <div className="toolbar">
        <div className="field grow">
          <label>Κατάστημα</label>
          <select value={storeCode} onChange={(e) => setStoreCode(e.target.value)}>
            {stores.map((s) => <option key={s.code} value={s.code}>{s.code} · {s.name}</option>)}
          </select>
        </div>
        {store && floorRows.length && hasAnyDeptDataAtAll ? (
          anyMismatch ? (
            <span className="pill sqft-crosscheck-bad">
              ⚠ {[
                netMismatchedFloors || totalNetMismatch ? 'Net' : null,
                grossMismatchedFloors || totalGrossMismatch ? 'Gross' : null,
              ].filter(Boolean).join(' & ')} δεν ταιριάζει
            </span>
          ) : (
            <span className="pill sqft-crosscheck-ok">✓ Τμήματα = Net & Gross m²</span>
          )
        ) : null}
      </div>
      {!store || floorRows.length === 0 ? (
        <div className="empty">Αυτό το κατάστημα δεν έχει καταχωρημένους ορόφους ακόμα — συμπλήρωσε πρώτα Net/Gross m² στην Επισκόπηση.</div>
      ) : (
        <div className="tablewrap">
          <table className="matrix">
            <thead>
              <tr>
                <th className="storehdr">Τμήμα</th>
                {floorRows.map(([key, , floorLabel]) => <th key={key}>{floorLabel}</th>)}
                <th>ΣΥΝΟΛΟ</th>
              </tr>
            </thead>
            <tbody>
              {DEPARTMENTS.map(([dk, dLabel]) => (
                <tr key={dk}>
                  <td className="storecell">{dLabel}{SQFT_NET_EXCLUDED_DEPTS.has(dk) ? <span className="sqft-excluded-tag" title="Δεν προσμετράται στο Net m²"> (εκτός Net)</span> : null}</td>
                  {floorRows.map(([key, floorKey]) => {
                    const v = (deptFloors[floorKey] || {})[dk];
                    return (
                      <td key={key} className="num">
                        {canEditSqft ? (
                          <SqftFloorCell value={v} onCommit={(val) => setDeptCell(floorKey, dk, val)} />
                        ) : v != null ? formatSqm(v) : <span className="cellempty">—</span>}
                      </td>
                    );
                  })}
                  <td className="num">{deptFloorSum(dk) ? formatSqm(deptFloorSum(dk)) : <span className="cellempty">—</span>}</td>
                </tr>
              ))}
              <tr className="sqft-total-row">
                <td className="storecell">ΣΥΝΟΛΟ ΤΜΗΜΑΤΩΝ (Net)</td>
                {floorRows.map(([key, floorKey]) => (
                  <td key={key} className="num">{netFloorSum(floorKey) ? formatSqm(netFloorSum(floorKey)) : <span className="cellempty">—</span>}</td>
                ))}
                <td className="num">{formatSqm(netGrandSum)}</td>
              </tr>
              <tr className="sqft-compare-row">
                <td className="storecell">Net m² καταχωρημένο</td>
                {floorRows.map(([key, floorKey]) => {
                  const net = (store.netSpaceFloors || {})[floorKey];
                  return <td key={key} className="num">{net != null ? formatSqm(net) : <span className="cellempty">—</span>}</td>;
                })}
                <td className="num">{store.netSpaceTotal != null ? formatSqm(store.netSpaceTotal) : <span className="cellempty">—</span>}</td>
              </tr>
              <tr className="sqft-diff-row">
                <td className="storecell">Διαφορά (Net)</td>
                {floorRows.map(([key, floorKey]) => {
                  const net = (store.netSpaceFloors || {})[floorKey];
                  const diff = net != null ? net - netFloorSum(floorKey) : null;
                  const mismatch = hasAnyDeptData(floorKey) && diff != null && Math.abs(diff) > SQFT_MATCH_EPSILON;
                  return (
                    <td key={key} className={'num' + (mismatch ? ' sqft-mismatch' : '')}>
                      {diff != null ? (diff > 0 ? '+' : '') + formatSqm(diff) : <span className="cellempty">—</span>}
                    </td>
                  );
                })}
                <td className={'num' + (totalNetMismatch ? ' sqft-mismatch' : '')}>
                  {store.netSpaceTotal != null ? ((store.netSpaceTotal - netGrandSum) > 0 ? '+' : '') + formatSqm(store.netSpaceTotal - netGrandSum) : <span className="cellempty">—</span>}
                </td>
              </tr>
              <tr className="sqft-total-row">
                <td className="storecell">ΣΥΝΟΛΟ ΤΜΗΜΑΤΩΝ (Gross)</td>
                {floorRows.map(([key, floorKey]) => (
                  <td key={key} className="num">{grossFloorSum(floorKey) ? formatSqm(grossFloorSum(floorKey)) : <span className="cellempty">—</span>}</td>
                ))}
                <td className="num">{formatSqm(grossGrandSum)}</td>
              </tr>
              <tr className="sqft-compare-row">
                <td className="storecell">Gross m² καταχωρημένο</td>
                {floorRows.map(([key, floorKey]) => {
                  const gross = (store.grossSpaceFloors || {})[floorKey];
                  return <td key={key} className="num">{gross != null ? formatSqm(gross) : <span className="cellempty">—</span>}</td>;
                })}
                <td className="num">{store.grossSpaceTotal != null ? formatSqm(store.grossSpaceTotal) : <span className="cellempty">—</span>}</td>
              </tr>
              <tr className="sqft-diff-row">
                <td className="storecell">Διαφορά (Gross)</td>
                {floorRows.map(([key, floorKey]) => {
                  const gross = (store.grossSpaceFloors || {})[floorKey];
                  const diff = gross != null ? gross - grossFloorSum(floorKey) : null;
                  const mismatch = hasAnyDeptData(floorKey) && diff != null && Math.abs(diff) > SQFT_MATCH_EPSILON;
                  return (
                    <td key={key} className={'num' + (mismatch ? ' sqft-mismatch' : '')}>
                      {diff != null ? (diff > 0 ? '+' : '') + formatSqm(diff) : <span className="cellempty">—</span>}
                    </td>
                  );
                })}
                <td className={'num' + (totalGrossMismatch ? ' sqft-mismatch' : '')}>
                  {store.grossSpaceTotal != null ? ((store.grossSpaceTotal - grossGrandSum) > 0 ? '+' : '') + formatSqm(store.grossSpaceTotal - grossGrandSum) : <span className="cellempty">—</span>}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      )}
    </>
  );
}

// Σύνοψη τμημάτων (sub-view, "όλα τα συνοπτικά") — ΟΛΑ τα καταστήματα σε γραμμές, τμήματα σε
// στήλες, τιμή = άθροισμα του τμήματος σε ΟΛΟΥΣ τους ορόφους αυτού του καταστήματος.
// Read-only (η επεξεργασία γίνεται στο per-store view παραπάνω, εδώ είναι απλά bird's-eye
// view του δικτύου) — ίδιο tablewrap/matrix στυλ με το Επισκόπηση.
// Δίκτυο-wide crosscheck (ζητήθηκε ρητά "να μπορεί να γίνει crosscheck... στόχος να είναι
// ΑΚΡΙΒΩΣ το ίδιο") — 2 επιπλέον στήλες (Net m² καταχωρημένο + Κατάσταση) πάνω στην ίδια
// σύνοψη, ώστε να φαίνεται με μια ματιά ΠΟΙΑ καταστήματα δεν έχουν ακόμα ταιριαστό
// breakdown, χωρίς να χρειάζεται να τα ανοίξεις ένα-ένα στο per-store view.
const SQFT_SUMMARY_TOGGLE_COLS = [['storeGroup', 'Ομάδα καταστήματος'], ['country', 'Χώρα (GR/CY)']];

function SqftDepartmentsSummaryView({ stores }) {
  const [q, setQ] = useState('');
  // Ποσοστά (ζητήθηκε ρητά) — τι ποσοστό του Net m² του καταστήματος είναι το τμήμα. Αποθήκη/
  // Χώροι Προσωπικού δεν έχουν νόημα σαν "% του Net" (το Net εξ ορισμού δεν τα περιλαμβάνει) —
  // δείχνουν "—" σε αυτή τη λειτουργία, βλ. SQFT_NET_EXCLUDED_DEPTS.
  const [showPercent, setShowPercent] = useState(false);
  // Ομάδα καταστήματος/Χώρα — προαιρετικές στήλες, ίδιο "Στήλες" drawer + per-account
  // αποθήκευση (users.column_prefs) με κάθε άλλο πίνακα του app (ζητήθηκε ρητά "να μπορώ να
  // τις εμφανίζω/κλείνω... θα μένει η επιλογή μέσα στο account").
  const { isHidden, toggle, resetToDefault: resetColumns } = useColumnVisibility('sqftSummary');
  const [sortBy, setSortBy] = useState('code');
  const [sortDir, setSortDir] = useState('asc');
  function toggleSort(field) {
    if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    else { setSortBy(field); setSortDir('asc'); }
  }

  const rows = useMemo(() => {
    const nq = q.trim().toLowerCase();
    const filtered = nq ? stores.filter((s) => s.code.toLowerCase().includes(nq) || (s.name || '').toLowerCase().includes(nq)) : stores;
    const mapped = filtered.map((s) => {
      const deptFloors = s.departmentSpaceFloors || {};
      const totals = {};
      let grand = 0;
      for (const [dk] of DEPARTMENTS) {
        const sum = Object.keys(deptFloors).reduce((acc, floorKey) => acc + (Number((deptFloors[floorKey] || {})[dk]) || 0), 0);
        totals[dk] = sum;
        grand += sum;
      }
      // netComparable: εξαιρεί Αποθήκη/Χώρους Προσωπικού (συγκρίνεται με Net). grand: ΟΛΑ τα
      // τμήματα, καμία εξαίρεση (συγκρίνεται με Gross) — ζητήθηκε ρητά "net με net πρέπει να
      // συμπέφτει, gross με gross πρέπει να συμπέφτει", πριν το Gross απλά δεν ελεγχόταν.
      const netComparable = netComparableStoreSum(s);
      const hasDeptData = Object.keys(deptFloors).length > 0;
      const netMismatch = hasDeptData && s.netSpaceTotal != null && Math.abs(s.netSpaceTotal - netComparable) > SQFT_MATCH_EPSILON;
      const grossMismatch = hasDeptData && s.grossSpaceTotal != null && Math.abs(s.grossSpaceTotal - grand) > SQFT_MATCH_EPSILON;
      const mismatch = netMismatch || grossMismatch;
      return { store: s, totals, grand, netComparable, netMismatch, grossMismatch, mismatch };
    });
    // Sort: κωδικός/ομάδα/χώρα (strings, localeCompare) — τα υπόλοιπα πεδία (τμήματα/ΣΥΝΟΛΟ)
    // δεν έχουν sort ζητηθεί ρητά, μόνο η ταυτότητα του καταστήματος.
    const valueFor = (r) => (sortBy === 'storeGroup' ? (r.store.storeGroup || '') : sortBy === 'country' ? (r.store.country || 'GR') : r.store.code);
    mapped.sort((a, b) => {
      const cmp = String(valueFor(a)).localeCompare(String(valueFor(b)), 'el');
      return sortDir === 'asc' ? cmp : -cmp;
    });
    return mapped;
  }, [stores, q, sortBy, sortDir]);

  const colTotals = useMemo(() => {
    const t = {};
    let grand = 0;
    for (const [dk] of DEPARTMENTS) { t[dk] = rows.reduce((sum, r) => sum + r.totals[dk], 0); grand += t[dk]; }
    return { t, grand };
  }, [rows]);
  const mismatchCount = rows.filter((r) => r.mismatch).length;
  const extraCols = (isHidden('storeGroup') ? 0 : 1) + (isHidden('country') ? 0 : 1);

  function pct(value, total) {
    if (!total) return null;
    return (value / total) * 100;
  }

  return (
    <>
      <div className="toolbar">
        <div className="field grow">
          <label>Αναζήτηση</label>
          <input className="search" placeholder="κωδικός ή όνομα καταστήματος…" value={q} onChange={(e) => setQ(e.target.value)} />
        </div>
        <div className="viewswitch">
          <button type="button" className={'viewswitch-btn' + (!showPercent ? ' active' : '')} onClick={() => setShowPercent(false)}>m²</button>
          <button type="button" className={'viewswitch-btn' + (showPercent ? ' active' : '')} onClick={() => setShowPercent(true)}>% του Net</button>
        </div>
        <ColumnsToggleButton fields={SQFT_SUMMARY_TOGGLE_COLS} isHidden={isHidden} toggle={toggle} resetToDefault={resetColumns} label="Στήλες" />
        {rows.length ? (
          mismatchCount ? (
            <span className="pill sqft-crosscheck-bad">⚠ {mismatchCount} κατάστημα{mismatchCount === 1 ? '' : 'τα'} δεν ταιριάζουν</span>
          ) : (
            <span className="pill sqft-crosscheck-ok">✓ Όλα ταιριάζουν</span>
          )
        ) : null}
      </div>
      <div className="count" style={{ marginBottom: 8 }}>{rows.length.toLocaleString('el')} καταστήματα</div>
      <div className="tablewrap">
        <table className="matrix">
          <thead>
            <tr>
              <SortableTh field="code" label="Κατάστημα" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} className="storehdr" />
              {!isHidden('storeGroup') ? <SortableTh field="storeGroup" label="Ομάδα καταστήματος" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} /> : null}
              {!isHidden('country') ? <SortableTh field="country" label="Χώρα" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} /> : null}
              {DEPARTMENTS.map(([dk, dLabel]) => <th key={dk}>{dLabel}</th>)}
              <th>ΣΥΝΟΛΟ (Net)</th>
              <th>Net m² καταχωρημένο</th>
              <th>ΣΥΝΟΛΟ (Gross)</th>
              <th>Gross m² καταχωρημένο</th>
              <th>Κατάσταση</th>
            </tr>
          </thead>
          <tbody>
            {rows.length === 0 ? <tr><td colSpan={DEPARTMENTS.length + 6 + extraCols} className="empty-cell">Κανένα κατάστημα</td></tr> : rows.map(({ store, totals, grand, netComparable, netMismatch, grossMismatch, mismatch }) => (
              <tr key={store.code}>
                <td className="storecell">{store.code} · {store.name}</td>
                {!isHidden('storeGroup') ? (
                  <td>{store.storeGroup && STORE_GROUP_COLOR[store.storeGroup] ? <span className="chip" style={{ background: STORE_GROUP_COLOR[store.storeGroup], color: '#fff' }}>{store.storeGroup}</span> : <span className="cellempty">—</span>}</td>
                ) : null}
                {!isHidden('country') ? <td>{store.country || 'GR'}</td> : null}
                {DEPARTMENTS.map(([dk]) => {
                  if (showPercent) {
                    if (SQFT_NET_EXCLUDED_DEPTS.has(dk)) return <td key={dk} className="num"><span className="cellempty">—</span></td>;
                    const p = pct(totals[dk], store.netSpaceTotal);
                    return <td key={dk} className="num">{p ? p.toLocaleString('el', { maximumFractionDigits: 1 }) + '%' : <span className="cellempty">—</span>}</td>;
                  }
                  return <td key={dk} className="num">{totals[dk] ? formatSqm(totals[dk]) : <span className="cellempty">—</span>}</td>;
                })}
                <td className={'num' + (netMismatch ? ' sqft-mismatch' : '')}>
                  {showPercent
                    ? (pct(netComparable, store.netSpaceTotal) != null ? pct(netComparable, store.netSpaceTotal).toLocaleString('el', { maximumFractionDigits: 1 }) + '%' : <span className="cellempty">—</span>)
                    : (netComparable ? formatSqm(netComparable) : <span className="cellempty">—</span>)}
                </td>
                <td className={'num' + (netMismatch ? ' sqft-mismatch' : '')}>{store.netSpaceTotal != null ? formatSqm(store.netSpaceTotal) : <span className="cellempty">—</span>}</td>
                <td className={'num' + (grossMismatch ? ' sqft-mismatch' : '')}>{grand ? formatSqm(grand) : <span className="cellempty">—</span>}</td>
                <td className={'num' + (grossMismatch ? ' sqft-mismatch' : '')}>{store.grossSpaceTotal != null ? formatSqm(store.grossSpaceTotal) : <span className="cellempty">—</span>}</td>
                <td className="num">{store.netSpaceTotal == null ? <span className="cellempty">—</span> : mismatch ? <span className="sqft-status-bad">⚠</span> : <span className="sqft-status-ok">✓</span>}</td>
              </tr>
            ))}
            {rows.length ? (
              <tr className="sqft-total-row">
                <td className="storecell" colSpan={1 + extraCols}>ΣΥΝΟΛΟ ΔΙΚΤΥΟΥ</td>
                {DEPARTMENTS.map(([dk]) => (
                  <td key={dk} className="num">{colTotals.t[dk] ? formatSqm(colTotals.t[dk]) : <span className="cellempty">—</span>}</td>
                ))}
                <td className="num" colSpan={2}></td>
                <td className="num">{formatSqm(colTotals.grand)}</td>
                <td className="num" colSpan={2}></td>
              </tr>
            ) : null}
          </tbody>
        </table>
      </div>
    </>
  );
}

// Δομή "σαν spreadsheet" (ζητήθηκε ρητά, βλ. στιγμιότυπο): όροφοι = γραμμές (πάνω προς τα
// κάτω, 6ος όροφος έως Υπόγειο -3), καταστήματα = στήλες με Net/Gross δίπλα-δίπλα, ΣΥΝΟΛΟ
// σαν τελευταία γραμμή. Ίδιο "transposed matrix" pattern με το StoresPage (spacegroups σαν
// στήλες) — sticky πρώτη στήλη μέσω .storehdr/.storecell, colored header μέσω STORE_GROUP_COLOR.
// Έχει 3 views (βλ. viewswitch): Επισκόπηση (αυτό, default), Τμήματα ανά κατάστημα, Σύνοψη
// τμημάτων (βλ. SqftDepartmentsPerStoreView/SqftDepartmentsSummaryView παραπάνω) — ίδιο
// `stores` state μοιράζεται και στα 3, ζητήθηκε ρητά "στο ίδιο menu item", όχι ξεχωριστό tab.
function SqftPage() {
  const { role } = useAuth();
  const canEditSqft = CAN_EDIT_SQFT(role);
  const [stores, setStores] = useState(null);
  const [filters, setFilters] = useState(emptySqftFilters);
  const [sortBy, setSortBy] = useState('netTotal');
  const [sortDir, setSortDir] = useState('desc');
  const [storePage, setStorePage] = useState(0);
  const { isHidden, toggle, resetToDefault: resetColumns } = useColumnVisibility('sqft');
  // 3 views ΜΕΣΑ στο ίδιο "Τετραγωνικά" menu item (ζητήθηκε ρητά, όχι ξεχωριστό sidebar item) —
  // 'overview' (το ήδη υπάρχον matrix, default), 'perStore' (νέο, breakdown ανά τμήμα ανά
  // κατάστημα), 'summary' (νέο, sub-view με όλα συνοπτικά).
  const [view, setView] = useState('overview');

  // Άμεσο write ΕΝΟΣ κελιού ορόφου (βλ. PUT /api/stores/:code/sqft, server.js) — optimistic
  // update πάνω στο ήδη-φορτωμένο `stores` state με ΤΗΝ ΤΙΜΗ που γύρισε ο server (το πραγματικό
  // floors object + το ΞΑΝΑ-υπολογισμένο σύνολο), ΟΧΙ πλήρες re-fetch. Το ΣΥΝΟΛΟ row (και το
  // netSpaceTotal/grossSpaceTotal του toolbar/sort) ξαναϋπολογίζεται αυτόματα σε πραγματικό
  // χρόνο επειδή `shown`/`floorRows` παρακάτω είναι useMemo πάνω σε αυτό το ίδιο `stores` state
  // — ζητήθηκε ρητά "τα αυτόματα αθροίσματα... σε πραγματικό χρόνο χωρίς ανανέωση".
  async function setSqftCell(store, field, floorKey, rawValue) {
    try {
      const { floors, total } = await api.send('PUT', `/api/stores/${encodeURIComponent(store.code)}/sqft`, { field, floorKey, value: rawValue });
      setStores((list) => list.map((s) => (s.code === store.code ? {
        ...s,
        ...(field === 'net' ? { netSpaceFloors: floors, netSpaceTotal: total } : { grossSpaceFloors: floors, grossSpaceTotal: total }),
      } : s)));
    } catch (e) { toastErr(e.message); }
  }

  useEffect(() => {
    api.get('/api/stores').then(setStores).catch((e) => toastErr(e.message));
  }, []);

  function setFilter(field, value) { setFilters((f) => ({ ...f, [field]: value })); setStorePage(0); }
  function clearFilters() { setFilters(emptySqftFilters()); setStorePage(0); }
  const anyFilterActive = filters.q || filters.storeGroups.size || filters.netMin || filters.netMax || filters.grossMin || filters.grossMax;

  const shown = useMemo(() => {
    if (!stores) return [];
    let list = stores;
    const nq = filters.q.trim().toLowerCase();
    if (nq) list = list.filter((s) => s.code.toLowerCase().includes(nq) || (s.name || '').toLowerCase().includes(nq));
    if (filters.storeGroups.size) list = list.filter((s) => s.storeGroup && filters.storeGroups.has(s.storeGroup));
    // Range φίλτρα ΜΟΝΟ πάνω στα σύνολα (netTotal/grossTotal) — τα ανά-όροφο είναι πολύ
    // λεπτομερή για να βγάζει νόημα range filter.
    if (filters.netMin) list = list.filter((s) => s.netSpaceTotal != null && s.netSpaceTotal >= Number(filters.netMin));
    if (filters.netMax) list = list.filter((s) => s.netSpaceTotal != null && s.netSpaceTotal <= Number(filters.netMax));
    if (filters.grossMin) list = list.filter((s) => s.grossSpaceTotal != null && s.grossSpaceTotal >= Number(filters.grossMin));
    if (filters.grossMax) list = list.filter((s) => s.grossSpaceTotal != null && s.grossSpaceTotal <= Number(filters.grossMax));

    function valueFor(s, field) {
      if (field === 'code') return s.code;
      if (field === 'storeGroup') return s.storeGroup || '';
      if (field === 'netTotal') return s.netSpaceTotal;
      if (field === 'grossTotal') return s.grossSpaceTotal;
      return null;
    }
    const sorted = list.slice().sort((a, b) => {
      const av = valueFor(a, sortBy), bv = valueFor(b, sortBy);
      const an = av == null, bn = bv == null;
      if (an && bn) return 0;
      if (an) return 1; // κενές τιμές πάντα στο τέλος, ασχέτως κατεύθυνσης
      if (bn) return -1;
      const cmp = typeof av === 'string' ? String(av).localeCompare(String(bv)) : av - bv;
      return sortDir === 'asc' ? cmp : -cmp;
    });
    return sorted;
  }, [stores, filters, sortBy, sortDir]);

  // Ίδιο pagination pattern με τα καταστήματα-σαν-στήλες στο StoresPage matrix (βλ. εκεί) —
  // εκατοντάδες καταστήματα × 2 στήλες (Net/Gross) η καθεμία θα ήταν πολύ βαρύ σε ένα table.
  const storePageCount = Math.max(1, Math.ceil(shown.length / SQFT_STORE_PAGE_SIZE));
  const pagedStores = useMemo(
    () => shown.slice(storePage * SQFT_STORE_PAGE_SIZE, (storePage + 1) * SQFT_STORE_PAGE_SIZE),
    [shown, storePage],
  );

  // Σειρά γραμμών: 6ος όροφος πάνω έως Υπόγειο -3 κάτω (αντίστροφο του SQFT_FLOORS, που
  // πάει -3 -> 6 — χρησιμοποιείται αλλού, π.χ. στο import, με αυτή τη σειρά).
  const floorRows = useMemo(() => [...SQFT_FLOORS].reverse().filter(([key]) => !isHidden(key)), [isHidden]);
  const colCount = 1 + pagedStores.length * 2;

  return (
    <div className="page">
      <div className="viewswitch" role="tablist" aria-label="Προβολή τετραγωνικών">
        <button type="button" role="tab" aria-selected={view === 'overview'} className={'viewswitch-btn' + (view === 'overview' ? ' active' : '')} onClick={() => setView('overview')}>Επισκόπηση</button>
        <button type="button" role="tab" aria-selected={view === 'perStore'} className={'viewswitch-btn' + (view === 'perStore' ? ' active' : '')} onClick={() => setView('perStore')}>Τμήματα ανά κατάστημα</button>
        <button type="button" role="tab" aria-selected={view === 'summary'} className={'viewswitch-btn' + (view === 'summary' ? ' active' : '')} onClick={() => setView('summary')}>Σύνοψη τμημάτων</button>
      </div>

      {view === 'perStore' ? (
        stores ? <SqftDepartmentsPerStoreView stores={stores} setStores={setStores} canEditSqft={canEditSqft} /> : <div className="empty">Φόρτωση…</div>
      ) : view === 'summary' ? (
        stores ? <SqftDepartmentsSummaryView stores={stores} /> : <div className="empty">Φόρτωση…</div>
      ) : (
      <>
      <div className="toolbar">
        <div className="field grow">
          <label>Αναζήτηση</label>
          <input className="search" placeholder="κωδικός ή όνομα καταστήματος…"
            value={filters.q} onChange={(e) => setFilter('q', e.target.value)} />
        </div>
        <div className="field" style={{ minWidth: 180 }}>
          <MultiSelectDropdown label="Ομάδα καταστήματος" options={STORE_GROUP_LABELS} selected={filters.storeGroups}
            onToggle={(label) => setFilter('storeGroups', toggleInSet(filters.storeGroups, label))} placeholder="Όλες οι ομάδες" />
        </div>
        <div className="field">
          <label>Net m² (από-έως)</label>
          <div style={{ display: 'flex', gap: 4 }}>
            <input style={{ width: 70 }} type="number" value={filters.netMin} onChange={(e) => setFilter('netMin', e.target.value)} placeholder="από" />
            <input style={{ width: 70 }} type="number" value={filters.netMax} onChange={(e) => setFilter('netMax', e.target.value)} placeholder="έως" />
          </div>
        </div>
        <div className="field">
          <label>Gross m² (από-έως)</label>
          <div style={{ display: 'flex', gap: 4 }}>
            <input style={{ width: 70 }} type="number" value={filters.grossMin} onChange={(e) => setFilter('grossMin', e.target.value)} placeholder="από" />
            <input style={{ width: 70 }} type="number" value={filters.grossMax} onChange={(e) => setFilter('grossMax', e.target.value)} placeholder="έως" />
          </div>
        </div>
        <div className="field">
          <label>Ταξινόμηση στηλών</label>
          <div style={{ display: 'flex', gap: 4 }}>
            <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
              {SQFT_SORT_OPTIONS.map(([f, label]) => <option key={f} value={f}>{label}</option>)}
            </select>
            <button type="button" className="btn ghost sm" title="Αντιστροφή κατεύθυνσης"
              onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}>
              {sortDir === 'asc' ? '↑' : '↓'}
            </button>
          </div>
        </div>
        <div className="field">
          <label>&nbsp;</label>
          <button className="btn ghost sm" onClick={clearFilters} disabled={!anyFilterActive}>Καθαρισμός φίλτρων</button>
        </div>
        <div className="spacer" />
        <ColumnsToggleButton
          fields={SQFT_FLOORS.map(([key, , label]) => [key, label])}
          isHidden={isHidden} toggle={toggle} resetToDefault={resetColumns} label="Όροφοι" />
      </div>

      <div className="count" style={{ marginBottom: 8 }}>
        {stores ? shown.length.toLocaleString('el') : '…'} καταστήματα
        {storePageCount > 1 ? ` (στήλες ${storePage * SQFT_STORE_PAGE_SIZE + 1}–${Math.min((storePage + 1) * SQFT_STORE_PAGE_SIZE, shown.length)})` : ''}
      </div>
      {storePageCount > 1 ? (
        <div className="matrix-note" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <button className="btn ghost sm" disabled={storePage === 0} onClick={() => setStorePage((p) => Math.max(0, p - 1))}>← Προηγούμενα</button>
          <span>Σελίδα καταστημάτων {storePage + 1} / {storePageCount}</span>
          <button className="btn ghost sm" disabled={storePage >= storePageCount - 1} onClick={() => setStorePage((p) => Math.min(storePageCount - 1, p + 1))}>Επόμενα →</button>
        </div>
      ) : null}

      <div className="tablewrap">
        <table className="matrix">
          <thead>
            <tr>
              <th className="storehdr" rowSpan={2}>Όροφος</th>
              {pagedStores.map((s) => (
                <th key={s.code} colSpan={2}
                  style={s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? { borderTop: `4px solid ${STORE_GROUP_COLOR[s.storeGroup]}` } : undefined}>
                  <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
                    {/* Πάντα ρεντεράρεται (invisible όταν δεν υπάρχει group) — ίδιο "κράτα το
                        header alignment" trick με το StoresPage transposed matrix. */}
                    <span className="chip" style={{
                      background: s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? STORE_GROUP_COLOR[s.storeGroup] : 'transparent',
                      color: s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? '#fff' : 'transparent',
                      marginBottom: 2, visibility: s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? 'visible' : 'hidden',
                    }}>{s.storeGroup || 'X'}</span>
                    <span className="sgid">{s.code}</span>
                    <span className="sgname" style={s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? { color: STORE_GROUP_COLOR[s.storeGroup] } : undefined}>{s.name || '—'}</span>
                  </div>
                </th>
              ))}
            </tr>
            <tr>
              {pagedStores.map((s) => (
                <React.Fragment key={s.code}>
                  <th>Net</th>
                  <th>Gross</th>
                </React.Fragment>
              ))}
            </tr>
          </thead>
          <tbody>
            {!stores ? (
              <tr><td colSpan={colCount} className="empty-cell">Φόρτωση…</td></tr>
            ) : shown.length === 0 ? (
              <tr><td colSpan={colCount} className="empty-cell">Κανένα κατάστημα</td></tr>
            ) : (
              <>
                {floorRows.map(([key, floorKey, floorLabel]) => (
                  <tr key={key}>
                    <td className="storecell">{floorLabel}</td>
                    {pagedStores.map((s) => {
                      const net = (s.netSpaceFloors || {})[floorKey];
                      const gross = (s.grossSpaceFloors || {})[floorKey];
                      // Crosscheck flag (ζητήθηκε ρητά: "όταν έχουμε αποκλίσεις... και στα 2
                      // views να εμφανίζεται τι διαφορά προκύπτει") — ΜΟΝΟ όταν υπάρχουν ΗΔΗ
                      // δεδομένα τμημάτων για αυτόν τον όροφο, αλλιώς κάθε άθικτος όροφος θα
                      // έδειχνε "διαφορά" πριν καν ξεκινήσει η συμπλήρωση τμημάτων.
                      const floorDepts = (s.departmentSpaceFloors || {})[floorKey];
                      const hasDeptData = floorDepts && Object.keys(floorDepts).length > 0;
                      const netDiff = hasDeptData && net != null ? net - netComparableFloorSum(s.departmentSpaceFloors, floorKey) : null;
                      const netMismatch = netDiff != null && Math.abs(netDiff) > SQFT_MATCH_EPSILON;
                      const grossDiff = hasDeptData && gross != null ? gross - grossComparableFloorSum(s.departmentSpaceFloors, floorKey) : null;
                      const grossMismatch = grossDiff != null && Math.abs(grossDiff) > SQFT_MATCH_EPSILON;
                      return (
                        <React.Fragment key={s.code}>
                          <td className="num">
                            <span className="sqft-net-cell">
                              {canEditSqft ? (
                                <SqftFloorCell value={net} onCommit={(v) => setSqftCell(s, 'net', floorKey, v)} />
                              ) : net != null ? formatSqm(net) : <span className="cellempty">—</span>}
                              {netMismatch ? (
                                <span className="sqft-diff-flag" title={`Διαφορά (Net) ${netDiff > 0 ? '+' : ''}${formatSqm(netDiff)} m² από το άθροισμα τμημάτων (βλ. Τμήματα ανά κατάστημα)`}>
                                  {netDiff > 0 ? '+' : ''}{formatSqm(netDiff)}
                                </span>
                              ) : null}
                            </span>
                          </td>
                          <td className="num">
                            <span className="sqft-net-cell">
                              {canEditSqft ? (
                                <SqftFloorCell value={gross} onCommit={(v) => setSqftCell(s, 'gross', floorKey, v)} />
                              ) : gross != null ? formatSqm(gross) : <span className="cellempty">—</span>}
                              {grossMismatch ? (
                                <span className="sqft-diff-flag" title={`Διαφορά (Gross) ${grossDiff > 0 ? '+' : ''}${formatSqm(grossDiff)} m² από το άθροισμα τμημάτων (βλ. Τμήματα ανά κατάστημα)`}>
                                  {grossDiff > 0 ? '+' : ''}{formatSqm(grossDiff)}
                                </span>
                              ) : null}
                            </span>
                          </td>
                        </React.Fragment>
                      );
                    })}
                  </tr>
                ))}
                <tr className="sqft-total-row">
                  <td className="storecell">ΣΥΝΟΛΟ</td>
                  {pagedStores.map((s) => {
                    const hasDeptData = s.departmentSpaceFloors && Object.keys(s.departmentSpaceFloors).length > 0;
                    const netDiff = hasDeptData && s.netSpaceTotal != null ? s.netSpaceTotal - netComparableStoreSum(s) : null;
                    const netMismatch = netDiff != null && Math.abs(netDiff) > SQFT_MATCH_EPSILON;
                    const grossDiff = hasDeptData && s.grossSpaceTotal != null ? s.grossSpaceTotal - grossComparableStoreSum(s) : null;
                    const grossMismatch = grossDiff != null && Math.abs(grossDiff) > SQFT_MATCH_EPSILON;
                    return (
                    <React.Fragment key={s.code}>
                      <td className="num">
                        <span className="sqft-net-cell">
                          {s.netSpaceTotal != null ? formatSqm(s.netSpaceTotal) : <span className="cellempty">—</span>}
                          {netMismatch ? (
                            <span className="sqft-diff-flag" title={`Διαφορά (Net) ${netDiff > 0 ? '+' : ''}${formatSqm(netDiff)} m² από το άθροισμα τμημάτων (βλ. Τμήματα ανά κατάστημα)`}>
                              {netDiff > 0 ? '+' : ''}{formatSqm(netDiff)}
                            </span>
                          ) : null}
                        </span>
                      </td>
                      <td className="num">
                        <span className="sqft-net-cell">
                          {s.grossSpaceTotal != null ? formatSqm(s.grossSpaceTotal) : <span className="cellempty">—</span>}
                          {grossMismatch ? (
                            <span className="sqft-diff-flag" title={`Διαφορά (Gross) ${grossDiff > 0 ? '+' : ''}${formatSqm(grossDiff)} m² από το άθροισμα τμημάτων (βλ. Τμήματα ανά κατάστημα)`}>
                              {grossDiff > 0 ? '+' : ''}{formatSqm(grossDiff)}
                            </span>
                          ) : null}
                        </span>
                      </td>
                    </React.Fragment>
                    );
                  })}
                </tr>
              </>
            )}
          </tbody>
        </table>
      </div>
      </>
      )}
    </div>
  );
}

/* ============================ PMM roster page ============================ */
// 100% read-only — καμία ενέργεια/drawer από κανέναν ρόλο (ζητήθηκε ρητά). Rename/activate/
// role/active/delete ζουν πλέον αποκλειστικά στο #/users (βλ. UsersAdminPage) — αυτή η σελίδα
// είναι απλά ένα φιλτραρισμένο read-only view πάνω στα ίδια δεδομένα (role='pm'), χρήσιμο σε
// ρόλους (moderator) που δεν έχουν πρόσβαση στην πλήρη λίστα Users.
function PmmAdminPage() {
  const [pmm, setPmm] = useState(null);

  const reload = useCallback(() => {
    api.get('/api/pmm').then(setPmm).catch((e) => toastErr(e.message));
  }, []);
  useEffect(() => { reload(); }, [reload]);

  const [nameQ, setNameQ] = useState('');
  const [statusQ, setStatusQ] = useState(''); // '' | 'active' | 'inactive'
  const [sortBy, setSortBy] = useState('displayName');
  const [sortDir, setSortDir] = useState('asc');

  const filterPmm = useCallback((list, { skipStatus } = {}) => list.filter((p) => {
    if (nameQ && !p.displayName.toLowerCase().includes(nameQ.trim().toLowerCase())) return false;
    if (!skipStatus) {
      if (statusQ === 'active' && !p.active) return false;
      if (statusQ === 'inactive' && p.active) return false;
    }
    return true;
  }), [nameQ, statusQ]);

  // Cascading: ίδιο "εξαίρεσε τον εαυτό σου" convention με τα υπόλοιπα tabs.
  const statusOptions = useMemo(() => {
    const filtered = filterPmm(pmm || [], { skipStatus: true });
    const present = new Set(filtered.map((p) => (p.active ? 'active' : 'inactive')));
    if (statusQ) present.add(statusQ);
    return ['active', 'inactive'].filter((s) => present.has(s));
  }, [pmm, filterPmm, statusQ]);

  function toggleSort(field) {
    if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    else { setSortBy(field); setSortDir('asc'); }
  }
  const anyFilterActive = nameQ || statusQ;
  function clearFilters() { setNameQ(''); setStatusQ(''); }
  const [filtersOpen, setFiltersOpen] = useState(false);
  function sortPmm(list) {
    const dir = sortDir === 'desc' ? -1 : 1;
    return list.slice().sort((a, b) => {
      if (sortBy === 'spacegroupCount') return (a.spacegroupCount - b.spacegroupCount) * dir;
      if (sortBy === 'status') return ((a.active ? 1 : 0) - (b.active ? 1 : 0)) * dir;
      return String(a.displayName).localeCompare(String(b.displayName)) * dir;
    });
  }

  const rows = useMemo(() => sortPmm(filterPmm(pmm || [])), [pmm, filterPmm, sortBy, sortDir]);

  if (!pmm) return <div className="empty">Φόρτωση…</div>;

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow">
          <label>Αναζήτηση</label>
          <input className="search" placeholder="Display name…" value={nameQ} onChange={(e) => setNameQ(e.target.value)} />
        </div>
        <button className="btn ghost sm" onClick={clearFilters} disabled={!anyFilterActive}>Καθαρισμός φίλτρων</button>
        <div className="spacer" />
        <button type="button" className="btn ghost" onClick={() => setFiltersOpen(true)}>
          Φίλτρα{statusQ ? <span className="cartbadge">•</span> : null}
        </button>
      </div>

      {filtersOpen ? (
        <div className="drawer-backdrop" onClick={() => setFiltersOpen(false)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Φίλτρα</b>
              <button className="btn ghost sm" onClick={() => setFiltersOpen(false)} aria-label="Κλείσιμο">✕</button>
            </div>
            <div className="drawer-body">
              <div className="drawer-field">
                <label>Κατάσταση</label>
                <select value={statusQ} onChange={(e) => setStatusQ(e.target.value)}>
                  <option value="">Όλα</option>
                  {statusOptions.includes('active') ? <option value="active">Ενεργός</option> : null}
                  {statusOptions.includes('inactive') ? <option value="inactive">Ανενεργός</option> : null}
                </select>
              </div>
            </div>
          </div>
        </div>
      ) : null}

      <div className="tablewrap">
        <table>
          <thead>
            <tr>
              <SortableTh field="displayName" label="Display Name" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="status" label="Κατάσταση" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="spacegroupCount" label="Spacegroups" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
            </tr>
          </thead>
          <tbody>
            {rows.length === 0 ? (
              <tr><td colSpan={3} className="empty-cell">Καμία εγγραφή</td></tr>
            ) : rows.map((p) => (
              <tr key={p.id}>
                <td><UserNameLink user={p} /></td>
                <td>
                  {p.active ? <span className="pill ok">Ενεργός</span> : <span className="pill no">Ανενεργός</span>}
                </td>
                <td>{p.spacegroupCount}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

/* ============================ Import page ============================ */
// Quoted-comma CSV parser — χρειάζεται γιατί κάποια ελληνικά ονόματα spacegroup/PM περιέχουν
// κόμμα μέσα σε "..." στο πηγαίο αρχείο.
function parseCsvLine(line) {
  const out = [];
  let cur = '';
  let inQuotes = false;
  for (let i = 0; i < line.length; i++) {
    const c = line[i];
    if (inQuotes) {
      if (c === '"') {
        if (line[i + 1] === '"') { cur += '"'; i++; } else inQuotes = false;
      } else cur += c;
    } else if (c === '"') {
      inQuotes = true;
    } else if (c === ',') {
      out.push(cur); cur = '';
    } else {
      cur += c;
    }
  }
  out.push(cur);
  return out;
}

function parseCsv(text) {
  const lines = text.split(/\r?\n/).filter((l) => l.trim().length);
  if (!lines.length) return { header: [], rows: [] };
  const header = parseCsvLine(lines[0]).map((h) => h.trim());
  const rows = lines.slice(1).map((line) => parseCsvLine(line).map((c) => c.trim()));
  return { header, rows };
}

const IMPORT_ENTITIES = {
  stores: {
    label: 'Καταστήματα', endpoint: 'stores',
    // Καμία εξάρτηση σε άλλο πίνακα — πρέπει να τρέξει ΠΡΩΤΟ, πριν το Cluster ανά
    // κατάστημα import (που κάνει validate storeCode έναντι αυτού του πίνακα). GROUP
    // ενσωματωμένο εδώ (πριν ήταν ξεχωριστό "Ομάδα καταστημάτων" tab) — προαιρετικό: κενό σε
    // νέα γραμμή = καμία ομάδα, κενό σε ήδη υπάρχουσα γραμμή = άφησε την τρέχουσα ομάδα
    // ανέγγιχτη (ίδιο "κενό κελί = leave untouched" convention με τα υπόλοιπα CSV imports).
    // COUNTRY (ζητήθηκε ρητά, πριν χειροκίνητο SQL update μετά το import) — πρώτη στήλη
    // (ζητήθηκε ρητά "μπροστά μπροστά"): κενό σε νέα γραμμή = 'GR' (ίδιο default με τη στήλη
    // στη βάση), κενό σε ήδη υπάρχουσα γραμμή αφήνει την τρέχουσα χώρα ανέγγιχτη.
    // Τετραγωνικά (NET/GROSS ανά όροφο, βλ. SQFT_FLOORS) ενσωματώθηκαν εδώ αργότερα (ζητήθηκε
    // ρητά "να περνάνε από εκεί όλα") — προαιρετικά ανά όροφο (όχι κάθε κατάστημα έχει όλους),
    // κενό σε ήδη υπάρχουσα γραμμή αφήνει τον όροφο ανέγγιχτο. Το σύνολο υπολογίζεται αυτόματα.
    columns: ['COUNTRY', 'CODE', 'NAME', 'GROUP', ...SQFT_FLOORS.flatMap(([, , label]) => [label + ' NET', label + ' GROSS'])],
    example: ['GR', '7001', 'Πυλαία Cosmos', 'Orange', '', '', '', '', '', '', '120', '125', '340', '350', '', '', '', '', '', '', '', '', '', ''],
    legend: [
      ['COUNTRY', "προαιρετικό — GR ή CY (χωρίς διάκριση πεζών/κεφαλαίων)· κενό σε νέα γραμμή = 'GR', κενό σε ήδη υπάρχουσα γραμμή αφήνει την τρέχουσα χώρα ανέγγιχτη"],
      ['CODE', 'κωδικός καταστήματος (μοναδικό, primary key)'],
      ['NAME', 'όνομα καταστήματος'],
      ['GROUP', 'προαιρετικό — μία από: Orange, Orange+, White, White+ (χωρίς διάκριση πεζών/κεφαλαίων)· κενό σε ήδη υπάρχουσα γραμμή αφήνει την τρέχουσα ομάδα ανέγγιχτη'],
      ['<ΟΡΟΦΟΣ> NET / <ΟΡΟΦΟΣ> GROSS', `τετραγωνικά (m²) ανά όροφο, ξεχωριστές στήλες NET/GROSS — όροφοι: ${SQFT_FLOORS.map(([, , l]) => l).join(', ')}. Προαιρετικό ανά όροφο (όχι κάθε κατάστημα έχει όλους) — κενό σε νέα γραμμή σημαίνει "δεν υπάρχει αυτός ο όροφος", κενό σε ήδη υπάρχουσα γραμμή αφήνει τον όροφο ανέγγιχτο. Το σύνολο (NET/GROSS) υπολογίζεται αυτόματα, δεν εισάγεται.`],
    ],
    toRow: (c) => {
      const netSpaceFloors = {}, grossSpaceFloors = {};
      SQFT_FLOORS.forEach(([, floorKey], i) => {
        const net = c[4 + i * 2];
        const gross = c[4 + i * 2 + 1];
        if (net !== undefined && net !== '') netSpaceFloors[floorKey] = net;
        if (gross !== undefined && gross !== '') grossSpaceFloors[floorKey] = gross;
      });
      return { country: c[0] || '', code: c[1] || '', name: c[2] || '', group: c[3] || '', netSpaceFloors, grossSpaceFloors };
    },
    keyLabel: (r) => r.code,
    diffFields: [['name', 'Όνομα'], ['storeGroup', 'Ομάδα'], ['country', 'Χώρα'], ['netSpaceTotal', 'Σύνολο Net m²'], ['grossSpaceTotal', 'Σύνολο Gross m²']],
  },
  'store-details': {
    label: 'Στοιχεία καταστήματος', endpoint: 'store-details',
    // Πρέπει να τρέξει ΜΕΤΑ το Καταστήματα import — εμπλουτίζει ένα ήδη υπάρχον κατάστημα,
    // ΔΕΝ δημιουργεί νέο. CSV (ξανά — ήταν JSON, γύρισε πίσω σε CSV, ζητήθηκε ρητά). Τα
    // τετραγωνικά (NET/GROSS ανά όροφο) μπαίνουν πλέον ΑΠΟ ΤΟ Καταστήματα import (βλ.
    // SQFT_FLOORS εκεί) — εδώ μένουν μόνο τα υπόλοιπα στοιχεία (διεύθυνση/επικοινωνία),
    // ζητήθηκε ρητά "εκεί θα είναι μόνο τα υπόλοιπα στοιχεία". Κενό σε ήδη υπάρχουσα γραμμή
    // αφήνει το πεδίο ανέγγιχτο (ίδιο "leave untouched" convention με Καταστήματα/Spacegroups).
    columns: ['CODE', 'ADDRESS', 'ΤΚ', 'GEO TAG', 'STORE MANAGER NAME', 'STORE MANAGER MOBILE', 'STORE MANAGER MAIL', 'SOM NAME', 'SOM MOBILE', 'SOM MAIL'],
    example: ['7001', 'Λεωφ. Κηφισίας 1', '15123', '37.99,23.75', 'Γιάννης Παπαδόπουλος', '6912345678', 'giannis@example.com', 'Μαρία Ιωάννου', '6923456789', 'maria@example.com'],
    legend: [
      ['CODE', 'stores.code — πρέπει να υπάρχει ήδη (import πρώτα Καταστήματα)'],
      ['ADDRESS', 'διεύθυνση καταστήματος· κενό σε ήδη υπάρχουσα γραμμή αφήνει την τρέχουσα διεύθυνση ανέγγιχτη'],
      ['ΤΚ', 'ταχυδρομικός κώδικας· ίδιος κανόνας με το ADDRESS'],
      ['GEO TAG', 'γεωγραφικές συντεταγμένες (ελεύθερο κείμενο)· ίδιος κανόνας με το ADDRESS'],
      ['STORE MANAGER NAME / MOBILE / MAIL', 'στοιχεία επικοινωνίας Store Manager· ίδιος κανόνας με το ADDRESS'],
      ['SOM NAME / MOBILE / MAIL', 'στοιχεία επικοινωνίας SOM· ίδιος κανόνας με το ADDRESS'],
    ],
    toRow: (c) => ({
      code: c[0] || '', address: c[1] || '', postalCode: c[2] || '', geoTag: c[3] || '',
      storeManagerName: c[4] || '', storeManagerMobile: c[5] || '', storeManagerMail: c[6] || '',
      somName: c[7] || '', somMobile: c[8] || '', somMail: c[9] || '',
    }),
    keyLabel: (r) => r.code,
    diffFields: [
      ['address', 'Διεύθυνση'], ['postalCode', 'ΤΚ'], ['geoTag', 'Geo Tag'],
      ['storeManagerName', 'Store Manager'], ['somName', 'SOM'],
    ],
  },
  spacegroups: {
    label: 'Spacegroups', endpoint: 'spacegroups',
    // 8-column Master_data.csv format — PM CY αφαιρέθηκε εντελώς (ζητήθηκε ρητά, "μόνο της
    // Ελλάδας", "ξηλώνουμε από παντού"), πριν ήταν 9-column με PM CY πρώτη στήλη.
    // "Yodiwo ID" = match key (== spacegroups.id). "Spacegroup id" = δεύτερος, ανεξάρτητος
    // κωδικός (νέα στήλη spacegroup_code) — καθαρά πληροφοριακό, καμία σχέση με το id.
    columns: ['PM', 'DIVISION', 'Family', 'Category', 'Yodiwo ID', 'Spacegroup id', 'Space Group', 'PM LOCKED'],
    example: ['Balla Pinelopi', 'Technology', 'Telephony', 'Tablet', '224', '050401', 'Tablets', 'FALSE'],
    legend: [
      ['PM', 'όνομα PM — αν δεν υπάρχει ήδη στο roster, δημιουργείται αυτόματα'],
      ['DIVISION', 'διαίρεση (π.χ. Technology, Home Appliances)'],
      ['Family', 'οικογένεια προϊόντος'],
      ['Category', 'κατηγορία'],
      ['Yodiwo ID', 'ΤΟ MATCH KEY — αντιστοιχεί σε spacegroups.id, καθορίζει ποιο spacegroup ενημερώνεται/δημιουργείται'],
      ['Spacegroup id', 'δεύτερος, ανεξάρτητος κωδικός (spacegroup_code) — καθαρά πληροφοριακό, όχι match key'],
      ['Space Group', 'το όνομα του spacegroup'],
      ['PM LOCKED', 'TRUE/yes/1 = κλειδώνει ΜΑΖΙ και τα 4 display πεδία σε αυτό το spacegroup (Live/Open/Box Moving/Non-Display — καμία εξαίρεση πλέον)· οτιδήποτε άλλο ή κενό = FALSE. Το feed lock δεν έχει CSV στήλη, μόνο UI toggle (lock modal).'],
    ],
    toRow: (c) => ({
      pmGr: c[0] || '', division: c[1] || '',
      family: c[2] || '', category: c[3] || '', yodiwoId: c[4] || '', spacegroupCode: c[5] || '', name: c[6] || '',
      blockPmEdit: c[7] || '',
    }),
    keyLabel: (r) => r.yodiwoId,
    diffFields: [
      ['name', 'Space Group'], ['division', 'Division'],
      ['family', 'Family'], ['category', 'Category'], ['spacegroupCode', 'Spacegroup id'],
      ['pmGrId', 'PM'], ['blockLiveEdit', 'PM Locked (Live/Open/Box Moving/Non-Display)'],
    ],
  },
  clusters: {
    label: 'Clusters', endpoint: 'clusters',
    // COMMENT: προαιρετικό ΚΑΙ σε νέα γραμμή (περιγραφικό, όχι governance πεδίο σαν τα 5
    // numeric) — κενό σε ήδη υπάρχουσα γραμμή αφήνει το τρέχον comment ανέγγιχτο, ίδιο "κενό =
    // leave untouched" convention με τα υπόλοιπα πεδία.
    columns: ['SG ID', 'CLUSTER', 'LIVE', 'OPEN', 'BOX MOVING', 'NON-DISPLAY', 'FEED', 'COMMENT'],
    example: ['224', 'c0', '10', '0', '0', '6', '10', 'Στήνεται σε γυάλινο ράφι, μπροστινή βιτρίνα'],
    legend: [
      ['SG ID', 'spacegroups.id — πρέπει να υπάρχει ήδη (import πρώτα Spacegroups)'],
      ['CLUSTER', 'κωδικός cluster (π.χ. c0, c1CY) — μαζί με SG ID καθορίζει τη γραμμή'],
      ['LIVE', 'αριθμός live faces — κενό επιτρέπεται σε ήδη υπάρχουσα γραμμή (αφήνει ανέγγιχτο)· υποχρεωτικό σε νέα (0 έγκυρη τιμή)'],
      ['OPEN', 'αριθμός open faces — ίδιος κανόνας με το LIVE'],
      ['BOX MOVING', 'αριθμός box moving faces — ίδιος κανόνας με το LIVE'],
      ['NON-DISPLAY', 'αριθμός non-display faces — ίδιος κανόνας με το LIVE'],
      ['FEED', 'πλήθος διαφορετικών SKUs που τροφοδοτούνται (ΟΧΙ τεμάχια) — ίδιος κανόνας με το LIVE'],
      ['COMMENT', 'ελεύθερο κείμενο — τρόπος στησίματος/έπιπλο· προαιρετικό ΚΑΙ σε νέα γραμμή, κενό σε ήδη υπάρχουσα γραμμή αφήνει το τρέχον comment ανέγγιχτο'],
    ],
    toRow: (c) => ({ spaceGroupId: c[0] || '', cluster: c[1] || '', live: c[2] || '', open: c[3] || '', boxMoving: c[4] || '', nonDisplay: c[5] || '', feed: c[6] || '', comment: c[7] || '' }),
    keyLabel: (r) => `${r.spaceGroupId} · ${r.cluster}`,
    diffFields: [...CLUSTER_5_FIELD_DEFS, ['comment', 'Comment']],
  },
  'cluster-tiers': {
    label: 'Clusters ανά Store Tier', endpoint: 'cluster-tiers',
    // Μία γραμμή = ένα (spacegroup, ομάδα καταστήματος, θέση) slot — ίδιο μοντέλο με το
    // ClusterTiersPage editor, απλά bulk. Πρέπει να τρέξει ΜΕΤΑ Spacegroups ΚΑΙ Clusters.
    columns: ['SG ID', 'STORE GROUP', 'ORDER', 'CLUSTER CODE'],
    example: ['224', 'Orange', '1', 'c1'],
    legend: [
      ['SG ID', 'spacegroups.id — πρέπει να υπάρχει ήδη (import πρώτα Spacegroups)'],
      ['STORE GROUP', 'μία από: Orange, Orange+, White, White+'],
      ['ORDER', 'θέση μέσα στη λίστα της ομάδας (1,2,3,…) — μικρότερο = πιο "min". Χωρίς σκληρό όριο πλήθους'],
      ['CLUSTER CODE', 'πρέπει να υπάρχει ήδη ως cluster αυτού του spacegroup (import πρώτα Clusters)· κενό = καθαρίζει (διαγράφει) αυτό το slot'],
    ],
    toRow: (c) => ({ spaceGroupId: c[0] || '', storeGroup: c[1] || '', tierOrder: c[2] || '', clusterCode: c[3] || '' }),
    keyLabel: (r) => `${r.spaceGroupId} · ${r.storeGroup} · ${r.tierOrder}`,
    diffFields: [['clusterCode', 'Cluster Code']],
    // Ίδιο conflict-detection convention με το store-cluster import — ίδιο (SG ID, STORE GROUP,
    // ORDER) με ΔΙΑΦΟΡΕΤΙΚΟ CLUSTER CODE μέσα στο ίδιο αρχείο δεν περνάει.
    dupKey: (r) => (r.spaceGroupId && r.storeGroup && r.tierOrder) ? `${r.spaceGroupId}::${r.storeGroup}::${r.tierOrder}` : null,
    dupValue: (r) => r.clusterCode || '',
    dupLabel: 'CLUSTER CODE',
  },
  'store-cluster': {
    label: 'Cluster ανά κατάστημα', endpoint: 'store-cluster',
    columns: ['STORE CODE', 'SG ID', 'CLUSTER CODE'],
    example: ['7001', '224', 'c0'],
    legend: [
      ['STORE CODE', 'stores.code — πρέπει να υπάρχει ήδη (import πρώτα Καταστήματα)'],
      ['SG ID', 'spacegroups.id — πρέπει να υπάρχει ήδη (import πρώτα Spacegroups)'],
      ['CLUSTER CODE', 'ποιο cluster έχει αυτό το κατάστημα σε αυτό το spacegroup· κενό = αφαιρεί την ανάθεση'],
    ],
    toRow: (c) => ({ storeCode: c[0] || '', spaceGroupId: c[1] || '', clusterCode: c[2] || '' }),
    keyLabel: (r) => `${r.storeCode} × ${r.spaceGroupId}`,
    diffFields: [['clusterCode', 'Cluster']],
    // Διπλότυπη γραμμή (ίδιο STORE CODE + SG ID) με ΔΙΑΦΟΡΕΤΙΚΟ CLUSTER CODE μέσα στο ίδιο
    // αρχείο — ζητήθηκε ρητά "να μην περνάει" (π.χ. 7001,100,c5 ΚΑΙ 7001,100,c2). Βλ.
    // findDuplicateConflicts· ίδια τιμή σε όλα τα διπλότυπα ΔΕΝ είναι σφάλμα, μόνο σύγκρουση.
    dupKey: (r) => (r.storeCode && r.spaceGroupId) ? `${r.storeCode}::${r.spaceGroupId}` : null,
    dupValue: (r) => r.clusterCode || '',
    dupLabel: 'CLUSTER CODE',
  },
  pmm: {
    label: 'PMs', endpoint: 'pmm',
    columns: ['NAME'],
    example: ['Balla Pinelopi'],
    legend: [
      ['NAME', 'όνομα PM (μοναδικό, καθορίζει τη γραμμή)'],
    ],
    toRow: (c) => ({ name: c[0] || '' }),
    keyLabel: (r) => r.name,
    diffFields: [],
  },
};

const IMPORT_ACTION_LABELS = { create: 'Νέα', update: 'Ενημέρωση', clear: 'Καθαρισμός', noop: 'Χωρίς αλλαγή', error: 'Σφάλμα' };
const IMPORT_ACTION_PILL = { create: 'ok', update: 'wait', clear: 'wait', noop: '', error: 'no' };

// Εντοπίζει γραμμές με το ΙΔΙΟ κλειδί (π.χ. STORE CODE + SG ID) αλλά ΔΙΑΦΟΡΕΤΙΚΗ τιμή (π.χ.
// CLUSTER CODE) μέσα στο ΙΔΙΟ αρχείο — ζητήθηκε ρητά "να μην περνάει" σε αυτή την περίπτωση
// (π.χ. 7001,100,c5 ΚΑΙ 7001,100,c2). Ίδιο key με ΙΔΙΑ τιμή παντού ΔΕΝ είναι σφάλμα (αβλαβές
// duplicate, η δεύτερη γραμμή απλά γίνεται 'noop'). Καλείται πάνω σε ΟΛΕΣ τις γραμμές μαζί,
// ΠΡΙΝ το chunking (βλ. sendChunked) — το per-chunk server-side validate/apply δεν βλέπει ποτέ
// όλο το αρχείο μαζί, άρα ένα ζευγάρι που πέφτει σε 2 διαφορετικά chunks θα ξέφευγε αν ο
// έλεγχος γινόταν μόνο εκεί. Entity-driven (dupKey/dupValue/dupLabel στο IMPORT_ENTITIES) —
// προς το παρόν μόνο το 'store-cluster' το ορίζει.
function findDuplicateConflicts(rows, entity) {
  if (!entity.dupKey) return new Map();
  const byKey = new Map(); // key -> [{line, value}]
  rows.forEach((r, i) => {
    const key = entity.dupKey(r);
    if (!key) return;
    if (!byKey.has(key)) byKey.set(key, []);
    byKey.get(key).push({ line: i + 2, value: entity.dupValue(r) });
  });
  const errorsByLine = new Map();
  for (const entries of byKey.values()) {
    if (entries.length < 2) continue;
    const distinctValues = [...new Set(entries.map((e) => e.value))];
    if (distinctValues.length < 2) continue;
    const lines = entries.map((e) => e.line);
    for (const { line } of entries) {
      errorsByLine.set(line,
        `Διπλότυπη γραμμή (ίδιο κλειδί) με διαφορετική τιμή ${entity.dupLabel} στις γραμμές ${lines.join(', ')} (τιμές: ${distinctValues.join(', ')})`);
    }
  }
  return errorsByLine;
}

// Στέλνει validate/apply σε κομμάτια αντί για ΕΝΑ request με όλες τις γραμμές μαζί — δύο
// λόγοι: (1) πραγματικό progress bar (ζητήθηκε ρητά, "να βλέπω ότι προχωράει το upload"),
// κάθε chunk που ολοκληρώνεται προχωράει τη μπάρα, όχι ένα fake/indeterminate animation· (2)
// μερικά apply endpoints (π.χ. clusters — sequential .update() ανά αλλαγμένη γραμμή) μπορούν
// να αργήσουν πολύ σε μεγάλα CSV, ρίσκο timeout στο serverless function limit του Vercel· ένα
// request με 200 γραμμές είναι ασφαλές, ένα με 4000+ ίσως όχι. Sequential (όχι Promise.all),
// ζητήθηκε ρητά ορατή πρόοδο — παράλληλα requests θα ολοκληρώνονταν σχεδόν ταυτόχρονα, καμία
// ενδιάμεση ανανέωση της μπάρας. Κανένα plan/apply endpoint δεν κάνει validation across ΟΛΗ
// τη μαζική λίστα γραμμών (π.χ. duplicate-key detection) — το chunking δεν αλλάζει τη
// σημασιολογία, μόνο πόσα requests στέλνονται· γι' αυτό το findDuplicateConflicts παραπάνω
// δουλεύει στο ImportPage πάνω σε ΟΛΟ το parsedRows, όχι εδώ ανά chunk.
// Αν ένα chunk αποτύχει στη μέση (π.χ. δίκτυο), τα ΠΡΟΗΓΟΥΜΕΝΑ chunks έχουν ήδη εφαρμοστεί
// στη βάση (apply δεν κάνει rollback across requests, κάθε chunk είναι το δικό του ανεξάρτητο
// commit) — επιστρέφουμε ό,τι μαζεύτηκε ΜΕΧΡΙ το σφάλμα (results.error το σηματοδοτεί) αντί να
// πετάξουμε το partial progress, ώστε ο χρήστης να ξέρει πόσο πρόλαβε να προχωρήσει πριν
// αποφασίσει αν θα ξαναδοκιμάσει (ξαναδοκιμή είναι ασφαλής — 'noop' για ό,τι ήδη εφαρμόστηκε).
const IMPORT_CHUNK_SIZE = 200;
async function sendChunked(endpoint, rows, onProgress) {
  let applied = 0, skipped = 0;
  const results = [];
  for (let i = 0; i < rows.length; i += IMPORT_CHUNK_SIZE) {
    const chunk = rows.slice(i, i + IMPORT_CHUNK_SIZE);
    let data;
    try {
      data = await api.send('POST', endpoint, { rows: chunk });
    } catch (e) {
      return { results, applied, skipped, error: e };
    }
    // Ο server υπολογίζει r.line σχετικά με ΑΥΤΟ το chunk (0-based local index + 2, βλ. π.χ.
    // planStoreClusterImport) — χωρίς το +i offset εδώ, κάθε chunk θα ξανα-ανέφερε γραμμές
    // 2..201, σπάζοντας το "#" column, τα React key (βλ. results.map key={r.line} στο
    // ImportPage) και το findDuplicateConflicts line-matching για αρχεία >IMPORT_CHUNK_SIZE.
    results.push(...data.results.map((r) => ({ ...r, line: r.line + i })));
    if (data.applied !== undefined) applied += data.applied;
    if (data.skipped !== undefined) skipped += data.skipped;
    onProgress(Math.min(i + IMPORT_CHUNK_SIZE, rows.length), rows.length);
  }
  return { results, applied, skipped, error: null };
}

function ImportProgressBar({ progress }) {
  if (!progress) return null;
  const pct = progress.total ? Math.round((progress.done / progress.total) * 100) : 0;
  return (
    <div className="import-progress" style={{ margin: '10px 0' }}>
      <div className="import-progress-track">
        <div className="import-progress-fill" style={{ width: pct + '%' }} />
      </div>
      <div className="import-progress-label">{progress.done} / {progress.total} γραμμές ({pct}%)</div>
    </div>
  );
}

function ImportPage() {
  const [entityKey, setEntityKey] = useState('spacegroups');
  const [text, setText] = useState('');
  const [parsedRows, setParsedRows] = useState(null); // raw parsed rows (entity-shaped objects) | null
  const [results, setResults] = useState(null); // validate/apply results | null
  const [applyResult, setApplyResult] = useState(null); // { applied, skipped } | null
  const [busy, setBusy] = useState(false);
  const [progress, setProgress] = useState(null); // { done, total } | null — βλ. sendChunked
  const [legendOpen, setLegendOpen] = useState(false);
  const [confirmClearOpen, setConfirmClearOpen] = useState(false);
  // Φίλτρο πάνω στο results table (πέρα από τα σφάλματα, που έχουν το δικό τους πάντα-ορατό
  // panel παρακάτω, βλ. errorRows) — resetάρεται σε κάθε νέο ξεκίνημα (νέο entity, καθαρισμός,
  // νέα ανάλυση CSV/JSON), όχι σε κάθε νέο validate πάνω στα ίδια δεδομένα.
  const [resultsFilter, setResultsFilter] = useState('all'); // 'all' | 'create' | 'update' | 'clear' | 'noop'
  const entity = IMPORT_ENTITIES[entityKey];

  function switchEntity(key) {
    setEntityKey(key);
    setText(''); setParsedRows(null); setResults(null); setApplyResult(null); setLegendOpen(false);
    setResultsFilter('all');
  }

  function onFile(e) {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => setText(String(reader.result || ''));
    reader.readAsText(file, 'utf-8');
  }

  function doParse() {
    if (entity.isJson) {
      try {
        setParsedRows(entity.toRows(JSON.parse(text)));
      } catch (e) { toastErr('Μη έγκυρο JSON: ' + e.message); return; }
      setResults(null);
      setApplyResult(null);
      setResultsFilter('all');
      return;
    }
    const { rows } = parseCsv(text);
    setParsedRows(rows.map(entity.toRow));
    setResults(null);
    setApplyResult(null);
    setResultsFilter('all');
  }

  async function doValidate() {
    if (!parsedRows) return;
    setBusy(true);
    setProgress({ done: 0, total: parsedRows.length });
    try {
      const { results, error } = await sendChunked(`/api/import/${entity.endpoint}/validate`, parsedRows,
        (done, total) => setProgress({ done, total }));
      if (results.length) {
        // Duplicate-conflict γραμμές (βλ. findDuplicateConflicts) γίνονται σφάλμα ΑΚΟΜΑ ΚΙ ΑΝ
        // ο server τις είδε ok — ο server δεν βλέπει ποτέ όλο το αρχείο μαζί (chunking).
        const dupErrors = findDuplicateConflicts(parsedRows, entity);
        setResults(dupErrors.size
          ? results.map((r) => dupErrors.has(r.line)
            ? { ...r, ok: false, action: 'error', errors: [...(r.errors || []), dupErrors.get(r.line)] }
            : r)
          : results);
      }
      if (error) toastErr(`Σταμάτησε στη γραμμή ${results.length + 1}: ${error.message}`);
    } catch (e) { toastErr(e.message); }
    setBusy(false);
    setProgress(null);
  }

  async function doApply() {
    if (!parsedRows) return;
    setBusy(true);
    setProgress({ done: 0, total: parsedRows.length });
    try {
      const { applied, skipped, results, error } = await sendChunked(`/api/import/${entity.endpoint}/apply`, parsedRows,
        (done, total) => setProgress({ done, total }));
      if (results.length) {
        setApplyResult({ applied, skipped });
        setResults(results);
      }
      if (error) {
        toastErr(`Σταμάτησε μετά από ${applied + skipped} γραμμές: ${error.message}`);
      } else {
        toast(`Εφαρμόστηκαν ${applied}, παραλείφθηκαν ${skipped}`);
      }
    } catch (e) { toastErr(e.message); }
    setBusy(false);
    setProgress(null);
  }

  function reset() {
    setText(''); setParsedRows(null); setResults(null); setApplyResult(null);
    setResultsFilter('all');
  }

  const counts = useMemo(() => {
    if (!results) return null;
    const c = { create: 0, update: 0, clear: 0, noop: 0, error: 0 };
    for (const r of results) c[r.action] = (c[r.action] || 0) + 1;
    return c;
  }, [results]);
  const validCount = results ? results.filter((r) => r.action !== 'error').length : 0;

  // Πάντα-ορατό, ξεχωριστό από το γενικό filter/πίνακα παρακάτω — ζητήθηκε ρητά "δεν μπορώ να
  // βρω εύκολα τα σφάλματα ... θέλω να μου τα φέρνεις πιο ξεκάθαρα" μετά από feedback ότι το
  // filter pill "με σφάλμα" ήταν δύσκολο να εντοπιστεί/να εμπιστευτεί. Δεν χρειάζεται κλικ —
  // εμφανίζεται μόνο του κάθε φορά που υπάρχουν σφάλματα, ταξινομημένο ανά γραμμή.
  const errorRows = useMemo(
    () => (results ? results.filter((r) => r.action === 'error').slice().sort((a, b) => a.line - b.line) : []),
    [results],
  );

  // Φιλτραρισμένη προβολή του γενικού results table (δημιουργίες/ενημερώσεις/κ.λπ.) — ΔΕΝ
  // επηρεάζει την ίδια την Εφαρμογή (πάντα στέλνει όλα τα parsedRows), μόνο τι βλέπει ο
  // χρήστης στον πίνακα. Τα σφάλματα έχουν το δικό τους πάντα-ορατό panel (errorRows) αντί
  // για ένα ακόμα filter pill εδώ.
  const displayedResults = useMemo(() => {
    if (!results) return [];
    return resultsFilter === 'all' ? results : results.filter((r) => r.action === resultsFilter);
  }, [results, resultsFilter]);

  // Πλήρες-row-replace apply (π.χ. Spacegroups) γράφει ΚΑΙ τα κενά κελιά σαν null, σβήνοντας
  // σιωπηλά ήδη υπάρχουσες τιμές αν το CSV έχει κενά σε στήλες που κάποτε ήταν συμπληρωμένες
  // (π.χ. re-import μερικού CSV). Εντοπίζουμε ΠΡΙΝ την εφαρμογή ώστε να προειδοποιήσουμε,
  // πάνω στα ήδη υπάρχοντα diffFields (ίδια πηγή με το preview table παρακάτω) — δουλεύει
  // αυτόματα για κάθε entity type που έχει before/after σε 'update', όχι μόνο spacegroups.
  const clearingRows = useMemo(() => {
    if (!results || !entity.diffFields.length) return [];
    return results.filter((r) => r.action === 'update' && r.before && r.after &&
      entity.diffFields.some(([f]) => {
        const b = r.before[f];
        const a = r.after[f];
        return b != null && String(b) !== '' && (a == null || String(a) === '');
      }));
  }, [results, entity]);

  return (
    <div className="page">
      <div className="toolbar">
        <div className="field grow"><b>Import</b></div>
      </div>

      <div className="seg" style={{ marginBottom: 16 }}>
        {Object.entries(IMPORT_ENTITIES).map(([key, e]) => (
          <button key={key} className={'seg-btn ' + (entityKey === key ? 'active' : '')} onClick={() => switchEntity(key)}>
            {e.label}
          </button>
        ))}
      </div>

      {!applyResult ? (
        <>
          <div className="matrix-note">
            {entity.isJson ? (
              <>Ανέβασε αρχείο .json (ή επικόλλησε το περιεχόμενό του), μετά "Ανάλυση JSON" και "Έλεγχος" για προεπισκόπηση πριν την εφαρμογή.</>
            ) : (
              <>
                Στήλες CSV (με header): <b>{entity.columns.join(', ')}</b>. Επικόλλησε κείμενο ή ανέβασε αρχείο .csv, μετά "Έλεγχος" για προεπισκόπηση πριν την εφαρμογή.
                <br />
                Παράδειγμα γραμμής: <span style={{ fontFamily: 'var(--mono)' }}>{entity.example.join(',')}</span>{' '}
              </>
            )}
            <button type="button" className="info-btn" title="Υπόμνημα στηλών" aria-label="Υπόμνημα στηλών"
              onClick={() => setLegendOpen((o) => !o)}>
              ⓘ
            </button>
          </div>
          {legendOpen ? (
            <div className="matrix-note" style={{ marginTop: -6 }}>
              <b>Υπόμνημα:</b>{' '}
              {entity.legend.map(([col, explain], i) => (
                <span key={col}>
                  {i > 0 ? ' · ' : ''}<b>{col}</b>: {explain}
                </span>
              ))}
            </div>
          ) : null}
          <div className="field" style={{ marginBottom: 10 }}>
            <input type="file" accept={entity.isJson ? '.json' : '.csv'} onChange={onFile} />
          </div>
          <textarea
            rows={8} className="import-textarea"
            placeholder={entity.isJson ? '{ "Public Orange": { "STORES": [...] }, ... }' : entity.columns.join(',')}
            value={text} onChange={(e) => { setText(e.target.value); setParsedRows(null); setResults(null); }}
          />
          <div className="picker-actions">
            <button className="btn primary" disabled={!text.trim() || busy} onClick={() => { doParse(); }}>
              {entity.isJson ? 'Ανάλυση JSON' : 'Ανάλυση CSV'}
            </button>
            {parsedRows ? (
              <button className="btn primary" disabled={busy} onClick={doValidate}>
                {busy ? 'Έλεγχος…' : `Έλεγχος (${parsedRows.length} γραμμές)`}
              </button>
            ) : null}
            {results ? (
              <button className="btn primary" disabled={busy || !validCount || !!(counts && counts.error)}
                onClick={() => { if (clearingRows.length) setConfirmClearOpen(true); else doApply(); }}>
                {busy ? 'Εφαρμογή…' : (counts && counts.error) ? `Διόρθωσε ${counts.error} σφάλμα${counts.error === 1 ? '' : 'τα'} πρώτα` : `Εφαρμογή (${validCount} γραμμές)`}
              </button>
            ) : null}
            {text || parsedRows ? <button className="btn ghost" onClick={reset}>Καθαρισμός</button> : null}
          </div>

          <ImportProgressBar progress={progress} />

          {errorRows.length ? (
            <div className="import-errors">
              <b>⚠ {errorRows.length} γραμμ{errorRows.length === 1 ? 'ή' : 'ές'} με σφάλμα</b> — το αρχείο δεν μπορεί να εφαρμοστεί όσο υπάρχουν σφάλματα. Διόρθωσε τα παρακάτω και ξανά-ανέβασε/ξανά-έλεγξε το αρχείο.
              <div className="tablewrap" style={{ marginTop: 8, maxHeight: 320, overflow: 'auto' }}>
                <table>
                  <thead><tr><th>#</th><th>Κλειδί</th><th>Σφάλμα</th></tr></thead>
                  <tbody>
                    {errorRows.map((r) => (
                      <tr key={r.line}>
                        <td>{r.line}</td>
                        <td>{r.key || r.id}</td>
                        <td>{r.errors && r.errors.length ? r.errors.join(' · ') : ''}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          ) : null}

          {counts ? (
            <div className="matrix-note" style={{ marginTop: 14, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
              <div className="seg">
                <button type="button" className={'seg-btn' + (resultsFilter === 'all' ? ' active' : '')} onClick={() => setResultsFilter('all')}>
                  Όλα ({results.length})
                </button>
                {[['create', 'νέα'], ['update', 'ενημερώσεις'], ['clear', 'καθαρισμοί'], ['noop', 'χωρίς αλλαγή']]
                  .filter(([action]) => counts[action]).map(([action, label]) => (
                    <button key={action} type="button" className={'seg-btn' + (resultsFilter === action ? ' active' : '')}
                      onClick={() => setResultsFilter(action)}>
                      {counts[action]} {label}
                    </button>
                  ))}
              </div>
            </div>
          ) : null}

          {results ? (
            <div className="tablewrap" style={{ marginTop: 10 }}>
              <table>
                <thead>
                  <tr>
                    <th>#</th><th>Κλειδί</th><th>Ενέργεια</th><th>Αλλαγές</th><th>Σφάλματα</th>
                  </tr>
                </thead>
                <tbody>
                  {displayedResults.length === 0 ? (
                    <tr><td colSpan={5} className="empty-cell">Καμία γραμμή σε αυτό το φίλτρο</td></tr>
                  ) : displayedResults.map((r) => (
                    <tr key={r.line}>
                      <td>{r.line}</td>
                      <td>{r.key || r.id}</td>
                      <td><span className={'pill ' + (IMPORT_ACTION_PILL[r.action] || '')}>{IMPORT_ACTION_LABELS[r.action]}</span></td>
                      <td>
                        {(r.action === 'update' || r.action === 'clear') && entity.diffFields.length ? (
                          entity.diffFields.map(([f, label]) => {
                            const b = r.before ? r.before[f] : undefined;
                            const a = r.after ? r.after[f] : undefined;
                            if (String(b ?? '') === String(a ?? '')) return null;
                            return (
                              <span key={f} className="history-field" style={{ marginRight: 10 }}>
                                <b>{label}:</b> {b ?? '—'} → <span className="history-after">{a ?? '—'}</span>
                              </span>
                            );
                          })
                        ) : r.action === 'create' && r.after ? (
                          <span className="history-field">νέα γραμμή</span>
                        ) : null}
                      </td>
                      <td style={{ color: '#b3382a' }}>{r.errors && r.errors.length ? r.errors.join('· ') : ''}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          ) : null}
        </>
      ) : (
        <div className="matrix-note">
          Εφαρμόστηκαν <b>{applyResult.applied}</b> γραμμές, παραλείφθηκαν <b>{applyResult.skipped}</b> (χωρίς αλλαγή ή με σφάλμα).
          <div style={{ marginTop: 10 }}>
            <button className="btn primary" onClick={reset}>Νέο import</button>
          </div>
        </div>
      )}

      {confirmClearOpen ? (
        <div className="drawer-backdrop" onClick={() => setConfirmClearOpen(false)}>
          <div className="drawer" onClick={(e) => e.stopPropagation()}>
            <div className="drawer-head">
              <b>Προσοχή: θα σβηστούν υπάρχουσες τιμές</b>
              <button className="btn ghost sm" onClick={() => setConfirmClearOpen(false)} aria-label="Κλείσιμο">✕</button>
            </div>
            <div className="drawer-body">
              <p className="matrix-note">
                Το CSV έχει κενά κελιά σε {clearingRows.length} γραμμ{clearingRows.length === 1 ? 'ή' : 'ές'} όπου
                υπάρχει ήδη τιμή — η εφαρμογή θα την αδειάσει (πλήρης αντικατάσταση γραμμής, όχι merge).
              </p>
              <div className="tablewrap" style={{ marginTop: 10, maxHeight: 260, overflow: 'auto' }}>
                <table>
                  <thead><tr><th>Κλειδί</th><th>Πεδία που αδειάζουν</th></tr></thead>
                  <tbody>
                    {clearingRows.map((r) => (
                      <tr key={r.line}>
                        <td>{r.id}</td>
                        <td>
                          {entity.diffFields.filter(([f]) => {
                            const b = r.before[f]; const a = r.after[f];
                            return b != null && String(b) !== '' && (a == null || String(a) === '');
                          }).map(([f, label]) => <span key={f} className="history-field" style={{ marginRight: 10 }}>{label}: {r.before[f]} → —</span>)}
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
              <div className="picker-actions" style={{ marginTop: 14 }}>
                <button className="btn danger" onClick={() => { setConfirmClearOpen(false); doApply(); }}>
                  Συνέχεια, εφαρμογή
                </button>
                <button className="btn ghost" onClick={() => setConfirmClearOpen(false)}>Άκυρο</button>
              </div>
            </div>
          </div>
        </div>
      ) : null}
    </div>
  );
}

/* ============================ Τεκμηρίωση ============================ */
const DOCS_SECTIONS = [
  { key: 'overview', label: 'Επισκόπηση πλατφόρμας' },
  { key: 'domains', label: 'Display vs Feed & νέοι ρόλοι' },
  { key: 'roles', label: 'Ρόλοι & δικαιώματα' },
  { key: 'navigation', label: 'Πλοήγηση στην πλατφόρμα' },
  { key: 'search', label: 'Αναζήτηση, φίλτρα & προσαρμογή' },
  { key: 'data-flow', label: 'Πώς μπαίνουν τα δεδομένα' },
  { key: 'profile', label: 'Προφίλ χρήστη & στοιχεία επικοινωνίας' },
  { key: 'appearance', label: 'Θέμα εμφάνισης & desktop-only' },
  { key: 'import', label: 'Εισαγωγή δεδομένων (Import)' },
  { key: 'rebuild', label: 'Επαναφορά βάσης από το μηδέν', superAdminOnly: true },
];

// role -> [τι βλέπει, τι μπορεί να αλλάξει απευθείας, ειδικές σημειώσεις] — αντλείται από τα
// ΙΔΙΑ CAN_*/DIRECT_WRITE_ROLES/REQUEST_SUBMIT_ROLES/REQUEST_REVIEW_ROLES που ήδη ελέγχουν τα
// routes (βλ. ~L212-226) — καμία δεύτερη, ξεχωριστή περιγραφή που θα μπορούσε να ξεσυγχρονιστεί.
const DOCS_ROLE_ROWS = [
  {
    role: 'super_admin',
    sees: 'Τα πάντα.',
    writes: 'Τα πάντα απευθείας: clusters (display & feed), spacegroups (δημιουργία/διαγραφή/rename id), stores, ενεργοποίηση PM λογαριασμών, χρήστες, Import tab (μόνο αυτός ο ρόλος).',
    note: 'Ο μοναδικός ρόλος που μπορεί να διαχειριστεί πλήρως χρήστες (ρόλος/ενεργός/reset password/διαγραφή/ενεργοποίηση PM) και να τρέξει bulk import. Μπορεί να κλειδώσει/ξεκλειδώσει ΚΑΙ Display ΚΑΙ Feed σε ένα spacegroup, και να εγκρίνει αιτήματα και των δύο domains.',
  },
  {
    role: 'admin',
    sees: 'Τα πάντα.',
    writes: 'clusters (display & feed), cluster ανά κατάστημα (matrix cells), ownership/κατηγορία spacegroup, δημιουργία/διαγραφή spacegroup — απευθείας, χωρίς έγκριση. Βλέπει τη λίστα Χρηστών και επεξεργάζεται Display Name.',
    note: 'Έχει ό,τι έχει ο super_admin εκτός από πλήρη διαχείριση χρηστών (ρόλος/ενεργός/reset password/διαγραφή/ενεργοποίηση PM παραμένουν super_admin only). Μπορεί να κλειδώσει/ξεκλειδώσει ΚΑΙ Display ΚΑΙ Feed, και να εγκρίνει αιτήματα και των δύο domains.',
  },
  {
    role: 'moderator',
    sees: 'Τα πάντα (χωρίς περιορισμό).',
    writes: 'Τίποτα απευθείας.',
    note: 'Εγκρίνει/απορρίπτει αιτήματα ΚΑΙ display ΚΑΙ feed, από pm ή από τους νέους submit-only ρόλους (space_user/on_demand_user) — βλ. "Πώς μπαίνουν τα δεδομένα" παρακάτω.',
  },
  {
    role: 'pm',
    sees: 'Μόνο τα δικά του spacegroups (αυτά όπου spacegroups.pm_gr_id δείχνει στον ίδιο).',
    writes: 'Τίποτα απευθείας.',
    note: 'Υποβάλλει προτάσεις αλλαγών ΚΑΙ σε display ΚΑΙ σε feed πεδία (καλάθι → αίτημα) που περνούν από έγκριση — αν αλλάξει και τα δύο μαζί στο ίδιο drawer, δημιουργούνται 2 ξεχωριστά αιτήματα (ένα ανά domain), εγκρίνονται ανεξάρτητα. Το ίδιο ισχύει και σε ΝΕΑ γραμμή cluster — και τα δύο domains ξεκινούν εκκρεμή, με κοινό κωδικό αναφοράς (βλ. "Πώς μπαίνουν τα δεδομένα"). Αν ένα spacegroup έχει κλειδωμένο το Display, μπορεί να προτείνει μόνο αλλαγή στο Non-Display· αν έχει κλειδωμένο το Feed, καμία αλλαγή feed δεν επιτρέπεται καθόλου (καμία εξαίρεση).',
  },
  {
    role: 'viewer',
    sees: 'Τα πάντα (display & feed, χωρίς περιορισμό).',
    writes: 'Τίποτα.',
    note: 'Καθαρά read-only λογαριασμός, βλέπει ΚΑΙ τα δύο domains.',
  },
  {
    role: 'space_manager',
    sees: 'Όλα τα spacegroups (καμία ownership-scoping όπως στον pm) — μόνο display πεδία γράφει. Στο "Διαχείριση ομάδας" επεξεργάζεται τους δικούς του space_user/space_viewer ΚΑΙ τους λογαριασμούς pm (κοινός πόρος με τον on_demand_manager), αλλά βλέπει (read-only) και το on_demand team, τους moderators, και όλους τους viewers — ποτέ admin/super_admin.',
    writes: 'clusters (μόνο Faces/Status/Non-Display, ΟΧΙ Feed), matrix cells — απευθείας, χωρίς έγκριση. Μπορεί επίσης να δημιουργήσει ΝΕΑ γραμμή cluster — το δικό του (display) κομμάτι εφαρμόζεται αμέσως, το Feed πηγαίνει σαν εκκρεμές αίτημα στο On Demand τμήμα (βλ. "Πώς μπαίνουν τα δεδομένα"). Δημιουργεί/ενεργοποιεί/μετονομάζει/ενεργοποιεί-απενεργοποιεί ΚΑΙ νέους space_user/space_viewer λογαριασμούς ΚΑΙ λογαριασμούς pm — βλ. "Διαχείριση ομάδας" παρακάτω. Μπορεί επίσης να αναθέσει έναν pm σε οποιοδήποτε spacegroup (Διαχείριση → Spacegroups), ώστε να μπορεί να ολοκληρώσει τη ροή μετά τη δημιουργία ενός νέου pm λογαριασμού.',
    note: 'Εγκρίνει/απορρίπτει display αιτήματα (από pm ή space_user). Μπορεί να κλειδώσει/ξεκλειδώσει ΜΟΝΟ το Display ενός spacegroup — δεν βλέπει καν το Feed checkbox στο lock modal.',
  },
  {
    role: 'space_user',
    sees: 'Όλα τα spacegroups, ΟΛΑ τα αιτήματα (display & feed) — read-only για ό,τι δεν είναι δικό του domain. Στο "Διαχείριση ομάδας" βλέπει (read-only, με πλήρη στοιχεία) τους managers και των δύο τμημάτων, τους pm, και όλους τους submit/viewer ρόλους — ποτέ moderator/admin/super_admin.',
    writes: 'Τίποτα απευθείας.',
    note: 'Υποβάλλει προτάσεις ΜΟΝΟ σε display πεδία (καλάθι → αίτημα σε space_manager). Δεν βλέπει καν το πεδίο Feed στο drawer επεξεργασίας. Μπορεί επίσης να δημιουργήσει/επεξεργαστεί ΜΟΝΟ νέους space_viewer λογαριασμούς (ο ρόλος ακριβώς από κάτω του) στο "Διαχείριση ομάδας".',
  },
  {
    role: 'space_viewer',
    sees: 'Όλα τα spacegroups — μόνο display πεδία.',
    writes: 'Τίποτα.',
    note: 'Read-only, scoped σε display (Faces/Status/Non-Display) — δεν βλέπει το Feed πεδίο.',
  },
  {
    role: 'on_demand_manager',
    sees: 'Όλα τα spacegroups (καμία ownership-scoping) — μόνο feed πεδίο γράφει. Στο "Διαχείριση ομάδας" επεξεργάζεται τους δικούς του on_demand_user/on_demand_viewer ΚΑΙ τους λογαριασμούς pm (κοινός πόρος με τον space_manager), αλλά βλέπει (read-only) και το space team, τους moderators, και όλους τους viewers — ποτέ admin/super_admin.',
    writes: 'clusters (μόνο Feed), απευθείας, χωρίς έγκριση. Μπορεί επίσης να δημιουργήσει ΝΕΑ γραμμή cluster — το δικό του (feed) κομμάτι εφαρμόζεται αμέσως, το Display πηγαίνει σαν εκκρεμές αίτημα στο Space τμήμα (βλ. "Πώς μπαίνουν τα δεδομένα"). Δημιουργεί/ενεργοποιεί/μετονομάζει/ενεργοποιεί-απενεργοποιεί ΚΑΙ νέους on_demand_user/on_demand_viewer λογαριασμούς ΚΑΙ λογαριασμούς pm — βλ. "Διαχείριση ομάδας" παρακάτω. Μπορεί επίσης να αναθέσει έναν pm σε οποιοδήποτε spacegroup (Διαχείριση → Spacegroups), ώστε να μπορεί να ολοκληρώσει τη ροή μετά τη δημιουργία ενός νέου pm λογαριασμού.',
    note: 'Εγκρίνει/απορρίπτει feed αιτήματα (από pm ή on_demand_user). Μπορεί να κλειδώσει/ξεκλειδώσει ΜΟΝΟ το Feed ενός spacegroup — δεν βλέπει καν το Display checkbox στο lock modal.',
  },
  {
    role: 'on_demand_user',
    sees: 'Όλα τα spacegroups, ΟΛΑ τα αιτήματα (display & feed) — read-only για ό,τι δεν είναι δικό του domain. Στο "Διαχείριση ομάδας" βλέπει (read-only, με πλήρη στοιχεία) τους managers και των δύο τμημάτων, τους pm, και όλους τους submit/viewer ρόλους — ποτέ moderator/admin/super_admin.',
    writes: 'Τίποτα απευθείας.',
    note: 'Υποβάλλει προτάσεις ΜΟΝΟ σε το πεδίο Feed (καλάθι → αίτημα σε on_demand_manager). Στο drawer επεξεργασίας βλέπει μόνο το πεδίο Feed. Μπορεί επίσης να δημιουργήσει/επεξεργαστεί ΜΟΝΟ νέους on_demand_viewer λογαριασμούς (ο ρόλος ακριβώς από κάτω του) στο "Διαχείριση ομάδας".',
  },
  {
    role: 'on_demand_viewer',
    sees: 'Όλα τα spacegroups — μόνο το πεδίο feed.',
    writes: 'Τίποτα.',
    note: 'Read-only, scoped σε feed — δεν βλέπει Faces/Status/Non-Display.',
  },
];

// entity key -> ποιος πίνακας/ενέργεια αντιστοιχεί, για το section "Εισαγμένα δεδομένα".
// Τραβάει τις στήλες/legend ΑΠΕΥΘΕΙΑΣ από το ήδη υπάρχον IMPORT_ENTITIES (η ΙΔΙΑ πηγή που
// τροφοδοτεί το πραγματικό Import tab) ώστε να μην μπορεί ποτέ να ξεσυγχρονιστεί η τεκμηρίωση
// από αυτό που πραγματικά δέχεται ο import.
const DOCS_IMPORT_PREREQS = {
  stores: 'Καμία — μπορεί να είναι το πρώτο import.',
  'store-details': 'Καταστήματα (εμπλουτίζει ήδη υπάρχον κατάστημα).',
  spacegroups: 'Καμία — αν η στήλη PM αναφέρει όνομα που δεν υπάρχει ακόμα, δημιουργείται αυτόματα.',
  clusters: 'Spacegroups.',
  'cluster-tiers': 'Spacegroups + Clusters (κάθε δηλωμένος κωδικός πρέπει να υπάρχει ήδη σαν cluster αυτού του spacegroup).',
  'store-cluster': 'Καταστήματα + Spacegroups.',
  pmm: 'Καμία.',
};

// ΟΧΙ πραγματικό <a href="#..."> — το hash της σελίδας οδηγεί το routing (βλ. currentRoute()),
// ένα #docs-overview href θα πυροδοτούσε hashchange και θα "πετούσε" τον χρήστη αλλού (καμία
// route δεν ταιριάζει σε /docs-*, πέφτει στο default 'clusters'). Απλό scrollIntoView στο
// αντίστοιχο section, καμία επίδραση στο routing/hash.
function DocsSideNav({ activeKey, visibleSections }) {
  function scrollToSection(key) {
    const el = document.getElementById('docs-' + key);
    if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }
  return (
    <nav className="docs-nav">
      {visibleSections.map((s) => (
        <button key={s.key} type="button" onClick={() => scrollToSection(s.key)}
          className={'docs-nav-link' + (activeKey === s.key ? ' active' : '')}>
          {s.label}
        </button>
      ))}
    </nav>
  );
}

function DocsEntityBox({ title, sub }) {
  return (
    <div className="docs-entity-box">
      <div className="docs-entity-title">{title}</div>
      {sub ? <div className="docs-entity-sub">{sub}</div> : null}
    </div>
  );
}

function DocsPage() {
  const me = useAuth();
  const [activeKey, setActiveKey] = useState('overview');
  const isSuperAdmin = CAN_MANAGE_USERS(me.role);
  const visibleSections = DOCS_SECTIONS.filter((s) => !s.superAdminOnly || isSuperAdmin);

  // Highlight το section που είναι ορατό στο viewport καθώς κάνει scroll ο χρήστης — plain
  // IntersectionObserver, ίδιο επίπεδο πολυπλοκότητας με το ήδη υπάρχον scroll-tracking αλλού
  // στο αρχείο (π.χ. sticky headers), όχι νέα εξάρτηση.
  useEffect(() => {
    const els = visibleSections.map((s) => document.getElementById('docs-' + s.key)).filter(Boolean);
    if (!els.length) return;
    const obs = new IntersectionObserver((entries) => {
      const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
      if (visible[0]) setActiveKey(visible[0].target.id.replace('docs-', ''));
    }, { rootMargin: '-15% 0px -70% 0px' });
    els.forEach((el) => obs.observe(el));
    return () => obs.disconnect();
  }, [visibleSections.length]);

  return (
    <div className="page docs-page">
      <div className="toolbar">
        <div className="field grow"><b>Documentation</b></div>
      </div>

      <div className="docs-layout">
        <DocsSideNav activeKey={activeKey} visibleSections={visibleSections} />

        <div className="docs-content">
          <section id="docs-overview" className="docs-section">
            <h2>Επισκόπηση πλατφόρμας</h2>
            <p>
              Το Cluster Console είναι το εργαλείο που δείχνει και διαχειρίζεται <b>πού μέσα στα καταστήματα
              εμφανίζεται κάθε προϊόν</b>, με τι ποσότητα ("faces") και σε ποια κατάσταση. Η βασική ερώτηση
              που απαντάει η πλατφόρμα είναι: <i>"το προϊόν Χ, πόσα σημεία έχει στο κατάστημα Υ, και είναι
              ζωντανό/ανοιχτό/υπό μετακίνηση αυτή τη στιγμή;"</i> — για κάθε προϊόν και κάθε κατάστημα του
              δικτύου (Ελλάδα + Κύπρος), σε κλίμακα εκατοντάδων προϊόντων × δεκάδων καταστημάτων.
            </p>
            <p>Οι 4 βασικές έννοιες που χτίζουν όλο το υπόλοιπο σύστημα:</p>
            <div className="docs-diagram">
              <DocsEntityBox title="Spacegroup" sub="Ομάδα προϊόντων (π.χ. 'Tablets') — έχει division/family/category και έναν PM" />
              <div className="docs-diagram-arrow">↓ έχει πολλά</div>
              <DocsEntityBox title="Cluster" sub="Πόσα faces εμφανίζονται για αυτό το Spacegroup, με ποιο status (π.χ. Live)" />
              <div className="docs-diagram-arrow">↔ αντιστοιχίζεται σε</div>
              <DocsEntityBox title="Κατάστημα" sub="Ποιο cluster ενός Spacegroup έχει ανατεθεί σε κάθε συγκεκριμένο κατάστημα (matrix)" />
              <div className="docs-diagram-arrow">↓ διαχειρίζεται από</div>
              <DocsEntityBox title="PM (Product Manager)" sub="Ένας ρόλος 'pm' που προτείνει αλλαγές μόνο για τα δικά του Spacegroups" />
            </div>
            <p>
              <b>Spacegroup</b> είναι μια ομάδα/κατηγορία προϊόντων (π.χ. "Tablets", "BI Καταψύκτες") — έχει
              μια ταξινόμηση (division → family → category, π.χ. "Technology" → "IT" → "Tablet") και έναν
              υπεύθυνο PM.
            </p>
            <p>
              <b>Cluster</b> είναι μια συγκεκριμένη "γραμμή" μέσα σε ένα Spacegroup — λέει πόσα "faces"
              (σημεία έκθεσης/παρουσίασης του προϊόντος) υπάρχουν, σε τι <b>status</b> είναι (π.χ. "Live" =
              ζωντανό/ενεργό, "Open" = ανοιχτό, "Box Moving" = υπό μετακίνηση), πόσα από αυτά είναι
              "non-display" (όχι σε προθήκη), και πόσα ξεχωριστά <b>SKUs τροφοδοτούνται</b> ("Feed") σε αυτό
              το κατάστημα για αυτό το Spacegroup — το Feed είναι ανεξάρτητο πεδίο από το Faces/Non-Display
              (βλ. "Display vs Feed & νέοι ρόλοι" παρακάτω για τη διάκριση). Έχει επίσης ένα προαιρετικό
              <b> Comment</b> — ελεύθερο κείμενο που περιγράφει τον τρόπο στησίματος/σε τι έπιπλο στήνεται,
              ορατό μέσω ενός μικρού <Icon name="info" size={11} /> δίπλα στον κωδικό του, ΚΑΙ στο Clusters
              ΚΑΙ στο Cluster ανά κατάστημα. Ένα Spacegroup μπορεί να έχει
              πολλά clusters ταυτόχρονα (π.χ. ένα cluster "c0" με status Live και ένα "c1CY" ειδικά για την
              Κύπρο) — και προαιρετικά, ανά ομάδα καταστήματος (Orange/Orange+/White/White+), μπορεί να
              οριστεί ποιοι από αυτούς τους κωδικούς επιτρέπονται εκεί (βλ. "Clusters ανά Store Tier"
              παρακάτω στην Πλοήγηση).
            </p>
            <p>
              <b>Cluster ανά κατάστημα</b> είναι το σημείο όπου η αφηρημένη έννοια "cluster" γίνεται
              συγκεκριμένη: λέει ότι το κατάστημα 7001 (π.χ. "Πυλαία Cosmos") έχει ανατεθεί το cluster "c0"
              του Spacegroup "Tablets". Είναι ένας πίνακας-matrix Καταστήματα × Spacegroups, όπου κάθε κελί
              περιέχει τον κωδικό του cluster που ισχύει εκεί.
            </p>
            <p>
              <b>PM (Product Manager)</b> είναι ο άνθρωπος που είναι υπεύθυνος για ένα σύνολο Spacegroups.
              Ο ρόλος λογαριασμού "pm" στην πλατφόρμα βλέπει και προτείνει αλλαγές <i>μόνο</i> στα δικά του
              Spacegroups (αυτά όπου είναι ορισμένος ως PM) — δεν βλέπει καν τα υπόλοιπα στο
              μενού του. Ένας PM είναι απλά ένας χρήστης με ρόλο "pm" (Διαχείριση → Χρήστες) — η ιδιοκτησία
              πάνω σε ένα Spacegroup δείχνει κατευθείαν στον λογαριασμό του, καμία ξεχωριστή σύνδεση.
              Μπορεί να μείνει "εν αναμονή" (γνωστός με Display Name, χωρίς login ακόμα, π.χ. από import)
              μέχρι να του δοθεί username/password ("Ενεργοποίηση λογαριασμού"). Το Διαχείριση → PMM δείχνει
              την ίδια λίστα read-only (Display Name, κατάσταση, πλήθος Spacegroups) για ρόλους που δεν έχουν
              πρόσβαση στην πλήρη λίστα Χρηστών.
            </p>
          </section>

          <section id="docs-domains" className="docs-section">
            <h2>Display vs Feed & νέοι ρόλοι</h2>
            <p>
              Κάθε cluster έχει πλέον <b>δύο ανεξάρτητα "concerns"</b> πάνω στην ίδια γραμμή — δεν είναι
              δύο ξεχωριστά clusters, είναι δύο ξεχωριστές ομάδες πεδίων στην ΙΔΙΑ γραμμή, με ξεχωριστά
              δικαιώματα επεξεργασίας/έγκρισης η καθεμία:
            </p>
            <div className="docs-diagram">
              <DocsEntityBox title="Display (τοποθέτηση)" sub="Faces, Status, Non-Display — τι βλέπει ο πελάτης live μέσα στο κατάστημα (σε προθήκη/ανοιχτό/υπό μετακίνηση)" />
              <div className="docs-diagram-arrow">↔ ίδια γραμμή, ξεχωριστό domain</div>
              <DocsEntityBox title="Feed (τροφοδοσία)" sub="Ένα μόνο πεδίο: πλήθος ΔΙΑΦΟΡΕΤΙΚΩΝ SKUs που τροφοδοτούνται στο κατάστημα — ΟΧΙ σύνολο τεμαχίων" />
            </div>
            <p>
              Η διάκριση προϋπάρχει λειτουργικά στις δύο εταιρικές ομάδες που τα διαχειρίζονται: το τμήμα
              <b> Space</b> αποφασίζει την τοποθέτηση/προβολή (πόσα faces θα φανούν στο ράφι, τι status
              έχει το σημείο), ενώ το τμήμα <b>On Demand</b> αποφασίζει/παρακολουθεί πόσοι διαφορετικοί
              κωδικοί (SKUs) στέλνονται συνολικά στο κατάστημα — ανεξάρτητα από το πόσα απ' αυτά καταλήγουν
              σε προβολή. Το Feed δεν είναι ποτέ μικρότερο από το "καλυμμένο" μέρος του Display, αλλά τα
              δύο νούμερα ενημερώνονται/εγκρίνονται εντελώς χωριστά.
            </p>
            <p>
              Γι' αυτό υπάρχουν <b>6 νέοι ρόλοι</b>, παράλληλα με τους 5 παλιούς (κανένας παλιός ρόλος δεν
              καταργήθηκε) — σε 2 τριάδες, μία ανά domain:
            </p>
            <ul className="docs-list">
              <li><b>Space Manager</b> — γράφει display πεδία απευθείας (σαν admin, αλλά μόνο display) ΚΑΙ εγκρίνει/απορρίπτει display αιτήματα.</li>
              <li><b>Space User</b> — υποβάλλει προτάσεις μόνο σε display πεδία (σαν pm, αλλά μόνο display· βλέπει ΟΛΑ τα spacegroups, όχι μόνο δικά του).</li>
              <li><b>Space Viewer</b> — read-only, μόνο display πεδία.</li>
              <li><b>On Demand Manager</b> — γράφει το πεδίο Feed απευθείας ΚΑΙ εγκρίνει/απορρίπτει feed αιτήματα.</li>
              <li><b>On Demand User</b> — υποβάλλει προτάσεις μόνο στο πεδίο Feed (βλέπει ΟΛΑ τα spacegroups).</li>
              <li><b>On Demand Viewer</b> — read-only, μόνο το πεδίο Feed.</li>
            </ul>
            <p>
              Σε αντίθεση με τον pm, <b>κανένας από τους 6 νέους ρόλους δεν έχει ownership-scoping</b> —
              βλέπουν/ενεργούν σε ΟΛΑ τα spacegroups, ο μόνος περιορισμός τους είναι ΠΟΙΟ domain επιτρέπεται
              να αγγίξουν. Ο Space User/On Demand User βλέπουν επίσης <i>όλα</i> τα αιτήματα στο tab
              "Αιτήματα" (όχι μόνο τα δικά τους, όπως ο pm) — read-only για το domain που δεν είναι δικό
              τους.
            </p>
            <p>
              <b>Κλείδωμα ανά domain.</b> Το παλιό "PM Locked" toggle (ένα μόνο λουκέτο) έγινε ένα modal με
              δύο ανεξάρτητα checkboxes — "Κλείδωμα Display" και "Κλείδωμα Feed" — προσβάσιμο από το
              λουκέτο δίπλα σε κάθε spacegroup (Διαχείριση → Spacegroups). Ένας Space Manager βλέπει/αλλάζει
              ΜΟΝΟ το δικό του checkbox, ένας On Demand Manager μόνο το δικό του· admin/super_admin βλέπουν
              και τα δύο. Κλείδωμα Display αφήνει ακόμα εξαίρεση στο Non-Display (ίδιο με πριν)· κλείδωμα
              Feed μπλοκάρει 100% οποιαδήποτε πρόταση feed, χωρίς καμία εξαίρεση.
            </p>
            <p>
              <b>Χρωματικό σύστημα.</b> Στο tab "Αιτήματα" και στη στήλη "Έγκριση" του Clusters, κάθε
              αίτημα/κατάσταση χρωματίζεται ανά domain — <span className="pill domain-pill display">Display</span>{' '}
              μπλε, <span className="pill domain-pill feed">Feed</span> πορτοκαλί. Μια ανοιχτόχρωμη/σκούρη
              απόχρωση του ίδιου χρώματος δείχνει επιπλέον αν η αλλαγή αφορά ολόκληρο το cluster ή ένα
              μεμονωμένο κελί matrix/κατάστημα.
            </p>
          </section>

          <section id="docs-roles" className="docs-section">
            <h2>Ρόλοι & δικαιώματα</h2>
            <p>Κάθε λογαριασμός έχει ακριβώς έναν από τους 11 παρακάτω ρόλους:</p>
            <div className="tablewrap">
              <table className="docs-table">
                <thead>
                  <tr><th>Ρόλος</th><th>Τι βλέπει</th><th>Τι αλλάζει απευθείας</th><th>Σημείωση</th></tr>
                </thead>
                <tbody>
                  {DOCS_ROLE_ROWS.map((r) => (
                    <tr key={r.role}>
                      <td><span className="pill role">{ROLE_LABELS[r.role] || r.role}</span></td>
                      <td>{r.sees}</td>
                      <td>{r.writes}</td>
                      <td className="muted">{r.note}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </section>

          <section id="docs-navigation" className="docs-section">
            <h2>Πλοήγηση στην πλατφόρμα</h2>
            <p>
              Το πάνω μενού (navbar) αλλάζει ανάλογα με τον ρόλο σου — κάθε καρτέλα ελέγχει ξεχωριστά ποιος
              τη βλέπει, οπότε δύο χρήστες με διαφορετικό ρόλο μπορεί να βλέπουν διαφορετικό αριθμό
              καρτελών. Παρακάτω είναι το πλήρες σύνολο (ό,τι θα δει ένας super_admin):
            </p>
            <ul className="docs-list">
              <li><b>Dashboard</b> — συγκεντρωτικά στατιστικά δικτύου, οργανωμένα σε widgets μέσα στην ενότητα "Επισκόπηση δικτύου": <i>Σύνολα δικτύου</i> (καταστήματα, σύνολο Net/Gross τετραγωνικών, σύνολο clusters), <i>Καταστήματα ανά Tier</i> (πλήθος καταστημάτων ανά Orange/Orange+/White/White+), <i>Faces ανά τύπο (Display)</i> (άθροισμα Live/Open/Box Moving/Non-Display σε όλο το δίκτυο, ΟΧΙ Feed), <i>Πυκνότητα Display</i> (Live+Open+Box Moving ανατεθειμένα σε πραγματικά καταστήματα, ανά τετραγωνικό net), και <i>Θέση cluster ανά Store Tier</i> — δείχνει, ανά tier, πόσες αναθέσεις καταστημάτων πέφτουν στη 1η/2η/3η... δηλωμένη θέση (min→max) του "Clusters ανά Store Tier" κανόνα τους, ή σε "Χωρίς κανόνα" αν δεν υπάρχει καν δηλωμένος κανόνας. Πλήρως προσαρμόσιμο ανά λογαριασμό: κάθε widget μπορεί να αλλάξει μέγεθος (XS/S/M/L/XL/Full), να κρυφτεί/ξαναφανεί, και να μετακινηθεί με drag-and-drop μέσα στο section του — πατώντας "Προσαρμογή" πάνω δεξιά. Ορατό σε super_admin/moderator/space_manager/on_demand_manager.</li>
              <li><b>Clusters</b> — πλήρης λίστα όλων των clusters, με φίλτρα (spacegroup/status/division/family/category/PM/<b>Έγκριση</b> κ.λπ.), αναζήτηση, ταξινόμηση ανά στήλη, και επιλογή ορατών στηλών. Η στήλη "Feed (SKUs)" δείχνει το πλήθος τροφοδοτούμενων κωδικών δίπλα στο Faces. Η στήλη "Έγκριση" είναι υπολογισμένη (όχι ελεύθερο πεδίο) — δείχνει αν κάτι εκκρεμεί σε display, feed, ή και τα δύο, με το αντίστοιχο χρώμα· το dropdown φίλτρου δείχνει μόνο τις καταστάσεις που πραγματικά υπάρχουν αυτή τη στιγμή. Δίπλα σε κάθε κωδικό cluster που έχει <b>Comment</b> (προαιρετικό ελεύθερο κείμενο — τρόπος στησίματος/έπιπλο) εμφανίζεται ένα μικρό <Icon name="info" size={11} />, click ανοίγει το σχόλιο σε popover. Μια γραμμή με εκκρεμές αίτημα έχει επιπλέον κουμπί "Δες αίτημα" στις Ενέργειες, που πάει κατευθείαν στη σχετική κάρτα στο tab Αιτήματα. Εδώ ανοίγει και το drawer επεξεργασίας/πρότασης — ποια πεδία φαίνονται/είναι επεξεργάσιμα εξαρτάται από τον ρόλο σου (display-only ρόλοι δεν βλέπουν καν το Feed, και αντίστροφα· το Comment είναι display-domain, ίδια δικαιώματα με Faces/Status/Non-Display). Πάνω από τον κύριο πίνακα εμφανίζεται (μόνο όταν υπάρχει κάτι) η ενότητα <b>"Clusters σε αναμονή δημιουργίας"</b> — νέες γραμμές που περιμένουν ακόμα έγκριση στο ένα ή και στα δύο domains, βλ. "Πώς μπαίνουν τα δεδομένα".</li>
              <li><b>Cluster ανά κατάστημα</b> — matrix Καταστήματα × Spacegroups: κάθε κελί δείχνει ποιο cluster ισχύει σε αυτό το κατάστημα για αυτό το Spacegroup (πάντα display domain — το matrix δεν έχει feed κελιά). Αν το spacegroup έχει δηλωμένους κανόνες στο tab "Clusters ανά Store Tier" για την ομάδα καταστήματος (tier) αυτού του καταστήματος, το dropdown του κελιού δείχνει <i>μόνο</i> τους δηλωμένους κωδικούς για εκείνο το tier — χωρίς κανόνα, δείχνει όλους τους κωδικούς του spacegroup όπως πριν. Δίπλα σε κάθε γεμάτο κελί με Comment εμφανίζεται το ίδιο <Icon name="info" size={11} /> popover με το Clusters tab. Μπορείς να διαλέξεις ποια Spacegroups/καταστήματα θέλεις να δεις (picker, ώστε να μη φορτώνει άσκοπα ολόκληρο το δίκτυο), να αντιστρέψεις τον πίνακα (transposed), να ομαδοποιήσεις καταστήματα ανά ομάδα (Orange/Orange+/White/White+), και να φιλτράρεις κελιά κατά "Κατάσταση κελιού" (Όλα/Δικό μου/Εκκρεμεί/Ίδιο cluster) — τα μη-ταιριαστά κελιά γίνονται αχνά αντί να εξαφανίζονται, ώστε να μη χάνεται το context.</li>
              <li><b>Τετραγωνικά</b> (μέσα στη "Διαχείριση") — τετραγωνικά μέτρα (Net/Gross) ανά όροφο ανά κατάστημα (Ισόγειο, Υπόγειο, Πατάρι, 1ος-4ος όροφος), με το Net και το Gross κάθε ορόφου δίπλα-δίπλα, και σύνολα ανά κατάστημα.</li>
              <li><b>Αιτήματα</b> — κάθε κάρτα δείχνει ρητά τον τύπο του αιτήματος (π.χ. "Feed · Cluster", "Display · Κατάστημα", "Νέο Spacegroup", χρωματισμένο ανάλογα) και τα συγκεκριμένα καταστήματα που επηρεάζει. Για pm/space_user/on_demand_user: υποβολή νέων προτάσεων και παρακολούθηση της κατάστασής τους (εκκρεμές/εγκεκριμένο/απορριφθέν, μόνο δικά τους για τον pm — όλα, read-only εκτός domain, για τους δύο νέους submit-only ρόλους)· για moderator/admin/super_admin/space_manager/on_demand_manager: έγκριση ή απόρριψη εκκρεμών αιτημάτων (κάθε Manager μόνο στο δικό του domain), με αριθμό εκκρεμών να φαίνεται σαν badge πάνω στην καρτέλα. Ένα νέο αίτημα εμφανίζει αυτόματα ένα popup στους αρμόδιους εγκριτές με την ίδια αναλυτική πληροφορία (τύπος + κατάστημα + διαφορές).</li>
              <li><b>Documentation</b> — αυτή η σελίδα. Ορατή σε όλους τους συνδεδεμένους λογαριασμούς.</li>
              <li><b>Platform Summary</b> — σύνδεσμος (ανοίγει σε νέα καρτέλα) προς ένα δημοσιευμένο interactive deck με επισκόπηση της πλατφόρμας για διοίκηση/προϊστάμενους. Ορατός σε όλους, κάτω-κάτω στο sidebar ακριβώς κάτω από το Documentation.</li>
              <li><b>Διαχείριση → Spacegroups</b> — πλήρης λίστα spacegroups, εκχώρηση PM, αλλαγή κατηγορίας, κλείδωμα Display/Feed (modal με 2 ανεξάρτητα checkboxes, βλ. "Display vs Feed & νέοι ρόλοι"), μετονομασία/δημιουργία/διαγραφή id (super_admin/admin). Το εικονίδιο <Icon name="layers" size={11} /> σε κάθε γραμμή πάει κατευθείαν στο "Clusters ανά Store Tier" γι' αυτό το spacegroup.</li>
              <li><b>Clusters ανά Store Tier</b> — ορίζει, ανά spacegroup και ανά ομάδα καταστήματος (Orange/Orange+/White/White+), ποιοι cluster κωδικοί επιτρέπονται στο "Cluster ανά κατάστημα" matrix — μια ταξινομημένη λίστα ΑΝΟΙΧΤΟΥ μήκους (min→max, χωρίς σκληρό όριο πλήθους), ο ίδιος κωδικός μπορεί ελεύθερα να συμμετέχει σε παραπάνω από 1 ομάδα. Spacegroup χωρίς δηλωμένους κανόνες συνεχίζει να δουλεύει όπως πριν (κανένας περιορισμός) — opt-in ανά spacegroup. Δύο τρόποι προβολής: <i>Ανά Spacegroup</i> (αναζήτηση+επιλογή ενός spacegroup, επεξεργασία και των 4 ομάδων μαζί) και <i>Ανά Ομάδα Καταστήματος</i> (επιλογή tier — με Ctrl/Cmd+click πολλαπλή επιλογή, μία στήλη ανά tier — λίστα όλων των spacegroups με τους δηλωμένους κωδικούς τους για κάθε επιλεγμένο tier). Το toggle "Εμφάνιση faces &amp; σχολίων" δείχνει μόνιμα (αντί για hover) τα Live/Open/Box Moving/Non-Display/Feed και το Comment κάθε δηλωμένου κωδικού. Προβολή ορατή σε όποιον βλέπει Spacegroups· επεξεργασία μόνο σε moderator/space_manager/admin/super_admin (ΟΧΙ on_demand_manager — η επιλογή cluster είναι display-domain απόφαση).</li>
              <li><b>Διαχείριση → Χρήστες</b> (super_admin πλήρως, admin βλέπει τη λίστα + επεξεργάζεται Display Name/Τίτλο/Τηλέφωνο/Email/Team Email) — δημιουργία λογαριασμών από το μηδέν, "Ενεργοποίηση λογαριασμού" σε υπάρχον role=pm row χωρίς username (π.χ. από import), αλλαγή ρόλου (και οι 11 ρόλοι διαθέσιμοι), reset password, ενεργοποίηση/απενεργοποίηση, διαγραφή (με πλήρη προεπισκόπηση τι θα επηρεαστεί πριν διαγραφεί κανείς) — μόνο super_admin. Δες "Προφίλ χρήστη & στοιχεία επικοινωνίας" παρακάτω για τα νέα πεδία.</li>
              <li><b>Διαχείριση ομάδας</b> (space_manager/on_demand_manager/space_user/on_demand_user, κάτω-κάτω στο sidebar πάνω από το Documentation) — ίδιος πίνακας με το Χρήστες, με δύο επίπεδα πρόσβασης:
                <ul className="docs-list">
                  <li><i>Επεξεργασία</i> — space_manager/on_demand_manager δημιουργούν/επεξεργάζονται (Display Name/Τίτλο/Τηλέφωνο/Email/Team Email/κατάσταση) τους δικούς τους space_user/space_viewer ή on_demand_user/on_demand_viewer λογαριασμούς, ΚΑΙ λογαριασμούς pm (κοινός πόρος — και οι δύο τύποι manager μπορούν να δημιουργήσουν/ενεργοποιήσουν/μετονομάσουν/ενεργοποιήσουν-απενεργοποιήσουν pm, αφού ένας PM δεν ανήκει αποκλειστικά σε ένα τμήμα)· space_user/on_demand_user μπορούν αντίστοιχα να δημιουργήσουν/επεξεργαστούν ΜΟΝΟ τον έναν viewer ρόλο ακριβώς από κάτω τους (space_viewer/on_demand_viewer). Καμία αλλαγή ρόλου, reset password, ή διαγραφή σε καμία περίπτωση — παραμένουν super_admin only. Μετά τη δημιουργία ενός pm, ο ίδιος manager μπορεί να τον αναθέσει σε ένα spacegroup από το Διαχείριση → Spacegroups.</li>
                  <li><i>Προβολή (read-only)</i> — και οι 4 αυτοί ρόλοι βλέπουν ΕΠΙΠΛΕΟΝ, χωρίς να μπορούν να το αλλάξουν, μια ευρύτερη εικόνα με πλήρη στοιχεία (τηλέφωνο/email/κ.λπ.): οι managers βλέπουν ΚΑΙ την άλλη ομάδα, τους moderators, και όλους τους viewers (τους pm πλέον τους επεξεργάζονται, βλ. πάνω)· οι space_user/on_demand_user βλέπουν τους managers, τους pm, και όλους τους submit/viewer ρόλους και των δύο τμημάτων. Σε καμία περίπτωση δεν εμφανίζονται admin/super_admin λογαριασμοί σε αυτή τη λίστα, ανεξάρτητα από ρόλο.</li>
                </ul>
              </li>
              <li><b>Διαχείριση → PMM</b> — read-only λίστα των PMs: Display Name, κατάσταση, πόσα Spacegroups κατέχει ο καθένας. Καμία ενέργεια εδώ — rename/activate/delete γίνονται από το Χρήστες (super_admin/admin) ή το Διαχείριση ομάδας (space_manager/on_demand_manager, εκτός από delete).</li>
              <li><b>Διαχείριση → Import</b> (super_admin/admin) — μαζική εισαγωγή δεδομένων μέσω CSV/JSON, με προεπισκόπηση πριν την εφαρμογή. Αναλυτικά παρακάτω.</li>
              <li><b>Διαχείριση → Καταστήματα</b> — λίστα καταστημάτων, δημιουργία νέου, χώρα (GR/CY), ομάδα καταστήματος.</li>
            </ul>
            <p>
              Πάνω δεξιά στο μενού, δίπλα στον ρόλο/λογαριασμό σου, βρίσκεται το <b>τρι-state κουμπί
              θέματος</b> (☀️/🌙/🖥️) — βλ. "Θέμα εμφάνισης & desktop-only" παρακάτω.
            </p>
          </section>

          <section id="docs-search" className="docs-section">
            <h2>Αναζήτηση, φίλτρα & προσαρμογή</h2>
            <p>
              Σχεδόν κάθε λίστα/πίνακας στην πλατφόρμα (Clusters, Cluster ανά κατάστημα, Spacegroups,
              Τετραγωνικά, Αιτήματα) έχει αναζήτηση με ελεύθερο κείμενο, φίλτρα (dropdown ανά στήλη ή
              πεδίο), και ταξινόμηση πατώντας πάνω σε μια στήλη. Οι επιλογές φίλτρων φαίνονται πάντα
              περιορισμένες σε ό,τι πραγματικά υπάρχει δεδομένα (cascading) — αν διαλέξεις μια Division,
              τα Family/Category dropdown δείχνουν μόνο τιμές που ταιριάζουν σε αυτή τη Division.
            </p>
            <p>
              <b>Ό,τι φιλτράρεις/αναζητάς/ταξινομείς αποθηκεύεται αυτόματα, ανά λογαριασμό, στον server</b> —
              όχι μόνο στο browser. Αυτό σημαίνει: αν κλείσεις τη σελίδα και ξανανοίξεις, αν κάνεις
              reload/refresh, ή αν συνδεθείς από άλλον υπολογιστή με τον ίδιο λογαριασμό, θα βρεις τα ίδια
              ακριβώς φίλτρα/αναζήτηση/ταξινόμηση όπως τα άφησες την τελευταία φορά — καμία ρύθμιση δεν
              χάνεται. Κάθε αλλαγή αποθηκεύεται μόνη της λίγο μετά την ενέργεια (χωρίς να χρειάζεται κουμπί
              "Αποθήκευση"). Το ίδιο ισχύει και για την προσαρμογή του Dashboard (μέγεθος/σειρά/απόκρυψη
              widgets) και για το ποιες στήλες έχεις κρύψει σε έναν πίνακα.
            </p>
            <p>
              Στο tab <b>Αιτήματα</b> συγκεκριμένα, τα συγκεντρωτικά νούμερα πάνω-πάνω ("Matrix: X
              εκκρεμούν, Y εγκρίθηκαν, Z απορρίφθηκαν" και το ίδιο για "Clusters") είναι <b>clickable</b> —
              πατώντας π.χ. πάνω στο "2 εγκρίθηκαν" των Clusters, η λίστα από κάτω φιλτράρεται αμέσως σε
              ακριβώς αυτά (τύπος=Clusters, κατάσταση=Εγκρίθηκαν), χωρίς να χρειάζεται να ανοίξεις το
              drawer "Φίλτρα" και να τα διαλέξεις χειροκίνητα.
            </p>
          </section>

          <section id="docs-data-flow" className="docs-section">
            <h2>Πώς μπαίνουν τα δεδομένα</h2>
            <p>
              Υπάρχουν δύο εντελώς διαφορετικοί δρόμοι για να αλλάξει κάτι στην πλατφόρμα, ανάλογα με τον
              ρόλο του λογαριασμού. Ο λόγος που υπάρχουν δύο δρόμοι είναι ελέγχου: ένας pm/Space
              User/On Demand User ξέρει καλύτερα τα δικά του προϊόντα, αλλά η τελική έγκριση μιας αλλαγής
              περνάει πάντα από κάποιον με ευρύτερη εικόνα, ώστε να μην μπει λάθος ή αντικρουόμενη
              πληροφορία απευθείας στα ζωντανά δεδομένα.
            </p>
            <div className="docs-flow-cols">
              <div className="docs-flow-col">
                <h3>Απευθείας επεξεργασία</h3>
                <p className="muted">super_admin / admin (display & feed) · space_manager (μόνο display) · on_demand_manager (μόνο feed)</p>
                <p>
                  Η αλλαγή εφαρμόζεται αμέσως, χωρίς κανένα ενδιάμεσο βήμα ή έγκριση — π.χ. αλλαγή
                  faces/status σε ένα cluster από το tab Clusters, ανάθεση cluster σε κατάστημα από το
                  matrix "Cluster ανά κατάστημα", ή αλλαγή ιδιοκτησίας/κατηγορίας ενός Spacegroup. Ο
                  server ελέγχει ΑΝΑ ΠΕΔΙΟ ποιος ρόλος επιτρέπεται να το αλλάξει απευθείας — ένας
                  space_manager μπορεί να αλλάξει Faces αλλά ΟΧΙ Feed στο ίδιο PUT request, κι αντίστροφα
                  για τον on_demand_manager. Κάθε τέτοια αλλαγή αρχειοθετεί αυτόματα την προηγούμενη τιμή
                  (ιστορικό) πριν γραφτεί η νέα, ώστε να υπάρχει πάντα ίχνος τι άλλαξε και πότε.
                </p>
              </div>
              <div className="docs-flow-col">
                <h3>Πρόταση → Έγκριση</h3>
                <p className="muted">pm / space_user / on_demand_user προτείνουν · moderator/admin/super_admin/space_manager/on_demand_manager εγκρίνουν (ο καθένας στο δικό του domain)</p>
                <ol>
                  <li>Ο χρήστης βλέπει ένα cluster/κελί matrix/spacegroup και πατάει επεξεργασία — αλλά αντί να αποθηκευτεί κατευθείαν, η αλλαγή μπαίνει στο <b>"Σύνολο Αλλαγών"</b> (το καλάθι, πάνω δεξιά) σαν πρόχειρο (draft) — δεν είναι ακόμα ορατή πουθενά αλλού.</li>
                  <li>Μπορεί να προσθέσει πολλές αλλαγές στο καλάθι πριν τις υποβάλει όλες μαζί. Αν αλλάξει ΚΑΙ display ΚΑΙ feed πεδία στο ίδιο cluster μέσα στο ίδιο drawer (μόνο ο pm μπορεί, βλέπει και τα δύο), αυτό γίνεται αυτόματα <b>2 ξεχωριστά αιτήματα</b> στο καλάθι — ένα ανά domain — ώστε να εγκρίνονται ανεξάρτητα.</li>
                  <li>Πατάει <b>"Υποβολή"</b> — κάθε πρόχειρο του καλαθιού γίνεται ένα εκκρεμές <b>αίτημα</b> (change request), ορατό στο tab "Αιτήματα".</li>
                  <li>Ο αρμόδιος εγκριτής για το domain αυτού του αιτήματος (display → moderator/admin/super_admin/space_manager, feed → moderator/admin/super_admin/on_demand_manager) βλέπει το πριν/μετά και την <b>εγκρίνει</b> ή την <b>απορρίπτει</b> (προαιρετικά με σημείωση). Μόνο τη στιγμή της έγκρισης η αλλαγή γράφεται πραγματικά στα δεδομένα — μια απόρριψη δεν αφήνει κανένα ίχνος στα πραγματικά δεδομένα.</li>
                </ol>
                <p className="muted">
                  Γι' αυτό μια πρόταση δεν φαίνεται αμέσως στο υπόλοιπο σύστημα — περιμένει έγκριση. Αν ένα
                  Spacegroup έχει κλειδωμένο το <b>Display</b>, επιτρέπεται πρόταση ΜΟΝΟ στο πεδίο
                  Non-Display ενός υπάρχοντος cluster — καμία άλλη display αλλαγή, καμία νέα γραμμή. Αν
                  έχει κλειδωμένο το <b>Feed</b>, ΚΑΜΙΑ πρόταση feed δεν επιτρέπεται καθόλου — χωρίς καμία
                  εξαίρεση (τα δύο κλειδώματα είναι ανεξάρτητα, βλ. "Display vs Feed & νέοι ρόλοι").
                </p>
              </div>
              <div className="docs-flow-col">
                <h3>Δημιουργία νέας γραμμής (υβριδικό)</h3>
                <p className="muted">space_manager / on_demand_manager / pm ανοίγουν νέα γραμμή · το άλλο domain πάντα περνάει από έγκριση</p>
                <ol>
                  <li>Ο χρήστης πατάει <b>"+ Νέα γραμμή"</b> στο tab Clusters, διαλέγει Spacegroup, γράφει έναν καινούριο κωδικό cluster, και συμπληρώνει και τα 5 πεδία (Faces/Status/Non-Display/Feed) — ακόμα κι αν δεν έχει δικαίωμα να γράψει απευθείας και τα δύο domains.</li>
                  <li>Στο "Υποβολή", η γραμμή σπάει σε <b>2 μισά</b> (ένα display, ένα feed) που μοιράζονται τον ίδιο <b>κωδικό αναφοράς</b> (π.χ. "#34") — ώστε ένας Space Manager κι ένας On Demand Manager να μπορούν να μιλάνε για την "ίδια" πρόταση μεταξύ τους, παρότι είναι 2 ξεχωριστές εγγραφές από κάτω. Το domain που ο χρήστης έχει δικαίωμα να γράψει απευθείας (π.χ. Display για έναν Space Manager) εφαρμόζεται <b>αμέσως</b> — το άλλο μπαίνει <b>εκκρεμές</b> στο σωστό τμήμα. Ένας pm δεν έχει δικαίωμα σε κανένα domain, οπότε και τα δύο μισά ξεκινούν εκκρεμή.</li>
                  <li>Όσο εκκρεμεί έστω το ένα μισό, η νέα γραμμή <b>ΔΕΝ εμφανίζεται</b> στον κύριο πίνακα Clusters — ζει μόνο στον ξεχωριστό πίνακα <b>"Clusters σε αναμονή δημιουργίας"</b>, πάνω από τον κύριο πίνακα, με προεπισκόπηση των τιμών και που ήδη εγκρίθηκαν.</li>
                  <li>Ο αρμόδιος εγκριτής του εκκρεμούς μισού το βλέπει σαν ΜΙΑ ενιαία κάρτα στο tab "Αιτήματα" (με τα 2 domains σαν ξεχωριστές γραμμές μέσα στην ίδια κάρτα, ίδιος κοινός κωδικός) και το εγκρίνει ή το απορρίπτει. Μόλις εγκριθούν <b>και τα δύο</b> μισά, η γραμμή εμφανίζεται κανονικά στον κύριο πίνακα Clusters — αν απορριφθεί το ένα μισό, το άλλο ακυρώνεται αυτόματα μαζί του (καμία ημιτελής γραμμή μένει σε εκκρεμότητα για πάντα).</li>
                </ol>
                <p className="muted">
                  Ο PM του Spacegroup ειδοποιείται και τις 2 φορές — όταν ξεκινάει η πρόταση δημιουργίας ΚΑΙ
                  όταν ολοκληρώνεται. Ισχύουν τα ίδια κλειδώματα Display/Feed με την "Πρόταση → Έγκριση".
                </p>
              </div>
            </div>
          </section>

          <section id="docs-profile" className="docs-section">
            <h2>Προφίλ χρήστη & στοιχεία επικοινωνίας</h2>
            <p>
              Κάθε λογαριασμός έχει, εκτός από Display Name, 4 προαιρετικά πεδία: <b>Τίτλος</b> (π.χ.
              "Senior Merchandiser"), <b>Τηλέφωνο</b>, <b>Email</b> και <b>Team Email</b> (ομαδικό
              email, διαφορετικό από το προσωπικό). Κανένα δεν είναι υποχρεωτικό.
            </p>
            <p>
              Αν συμπληρωθεί <b>Τίτλος</b>, αυτός εμφανίζεται <i>αντί</i> για τον ρόλο (π.χ. "Space
              Manager") παντού όπου φαίνεται το όνομά σου δίπλα σε κάποιο "position" — πάνω δεξιά στο
              μενού, και στη γραμμή "Υπέβαλε/Ενέκρινε/Απέρριψε" μιας κάρτας αιτήματος. Αν μείνει κενό,
              συνεχίζει να δείχνει τον ρόλο όπως πριν — καμία αλλαγή συμπεριφοράς αν δεν το χρησιμοποιήσεις.
            </p>
            <p>
              <b>Ποιος τα ρυθμίζει.</b> Ένας admin/super_admin μπορεί να συμπληρώσει/αλλάξει τα 4 πεδία
              για <i>οποιονδήποτε</i> χρήστη, απευθείας πάνω στη γραμμή του στο Διαχείριση → Χρήστες
              (inline, αποθηκεύεται μόλις φύγεις από το πεδίο). Ο ίδιος ο χρήστης μπορεί επίσης να τα
              συμπληρώσει/αλλάξει <i>μόνος του</i> για τον εαυτό του, μέσα από{' '}
              <b>Λογαριασμός → Ρυθμίσεις λογαριασμού</b> — καμία ειδική άδεια δεν χρειάζεται για αυτό. Το
              Display Name παραμένει το μόνο από τα βασικά στοιχεία που αλλάζει ΜΟΝΟ admin/super_admin
              (ούτε καν ο ίδιος ο χρήστης), αφού συνδέεται με ιστορικό/audit trail αιτημάτων.
            </p>
            <p>
              <b>Κλικ πάνω σε όνομα.</b> Οπουδήποτε εμφανίζεται το όνομα ενός χρήστη μέσα στην πλατφόρμα —
              σε μια κάρτα αιτήματος (ποιος υπέβαλε, ποιος ενέκρινε/απέρριψε), στη στήλη "PM" των Clusters
              ή των Spacegroups, στο "Δημιουργήθηκε από" των Χρηστών, ή στη λίστα PMM — το όνομα είναι
              clickable και ανοίγει ένα μικρό παράθυρο με τα στοιχεία του: φωτογραφία, Ονοματεπώνυμο,
              Τίτλος (ή ρόλος αν δεν έχει τίτλο), Τηλέφωνο, Email και Team Email. Έτσι όποιος δει ένα
              αίτημα ή μια ανάθεση μπορεί αμέσως να δει ΠΟΙΟΣ είναι ο άνθρωπος πίσω από το όνομα και πώς να
              επικοινωνήσει μαζί του, χωρίς να χρειάζεται να ψάξει στο Διαχείριση → Χρήστες.
            </p>
          </section>

          <section id="docs-appearance" className="docs-section">
            <h2>Θέμα εμφάνισης & desktop-only</h2>
            <p>
              Πάνω δεξιά στο μενού υπάρχει ένα τρι-state κουμπί με 3 επιλογές — <b>Φωτεινό</b> (☀️),
              <b> Σκοτεινό</b> (🌙), και <b>Σύστημα</b> (🖥️, ακολουθεί αυτόματα τη ρύθμιση του
              λειτουργικού σου συστήματος, ζωντανά αν το αλλάξεις όσο η εφαρμογή είναι ανοιχτή). Η αλλαγή
              εφαρμόζεται αμέσως, χωρίς reload, και <b>αποθηκεύεται στον λογαριασμό σου server-side</b> —
              αν συνδεθείς από άλλον υπολογιστή ή browser, θα δεις το ίδιο θέμα που είχες επιλέξει τελευταία
              φορά. Η οθόνη σύνδεσης (πριν καν μπεις) ακολουθεί πάντα τη ρύθμιση του συστήματός σου, αφού
              δεν υπάρχει ακόμα λογαριασμός να θυμηθεί προτίμηση.
            </p>
            <p>
              Η πλατφόρμα είναι σχεδιασμένη <b>μόνο για desktop/tablet</b> — τα δεδομένα (πυκνοί πίνακες,
              το matrix Καταστήματα × Spacegroups) δεν προσαρμόζονται σε μικρή οθόνη κινητού. Αν ανοίξεις
              την εφαρμογή σε στενό παράθυρο/κινητό (κάτω από ~900px πλάτος), βλέπεις ένα μήνυμα που σου
              ζητάει να χρησιμοποιήσεις υπολογιστή αντί για το ίδιο το περιεχόμενο — φαρδαίνοντας ξανά το
              παράθυρο η εφαρμογή εμφανίζεται κανονικά, χωρίς να χρειάζεται να ξαναφορτώσεις τη σελίδα.
            </p>
          </section>

          <section id="docs-import" className="docs-section">
            <h2>Εισαγωγή δεδομένων (Import)</h2>
            <p>
              Το tab <b>Διαχείριση → Import</b> (super_admin/admin) δέχεται μαζική εισαγωγή δεδομένων
              μέσω CSV — χρήσιμο όταν έχεις έτοιμα δεδομένα σε spreadsheet/export από άλλο σύστημα
              και θέλεις να τα φορτώσεις μαζικά αντί να τα καταχωρείς ένα-ένα από τη φόρμα.
            </p>
            <p>
              Κάθε import ακολουθεί την ίδια, δίστιγμη διαδικασία: πρώτα επικολλάς/ανεβάζεις το αρχείο,
              μετά πατάς <b>"Έλεγχος"</b> — η πλατφόρμα δείχνει μια πλήρη προεπισκόπηση, γραμμή-γραμμή, τι
              θα γίνει (νέα εγγραφή / ενημέρωση υπάρχουσας / χωρίς αλλαγή / σφάλμα) <i>χωρίς να γράψει
              τίποτα ακόμα</i>. Μόνο αφού δεις και εγκρίνεις αυτή την προεπισκόπηση, πατάς "Εφαρμογή" και
              οι αλλαγές γράφονται πραγματικά. Αν μια γραμμή έχει σφάλμα (π.χ. αναφέρεται σε spacegroup που
              δεν υπάρχει ακόμα), παραλείπεται — δεν μπλοκάρει τις υπόλοιπες σωστές γραμμές.
            </p>
            <p>
              Η σειρά με την οποία κάνεις τα imports έχει σημασία — κάποια entities αναφέρονται σε άλλα
              (π.χ. ένα Cluster πρέπει να δείχνει σε Spacegroup που ήδη υπάρχει). Η στήλη "Προαπαιτούμενο"
              παρακάτω δείχνει ακριβώς τι πρέπει να έχει προηγηθεί. Αν ξεκινάς από άδεια βάση, δες την
              πλήρη σειρά στην ενότητα{isSuperAdmin ? ' "Επαναφορά βάσης από το μηδέν" παρακάτω' : ' "Επαναφορά βάσης από το μηδέν" (ορατή μόνο σε super_admin)'}.
            </p>
            <div className="tablewrap">
              <table className="docs-table">
                <thead>
                  <tr><th>Import</th><th>Στήλες</th><th>Προαπαιτούμενο</th></tr>
                </thead>
                <tbody>
                  {Object.entries(IMPORT_ENTITIES).map(([key, e]) => (
                    <tr key={key}>
                      <td><b>{e.label}</b></td>
                      <td className="mono">{e.isJson ? 'JSON upload (δομή: ανά ομάδα καταστήματος → λίστα καταστημάτων)' : e.columns.join(', ')}</td>
                      <td>{DOCS_IMPORT_PREREQS[key] || '—'}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </section>

          {isSuperAdmin ? (
            <section id="docs-rebuild" className="docs-section">
              <h2>Επαναφορά βάσης από το μηδέν</h2>
              <p className="docs-warn">
                Ορατό μόνο σε super_admin. Ακολούθησε αυτή τη σειρά αν η βάση δεδομένων διαγραφεί
                εντελώς και χρειάζεται να ξαναχτιστεί από την αρχή — η σειρά έχει σημασία, γιατί
                κάθε επόμενο βήμα βασίζεται σε δεδομένα του προηγούμενου.
              </p>
              <ol className="docs-runbook">
                <li>
                  <b>Δημιούργησε το schema.</b> Τρέξε το <span className="mono">sql/schema_all.sql</span> στο
                  Supabase SQL Editor — φτιάχνει όλους τους πίνακες και γεμίζει τους 5 ρόλους.
                </li>
                <li>
                  <b>Δημιούργησε τον πρώτο λογαριασμό.</b> Από τη γραμμή εντολών:{' '}
                  <span className="mono">node scripts/create-super-admin.js &lt;username&gt; &lt;password&gt;</span>.
                  Χωρίς αυτόν δεν μπορεί να συνδεθεί κανείς στην πλατφόρμα.
                </li>
                <li>
                  <b>Καταστήματα.</b> Καμία εξάρτηση — αλλά χρειάζεται πριν από το Spacegroups
                  ownership matching και πριν το Cluster ανά κατάστημα (βήμα 6). Η ομάδα
                  καταστήματος (Orange/Orange+/White/White+) είναι προαιρετική στήλη μέσα σε
                  αυτό το ίδιο import, όχι ξεχωριστό βήμα.
                </li>
                <li>
                  <b>Spacegroups.</b> Καμία εξάρτηση — αν η στήλη PM αναφέρει όνομα που
                  δεν υπάρχει ακόμα στο roster, δημιουργείται αυτόματα και ματσάρεται με το
                  spacegroup στο ίδιο βήμα (δεν χρειάζεται ξεχωριστό PMs import πρώτα).
                </li>
                <li>
                  <b>Clusters.</b> Κάθε γραμμή αναφέρεται σε ένα Spacegroup (βήμα 4) — πρέπει να
                  υπάρχει ήδη, αλλιώς η γραμμή απορρίπτεται. Κάθε γραμμή χρειάζεται τιμή (0 έγκυρη)
                  και στα 5 πεδία Live/Open/Box Moving/Non-Display/Feed.
                </li>
                <li>
                  <b>Cluster ανά κατάστημα.</b> Κάθε γραμμή αναφέρεται σε ένα Κατάστημα (βήμα 3) και
                  ένα Spacegroup (βήμα 4) — και τα δύο πρέπει να υπάρχουν ήδη.
                </li>
                <li>
                  <b>Προαιρετικά, όποτε βολεύει μετά το αντίστοιχο βασικό βήμα:</b> Στοιχεία
                  καταστήματος (χρειάζεται Καταστήματα, βήμα 3) — PMs (roster), αν θέλεις να
                  προσθέσεις έναν PM χωρίς να περάσει μέσα από το Spacegroups import — Clusters
                  ανά Store Tier (χρειάζεται Spacegroups + Clusters, βήματα 4-5, κάθε δηλωμένος
                  κωδικός πρέπει να υπάρχει ήδη σαν cluster) — καθώς και
                  το ownership override
                  ανά κατηγορία/spacegroup (χρειάζεται PMs + Spacegroups, γίνεται από το UI, όχι CSV).
                </li>
                <li>
                  <b>Από εδώ και πέρα</b> οι αλλαγές γίνονται είτε απευθείας (super_admin/admin) είτε
                  μέσω του κύκλου πρότασης→έγκρισης του pm (βλ. "Πώς μπαίνουν τα δεδομένα" παραπάνω) —
                  δεν υπάρχει πια ζήτημα σειράς.
                </li>
              </ol>
            </section>
          ) : null}
        </div>
      </div>
    </div>
  );
}

/* ============================ Shell ============================ */
function currentRoute() {
  const h = (location.hash || '#/dashboard').replace('#', '');
  if (h.startsWith('/stores-admin')) return 'stores-admin';
  if (h.startsWith('/stores')) return 'stores';
  if (h.startsWith('/sqft')) return 'sqft';
  if (h.startsWith('/requests')) return 'requests';
  if (h.startsWith('/dashboard')) return 'dashboard';
  if (h.startsWith('/users')) return 'users';
  if (h.startsWith('/pmm')) return 'pmm';
  if (h.startsWith('/cluster-tiers')) return 'cluster-tiers';
  if (h.startsWith('/spacegroups')) return 'spacegroups';
  if (h.startsWith('/import')) return 'import';
  if (h.startsWith('/docs')) return 'docs';
  return 'clusters';
}

// Sidebar nav — flat, reorderable, κρυφά items ανά χρήστη. Κράτα παρατεταμένο κλικ στο
// λογότυπο (βλ. AppShell) μπαίνεις σε edit mode: κάθε item παίρνει drag handle (⠿, DndKit
// useSortable — ίδιο pattern με WidgetCard/DndKitSectionGrid στο Dashboard) + ✕ απόκρυψης.
// order/hidden: per-account, server-side (users.view_prefs, tab 'sidebar') — ίδιο
// useViewPrefs/usePersistedField hook με κάθε άλλο view pref σε αυτό το αρχείο, ΟΧΙ localStorage.
function SidebarNavList({ editMode, sensors, keys, onDragEnd, children }) {
  if (!editMode || !window.DndKit) {
    return <nav className="sidebar-nav">{children}</nav>;
  }
  const { DndContext, SortableContext, closestCenter, verticalListSortingStrategy } = window.DndKit;
  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
      <SortableContext items={keys} strategy={verticalListSortingStrategy}>
        <nav className="sidebar-nav sidebar-nav-editing">{children}</nav>
      </SortableContext>
    </DndContext>
  );
}

function SidebarNavItem({ id, item, active, editMode, onNavigate, onHide }) {
  const sortable = editMode && window.DndKit ? window.DndKit.useSortable({ id }) : null;
  const style = sortable ? {
    transform: window.DndKit.CSS.Transform.toString(sortable.transform),
    transition: sortable.transition,
  } : undefined;

  if (editMode) {
    return (
      <div ref={sortable ? sortable.setNodeRef : undefined} style={style}
        className={'sidebar-item sidebar-item-editable' + (sortable && sortable.isDragging ? ' dragging' : '')}>
        <span className="sidebar-draghandle" title="Σύρε για αναδιάταξη"
          {...(sortable ? { ...sortable.attributes, ...sortable.listeners } : {})}>⠿</span>
        <span className="sidebar-item-icon"><Icon name={item.icon} size={19} /></span>
        <span className="sidebar-label">{item.label}</span>
        <button type="button" className="sidebar-item-hide" title="Απόκρυψη" onClick={onHide}>✕</button>
      </div>
    );
  }
  return (
    <button type="button" className={'sidebar-item ' + (active ? 'active' : '')}
      onClick={onNavigate} title={item.title || item.label}>
      <span className="sidebar-item-icon">
        <Icon name={item.icon} size={19} />
        {item.badge ? <span className="cartbadge sidebar-badge">{item.badge}</span> : null}
      </span>
      <span className="sidebar-label">{item.label}</span>
    </button>
  );
}

// Profile dropdown, πάνω-δεξιά — avatar trigger, μαζεύει ό,τι αφορά τον λογαριασμό (θέμα,
// προφίλ/avatar, αλλαγή password, logout), ζητήθηκε ρητά. Ίδιο interaction pattern με το
// NavDropdown παραπάνω (open state + click-outside-closes μέσω mousedown listener), αλλά
// ξεχωριστό component — διαφορετικό trigger (avatar, όχι tab-styled button) και θέση (κάτω
// από το avatar, δεξιά ευθυγραμμισμένο, όχι μέσα στη σειρά .tabs).
// Καμπανάκι ειδοποιήσεων — ορατό σε ΟΛΟΥΣ τους χρήστες (πριν ήταν μόνο reviewers, βλ.
// NotificationsProvider). Ίδιο open/close + click-outside pattern με το ProfileMenu ακριβώς
// από κάτω. Click σε γραμμή = mark read + navigate (`#/requests?highlight=ID` ή
// `#/stores?sg=ID`, βλ. notificationLink) — ίδιο direct location.hash convention με τα ήδη
// υπάρχοντα deep-links αλλού στο αρχείο, όχι μέσω του go() (αυτό περιμένει bare route keys).
function NotificationBell({ reference }) {
  const [open, setOpen] = useState(false);
  const [historyOpen, setHistoryOpen] = useState(false);
  const ref = useRef(null);
  const notif = useNotifications();

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  if (!notif) return null;
  const { items, unreadCount, markRead, markAllRead } = notif;
  // Μόνο μη-αναγνωσμένα εδώ (ζητήθηκε ρητά "όταν γίνουν read να φεύγουν... και να μην
  // εμφανίζονται ξανά") — το ίδιο το mark-as-read αφαιρεί τη γραμμή από τη λίστα αντί να την
  // αφήνει εκεί απλά ξε-highlighted. Πλήρες ιστορικό (και read, τελευταίοι 6 μήνες) υπάρχει
  // στο σταθερό κουμπί "Ιστορικό ειδοποιήσεων" κάτω-κάτω στο popover (βλ.
  // NotificationHistoryDrawer), δεν χάνεται τίποτα.
  const unreadItems = items.filter((n) => !n.readAt);

  function onItemClick(n) {
    markRead(n.id);
    setOpen(false);
    document.activeElement?.blur();
    location.hash = notificationLink(n);
  }

  return (
    <div className="notifbell" ref={ref}>
      <button type="button" className="header-iconbtn" onClick={() => setOpen((o) => !o)} title="Ειδοποιήσεις" aria-label="Ειδοποιήσεις">
        <Icon name="bell" size={18} />
        {unreadCount ? <span className="cartbadge header-badge">{unreadCount}</span> : null}
      </button>
      {open ? (
        <div className="notif-popover">
          <div className="notif-popover-head">
            <b>Ειδοποιήσεις</b>
            {unreadItems.length ? <button type="button" className="btn ghost sm" onClick={markAllRead}>Όλα αναγνωσμένα</button> : null}
          </div>
          <div className="notif-list">
            {!unreadItems.length ? <div className="notif-empty">Καμία νέα ειδοποίηση</div> : unreadItems.map((n) => (
              <div key={n.id} className="notif-item unread" onClick={() => onItemClick(n)}>
                <span className={'notif-dot notif-dot-' + (n.type === 'request_approved' ? 'ok' : n.type === 'request_rejected' ? 'bad' : n.type === 'stale_request' ? 'warn' : 'default')} />
                <div className="notif-item-text">
                  <div>{describeNotification(n, reference)}</div>
                  <div className="notif-item-time">{new Date(n.createdAt).toLocaleString('el')}</div>
                </div>
              </div>
            ))}
          </div>
          <div className="notif-popover-foot">
            <button type="button" className="btn ghost sm" onClick={() => { setOpen(false); setHistoryOpen(true); }}>
              Ιστορικό ειδοποιήσεων
            </button>
          </div>
        </div>
      ) : null}
      {historyOpen ? <NotificationHistoryDrawer reference={reference} onClose={() => setHistoryOpen(false)} /> : null}
    </div>
  );
}

// Πλήρες ιστορικό ειδοποιήσεων (read+unread, τελευταίοι 6 μήνες, βλ. GET
// /api/notifications/history) — σε αντίθεση με το popover του NotificationBell που δείχνει ΜΟΝΟ
// unread. Ίδιο .drawer pattern με το HistoryDrawer (~L1271), δικό του data shape (notifications,
// όχι audit diffs) — reuse describeNotification/notificationLink/.notif-dot-* από εκεί.
function NotificationHistoryDrawer({ reference, onClose }) {
  const notif = useNotifications();
  const [items, setItems] = useState(null);
  const [total, setTotal] = useState(0);
  const [page, setPage] = useState(0);
  const perPage = 50;
  const pageCount = Math.max(1, Math.ceil(total / perPage));

  useEffect(() => {
    api.get(`/api/notifications/history?page=${page}&perPage=${perPage}`)
      .then(({ notifications, total: t }) => { setItems(notifications); setTotal(t); })
      .catch((e) => toastErr(e.message));
  }, [page]);

  function onItemClick(n) {
    if (!n.readAt) {
      notif && notif.markRead(n.id);
      setItems((cur) => cur.map((it) => (it.id === n.id ? { ...it, readAt: new Date().toISOString() } : it)));
    }
    onClose();
    location.hash = notificationLink(n);
  }

  return (
    <div className="drawer-backdrop" onClick={onClose}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Ιστορικό ειδοποιήσεων</b>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        <div className="drawer-body">
          {!items ? (
            <div className="empty">Φόρτωση…</div>
          ) : items.length === 0 ? (
            <div className="empty">Καμία ειδοποίηση τους τελευταίους 6 μήνες.</div>
          ) : items.map((n) => (
            <div key={n.id} className={'notif-item' + (!n.readAt ? ' unread' : '')} onClick={() => onItemClick(n)}>
              <span className={'notif-dot notif-dot-' + (n.type === 'request_approved' ? 'ok' : n.type === 'request_rejected' ? 'bad' : 'default')} />
              <div className="notif-item-text">
                <div>{describeNotification(n, reference)}</div>
                <div className="notif-item-time">{new Date(n.createdAt).toLocaleString('el')}</div>
              </div>
            </div>
          ))}
        </div>
        <Pager page={page} total={total} perPage={perPage} pageCount={pageCount} onPageChange={setPage} />
        <div className="drawer-foot">
          <button className="btn ghost" onClick={onClose}>Κλείσιμο</button>
        </div>
      </div>
    </div>
  );
}

function ProfileMenu({ me, onLogout, onOpenProfile, onOpenChangePassword }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  const theme = useTheme();

  useEffect(() => {
    if (!open) return;
    function onDocClick(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [open]);

  return (
    <div className="profilemenu" ref={ref}>
      <button type="button" className="profilemenu-trigger" onClick={() => setOpen((o) => !o)} aria-label="Λογαριασμός">
        <span className="profilemenu-trigger-text">
          <span className="profilemenu-trigger-name">{me.name}</span>
          <span className="profilemenu-trigger-role">{me.title || ROLE_LABELS[me.role] || me.role}</span>
        </span>
        <Avatar userId={me.id} name={me.name} hasAvatar={!!me.avatarPath} size={32} />
      </button>
      {open ? (
        <div className="profilemenu-popover">
          <div className="profilemenu-header">
            <Avatar userId={me.id} name={me.name} hasAvatar={!!me.avatarPath} size={40} />
            <div className="profilemenu-header-text">
              <b>{me.name}</b>
              <span className="pill role" style={{ width: 'fit-content' }}>{me.title || ROLE_LABELS[me.role] || me.role}</span>
            </div>
          </div>
          <button type="button" className="profilemenu-item" onClick={() => { setOpen(false); onOpenProfile(); }}>
            Ρυθμίσεις λογαριασμού
          </button>
          <button type="button" className="profilemenu-item" onClick={() => { setOpen(false); onOpenChangePassword(); }}>
            Αλλαγή password
          </button>
          <div className="profilemenu-item profilemenu-theme">
            <span>Θέμα</span>
            <ThemeToggle />
          </div>
          {theme && theme.pref !== 'system' ? (
            <div className="profilemenu-item profilemenu-theme">
              <span>Απόχρωση</span>
              <ThemeVariantPicker />
            </div>
          ) : null}
          <button type="button" className="profilemenu-item profilemenu-logout" onClick={onLogout}>
            Αποσύνδεση
          </button>
        </div>
      ) : null}
    </div>
  );
}

// "Ρυθμίσεις λογαριασμού" drawer — avatar preview + upload. onUpdated καλεί το ήδη υπάρχον checkAuth() (βλ.
// App) ώστε το νέο avatar να εμφανιστεί αμέσως παντού (topbar, αυτό το ίδιο drawer) χωρίς full
// page reload — ίδιο "reload μετά από write" πνεύμα με κάθε άλλο optimistic-update flow εδώ.
function ProfileDrawer({ me, onClose, onUpdated, onSilentRefresh }) {
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  const fileRef = useRef(null);
  // Τοπικό state (αρχικοποιείται από me.notificationPrefs) — immediate visual feedback στο
  // toggle, χωρίς να περιμένει το round-trip. onSilentRefresh (καλεί το ήδη υπάρχον checkAuth
  // σε silent mode) κρατάει το me.notificationPrefs ενημερωμένο ΓΙΑ ΤΗΝ ΕΠΟΜΕΝΗ φορά που θα
  // ανοίξει αυτό το drawer, χωρίς να το κλείνει (σε αντίθεση με το onUpdated, που κλείνει).
  const [notifPrefs, setNotifPrefs] = useState(() => me.notificationPrefs || {});

  // Αυτοεξυπηρέτηση — ζητήθηκε ρητά "δικαίωμα αλλαγής ο καθένας για τον εαυτό του" για
  // title/phone/email/teamEmail (σε αντίθεση με το displayName, που παραμένει admin/super_admin
  // only). Τοπικό state σαν το notifPrefs παραπάνω — ΔΕΝ ξανασυγχρονίζεται από ένα μελλοντικό me
  // refresh, δείχνει απλά ό,τι μόλις αποθηκεύτηκε.
  const [title, setTitle] = useState(me.title || '');
  const [phone, setPhone] = useState(me.phone || '');
  const [email, setEmail] = useState(me.email || '');
  const [teamEmail, setTeamEmail] = useState(me.teamEmail || '');
  const [profileBusy, setProfileBusy] = useState(false);
  const profileDirty = title !== (me.title || '') || phone !== (me.phone || '') || email !== (me.email || '') || teamEmail !== (me.teamEmail || '');

  async function saveProfile() {
    setProfileBusy(true);
    try {
      await api.send('PUT', '/api/users/me/profile', { title, phone, email, teamEmail });
      toast('Ενημερώθηκαν τα στοιχεία σου');
      onSilentRefresh && onSilentRefresh();
    } catch (e) { toastErr(e.message); }
    setProfileBusy(false);
  }

  function toggleNotifType(type, checked) {
    const next = { ...notifPrefs, [type]: checked };
    setNotifPrefs(next);
    api.send('PUT', '/api/users/me/notification-prefs', { notificationPrefs: { [type]: checked } })
      .then(() => onSilentRefresh && onSilentRefresh())
      .catch((e) => toastErr(e.message));
  }

  function onFile(e) {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    if (file.size > 2 * 1024 * 1024) { setErr('Η εικόνα ξεπερνάει το όριο 2MB'); return; }
    setErr('');
    setBusy(true);
    const reader = new FileReader();
    reader.onload = async () => {
      try {
        await api.send('POST', '/api/users/me/avatar', { imageBase64: String(reader.result) });
        toast('Το avatar ενημερώθηκε');
        onUpdated();
      } catch (e2) { setErr(e2.message); }
      setBusy(false);
    };
    reader.onerror = () => { setErr('Αποτυχία ανάγνωσης αρχείου'); setBusy(false); };
    reader.readAsDataURL(file);
  }

  return (
    <div className="drawer-backdrop" onClick={onClose}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Ρυθμίσεις λογαριασμού</b>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        <div className="drawer-body">
          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
            <Avatar userId={me.id} name={me.name} hasAvatar={!!me.avatarPath} size={96} />
            <div style={{ textAlign: 'center' }}>
              <div><b>{me.name}</b></div>
              <div className="matrix-note" style={{ marginTop: 2 }}>{me.username} · {ROLE_LABELS[me.role] || me.role}</div>
            </div>
            <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={onFile} style={{ display: 'none' }} />
            <button className="btn primary" disabled={busy} onClick={() => fileRef.current.click()}>
              {busy ? 'Ανέβασμα…' : 'Αλλαγή φωτογραφίας'}
            </button>
            {err ? <div className="autherr">{err}</div> : null}
          </div>

          <div className="drawer-section">
            <div className="drawer-section-title">Στοιχεία προφίλ</div>
            <div className="matrix-note" style={{ marginBottom: 10 }}>
              Ο τίτλος εμφανίζεται αντί για τον ρόλο σου όπου φαίνεται το όνομά σου — αν μείνει κενός, δείχνει τον ρόλο όπως πριν.
            </div>
            <div className="drawer-field">
              <label>Τίτλος</label>
              <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={ROLE_LABELS[me.role] || me.role} />
            </div>
            <div className="drawer-field">
              <label>Τηλέφωνο</label>
              <input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="—" />
            </div>
            <div className="drawer-field">
              <label>Email</label>
              <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="—" />
            </div>
            <div className="drawer-field">
              <label>Team email</label>
              <input value={teamEmail} onChange={(e) => setTeamEmail(e.target.value)} placeholder="—" />
            </div>
            <button className="btn primary sm" disabled={!profileDirty || profileBusy} onClick={saveProfile} style={{ marginTop: 4 }}>
              {profileBusy ? 'Αποθήκευση…' : 'Αποθήκευση'}
            </button>
          </div>

          <div className="drawer-section">
            <div className="drawer-section-title">Ειδοποιήσεις</div>
            <div className="matrix-note" style={{ marginBottom: 10 }}>
              Ποιες ειδοποιήσεις θέλεις να λαμβάνεις (καμπανάκι πάνω δεξιά).
            </div>
            {NOTIFICATION_TYPES_CLIENT.map((type) => (
              <Toggle key={type} checked={notifPrefs[type] !== false}
                onChange={(checked) => toggleNotifType(type, checked)} label={NOTIFICATION_TYPE_LABELS[type]} />
            ))}
          </div>
        </div>
        <div className="drawer-foot">
          <button className="btn ghost" onClick={onClose}>Κλείσιμο</button>
        </div>
      </div>
    </div>
  );
}

// Self-service "Αλλαγή password" drawer — ίδια πεδία/validation/endpoint με το ήδη υπάρχον
// ChangePasswordPage (υποχρεωτική ροή πρώτου login), αλλά ως drawer πάνω από ήδη-φορτωμένη
// σελίδα αντί για full-screen replace, αφού εδώ η αλλαγή είναι προαιρετική/on-demand.
function ChangePasswordDrawer({ onClose }) {
  const [p1, setP1] = useState('');
  const [p2, setP2] = useState('');
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');

  async function submit(e) {
    e.preventDefault();
    if (p1.length < 8) return setErr('Το password πρέπει να έχει τουλάχιστον 8 χαρακτήρες.');
    if (p1 !== p2) return setErr('Τα passwords δεν ταιριάζουν.');
    setBusy(true); setErr('');
    try {
      await api.send('POST', '/api/auth/change-password', { newPassword: p1 });
      toast('Το password άλλαξε');
      onClose();
    } catch (e2) { setErr(e2.message); }
    setBusy(false);
  }

  return (
    <div className="drawer-backdrop" onClick={onClose}>
      <div className="drawer" onClick={(e) => e.stopPropagation()}>
        <div className="drawer-head">
          <b>Αλλαγή password</b>
          <button className="btn ghost sm" onClick={onClose} aria-label="Κλείσιμο">✕</button>
        </div>
        <form className="drawer-body" onSubmit={submit}>
          <div className="drawer-field">
            <label>Νέο password</label>
            <input type="password" autoFocus value={p1} onChange={(e) => setP1(e.target.value)} />
          </div>
          <div className="drawer-field">
            <label>Επιβεβαίωση password</label>
            <input type="password" value={p2} onChange={(e) => setP2(e.target.value)} />
          </div>
          {err ? <div className="autherr">{err}</div> : null}
        </form>
        <div className="drawer-foot">
          <button className="btn primary" disabled={busy || !p1 || !p2} onClick={submit}>
            {busy ? 'Αποθήκευση…' : 'Αλλαγή password'}
          </button>
          <button className="btn ghost" onClick={onClose}>Άκυρο</button>
        </div>
      </div>
    </div>
  );
}

/* ============================ Dashboard: hand-rolled SVG charts ============================ */
// Κανένα charting library δεν είναι vendored (app.jsx δεν έχει build step, index.html φορτώνει
// μόνο React/ReactDOM/Babel standalone τοπικά) — ίδιο "καμία νέα εξάρτηση" convention με την
// υπόλοιπη εφαρμογή, τα charts είναι μικρά, χειροποίητα inline SVG components.
// Σταθερή, ουδέτερη ποιοτική παλέτα για κατηγορικά δεδομένα που ΔΕΝ έχουν ήδη δικό τους χρώμα
// (π.χ. status/PM/leaderboard) — STORE_GROUP_COLOR χρησιμοποιείται ΑΥΤΟΥΣΙΟ όπου τα δεδομένα
// είναι ήδη ομάδες καταστήματος, ώστε το ίδιο "Orange" να έχει το ΙΔΙΟ χρώμα παντού στην app.
// #7885a3 (τελευταίο, "fallback/default" χρώμα) — ελαφρώς πιο ανοιχτό από το γενικό --muted
// (#64708a) ώστε να διατηρεί επαρκή αντίθεση ΚΑΙ στα δύο themes ως graphical element (bar
// fill/donut segment) πάνω σε light ΚΑΙ σε dark --panel — το αρχικό ήταν βελτιστοποιημένο
// μόνο για light background.
const CHART_PALETTE = ['#2f52b3', '#0d7d7d', '#b7791f', '#8a4fd1', '#e0553b', '#1f8a4c', '#c24329', '#7885a3'];

function StatTile({ label, value, sub, compact }) {
  return (
    <div className={'stattile' + (compact ? ' compact' : '')}>
      <div className="stattile-value" title={typeof value === 'number' ? value.toLocaleString('el') : undefined}>
        {typeof value === 'number' ? value.toLocaleString('el') : value}
      </div>
      <div className="stattile-label">{label}</div>
      {sub ? <div className="stattile-sub">{sub}</div> : null}
    </div>
  );
}

// entries: [{label, value}] — κάθετες μπάρες, ύψος αναλογικό στο max, τιμή πάνω από κάθε
// μπάρα. colorFor(label, i) προαιρετικό — default κυλάει το CHART_PALETTE.
function BarChart({ entries, colorFor, height = 160, formatValue }) {
  const max = Math.max(1, ...entries.map((e) => e.value));
  const fmt = formatValue || ((v) => v.toLocaleString('el'));
  return (
    <div className="barchart" style={{ height }}>
      {entries.map((e, i) => {
        const h = Math.round((e.value / max) * (height - 34));
        const color = colorFor ? colorFor(e.label, i) : CHART_PALETTE[i % CHART_PALETTE.length];
        return (
          <div className="barchart-col" key={e.label}>
            <div className="barchart-value">{fmt(e.value)}</div>
            <div className="barchart-bar" style={{ height: h, background: color }} title={`${e.label}: ${fmt(e.value)}`} />
            <div className="barchart-label" title={e.label}>{e.label}</div>
          </div>
        );
      })}
    </div>
  );
}

// entries: [{label, value}] — donut μέσω SVG stroke-dasharray ανά segment (γυρισμένο -90deg
// ώστε να ξεκινάει από πάνω, ίδιο convention με τυπικά pie/donut charts).
function DonutChart({ entries, colorFor, size = 140, thickness = 22 }) {
  const total = entries.reduce((s, e) => s + e.value, 0) || 1;
  const r = (size - thickness) / 2;
  const c = 2 * Math.PI * r;
  let offset = 0;
  return (
    <div className="donutchart" style={{ width: size + 140 }}>
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
        <g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
          <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--line-soft)" strokeWidth={thickness} />
          {entries.map((e, i) => {
            const frac = e.value / total;
            const dash = frac * c;
            const color = colorFor ? colorFor(e.label, i) : CHART_PALETTE[i % CHART_PALETTE.length];
            const circle = (
              <circle key={e.label} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={thickness}
                strokeDasharray={`${dash} ${c - dash}`} strokeDashoffset={-offset}>
                <title>{`${e.label}: ${e.value.toLocaleString('el')} (${Math.round(frac * 100)}%)`}</title>
              </circle>
            );
            offset += dash;
            return circle;
          })}
        </g>
      </svg>
      <div className="donutchart-legend">
        {entries.map((e, i) => (
          <div className="donutchart-legend-item" key={e.label}>
            <span className="donutchart-swatch" style={{ background: colorFor ? colorFor(e.label, i) : CHART_PALETTE[i % CHART_PALETTE.length] }} />
            <span className="donutchart-legend-label">{e.label}</span>
            <span className="donutchart-legend-value">{e.value.toLocaleString('el')}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// series: [{ id, name, points: [{ storeCode, value }] }] — μία polyline ανά series, ΚΟΙΝΟΣ
// x-άξονας (labels, ίδιο μήκος/σειρά σε όλα τα series) στο κάτω μέρος, y-άξονας τιμών (κοινό
// scale/max σε ΟΛΑ τα series — ζητήθηκε ρητά "ο ίδιος άξονας τιμών" ώστε πολλαπλά spacegroups
// ή το category-άθροισμα να είναι οπτικά συγκρίσιμα στο ίδιο chart). Hover σε κάθε σημείο
// δείχνει store/τιμή μέσω native <title>, ίδιο "no JS tooltip lib" convention με τα άλλα charts.
// labels: σύντομα (π.χ. store code) — αυτά ρεντεράρονται στον x-άξονα, μένουν στενά ώστε να
// χωράνε πολλά καταστήματα δίπλα-δίπλα χωρίς να γίνεται αδιάβαστο. fullLabels (προαιρετικό):
// πιο περιγραφικά (π.χ. "code · name") — μπαίνουν ΜΟΝΟ στο hover tooltip κάθε σημείου, όχι
// στον άξονα, ώστε το όνομα καταστήματος να είναι διαθέσιμο χωρίς να στριμώχνει το chart.
// maxSeries: όταν το series έχει πολλά μέλη (π.χ. 60+ spacegroups ενός μεγάλου family), το
// chart γίνεται αδιάβαστο κουβάρι — δείχνουμε ΜΟΝΟ τα top-N (βάσει αθροίσματος τιμών) σαν
// γραμμές, τα υπόλοιπα λίστα-only στο legend (ζητήθηκε ρητά "περιόρισε σε top 15 αλλά και το
// hover"). hoveredId: id του series που είναι hover-highlighted (από το legend ΕΙΤΕ από το
// ίδιο το chart) — όλες οι άλλες γραμμές γίνονται διάφανες, η επιλεγμένη παίρνει value-labels
// σε ΟΛΟ το μήκος της (ζητήθηκε ρητά "να εμφανίζει τις τιμές σε όλο το μήκος").
// hoveredX: index πάνω στο κοινό x-άξονα (store) — δείχνει ΟΛΕΣ τις τιμές όλων των ορατών
// σειρών σε εκείνο το σημείο μέσα σε ένα tooltip panel (ζητήθηκε ρητά "να μπορώ να δω όλες
// τις τιμές στο tooltip", όχι μόνο της μίας γραμμής που έχει ακριβώς από κάτω το ποντίκι).
function LineChart({ series, labels, fullLabels, colorFor, height = 260, formatValue, maxSeries = 15 }) {
  const fmt = formatValue || ((v) => v.toLocaleString('el'));
  const [hoveredId, setHoveredId] = useState(null);
  const [hoveredX, setHoveredX] = useState(null);
  const containerRef = useRef(null);
  const [containerWidth, setContainerWidth] = useState(600);

  useEffect(() => {
    if (!containerRef.current) return;
    const ro = new ResizeObserver((entries) => {
      const w = entries[0]?.contentRect?.width;
      if (w) setContainerWidth(w);
    });
    ro.observe(containerRef.current);
    return () => ro.disconnect();
  }, []);

  const sortedByTotal = useMemo(
    () => series.map((s) => ({ ...s, total: s.points.reduce((sum, p) => sum + p.value, 0) })).sort((a, b) => b.total - a.total),
    [series],
  );
  const shownSeries = sortedByTotal.slice(0, maxSeries);
  const overflowSeries = sortedByTotal.slice(maxSeries);
  const colorById = useMemo(() => new Map(series.map((s, i) => [s.id, colorFor ? colorFor(s.name, i) : CHART_PALETTE[i % CHART_PALETTE.length]])), [series, colorFor]);

  const allValues = shownSeries.flatMap((s) => s.points.map((p) => p.value));
  const max = Math.max(1, ...allValues);
  const axisLabels = fullLabels || labels;
  const n = labels.length;
  // padB μεγαλύτερο εδώ (ήταν 28) — τα axis labels είναι πλέον "code · όνομα καταστήματος"
  // (ζητήθηκε ρητά, όχι μόνο ο κωδικός), γυρισμένα -45° ώστε να χωράνε χωρίς να πατάει το ένα
  // πάνω στο άλλο· χρειάζονται πολύ περισσότερο κάθετο χώρο από απλά "7001".
  const padL = 8, padR = 8, padT = 10, padB = 90;
  // Responsive: όσο χωράει ο container χωρίς scroll (τουλάχιστον 46px ανά σημείο ώστε τα
  // περιστραμμένα axis labels να μη πατάνε το ένα πάνω στο άλλο) — μόνο όταν τα σημεία είναι
  // πάρα πολλά για να χωρέσουν πέφτει σε οριζόντιο scroll (βλ. .linechart-scroll).
  const width = Math.max(containerWidth, n * 46);
  const plotW = width - padL - padR, plotH = height - padT - padB;
  const xFor = (i) => padL + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW);
  const yFor = (v) => padT + plotH - (v / max) * plotH;

  return (
    <div className="linechart" ref={containerRef}>
      <div className="linechart-scroll">
        <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} onMouseLeave={() => setHoveredX(null)}>
          {[0, .25, .5, .75, 1].map((f) => (
            <line key={f} x1={padL} x2={width - padR} y1={padT + plotH * (1 - f)} y2={padT + plotH * (1 - f)} stroke="var(--line-soft)" strokeWidth="1" />
          ))}
          {shownSeries.map((s) => {
            const color = colorById.get(s.id);
            const dimmed = hoveredId != null && hoveredId !== s.id;
            const d = s.points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(p.value)}`).join(' ');
            return (
              <g key={s.id} opacity={dimmed ? 0.15 : 1}>
                <path d={d} fill="none" stroke={color} strokeWidth={hoveredId === s.id ? 3 : 2} />
                {s.points.map((p, i) => (
                  <circle key={i} cx={xFor(i)} cy={yFor(p.value)} r={hoveredId === s.id ? 4 : 3} fill={color} />
                ))}
                {hoveredId === s.id ? s.points.map((p, i) => (
                  <text key={i} x={xFor(i)} y={yFor(p.value) - 8} textAnchor="middle" fontSize="10" fontWeight="600" fill={color}>{fmt(p.value)}</text>
                )) : null}
              </g>
            );
          })}
          {/* Αόρατες, φαρδιές λωρίδες ανά x-σημείο — πιάνουν το hover σε ΟΛΟ το κάθετο ύψος
              του chart (όχι μόνο πάνω σε ένα 3px κύκλο μίας γραμμής), ώστε το tooltip να
              δείχνει όλες τις σειρές σε αυτό το σημείο ασχέτως ποια γραμμή περνάει ακριβώς
              από κάτω από το ποντίκι. */}
          {labels.map((_, i) => (
            <rect key={i} x={xFor(i) - (plotW / Math.max(1, n - 1)) / 2} y={padT} width={plotW / Math.max(1, n - 1)} height={plotH}
              fill="transparent" onMouseEnter={() => setHoveredX(i)} />
          ))}
          {hoveredX != null ? <line x1={xFor(hoveredX)} x2={xFor(hoveredX)} y1={padT} y2={padT + plotH} stroke="var(--muted)" strokeWidth="1" strokeDasharray="3 3" /> : null}
          {axisLabels.map((label, i) => (
            <text key={i} x={xFor(i)} y={plotH + padT + 14} textAnchor="end" fontSize="10" fill="var(--muted)"
              transform={`rotate(-45 ${xFor(i)} ${plotH + padT + 14})`}>{label}</text>
          ))}
        </svg>
        {hoveredX != null ? (
          <div className="linechart-tooltip" style={{ left: Math.min(Math.max(xFor(hoveredX), 85), Math.min(width, containerWidth) - 85) }}>
            <div className="linechart-tooltip-head">{axisLabels[hoveredX]}</div>
            {shownSeries.slice().sort((a, b) => b.points[hoveredX].value - a.points[hoveredX].value).map((s) => (
              <div key={s.id} className="linechart-tooltip-row" style={{ fontWeight: hoveredId === s.id ? 700 : 400 }}>
                <span className="linechart-swatch" style={{ background: colorById.get(s.id) }} />
                <span className="linechart-tooltip-label">{s.name}</span>
                <span className="linechart-tooltip-value">{fmt(s.points[hoveredX].value)}</span>
              </div>
            ))}
          </div>
        ) : null}
      </div>
      <div className="linechart-legend">
        {shownSeries.map((s) => (
          <div key={s.id} className={'linechart-legend-item' + (hoveredId === s.id ? ' active' : '')}
            onMouseEnter={() => setHoveredId(s.id)} onMouseLeave={() => setHoveredId(null)}>
            <span className="linechart-swatch" style={{ background: colorById.get(s.id) }} />
            <span className="linechart-legend-label">{s.name}</span>
          </div>
        ))}
        {overflowSeries.length ? (
          <div className="linechart-legend-overflow" title={overflowSeries.map((s) => s.name).join(', ')}>
            +{overflowSeries.length} ακόμα
          </div>
        ) : null}
      </div>
    </div>
  );
}

// Ομαδοποιημένο (clustered) bar chart + line overlay σε ΔΕΥΤΕΡΟ, ανεξάρτητο άξονα — ζητήθηκε
// ρητά "πολλαπλές μπάρες με ίδιο χρώμα η κατηγορία... ομαδοποιημένα ανά γκρουπ καταστημάτων
// και από πίσω γραμμές με τις μέσες τιμές ανά m²". Μπάρες = ένα πεδίο (π.χ. Live) ανά ομάδα
// καταστήματος, ΙΔΙΟ χρώμα ανά πεδίο σε ΟΛΑ τα groups (colorFor(fieldLabel), όχι ανά index)
// ώστε να αναγνωρίζεται οπτικά η ίδια κατηγορία σε κάθε cluster. Γραμμές = μία ανά πεδίο, ίδιο
// χρώμα με το αντίστοιχο μπαρ, σχεδιασμένες ΠΡΩΤΕΣ (άρα οπτικά "από πίσω") πάνω σε δικό τους
// y-scale (lineValues) — τελείως διαφορετική κλίμακα τιμών από τις μπάρες (π.χ. σύνολα σε
// χιλιάδες vs πυκνότητα/m² σε δεκαδικά), θα ήταν αόρατες αν μοιράζονταν τον ίδιο άξονα.
function GroupedBarLineChart({
  groups, fields, barValues, lineValues, colorFor, height = 300,
  barFormatValue, lineFormatValue, barAxisLabel, lineAxisLabel,
}) {
  const bfmt = barFormatValue || ((v) => v.toLocaleString('el'));
  const lfmt = lineFormatValue || ((v) => v.toLocaleString('el'));
  const [hoveredGroup, setHoveredGroup] = useState(null);
  const containerRef = useRef(null);
  const [containerWidth, setContainerWidth] = useState(600);

  useEffect(() => {
    if (!containerRef.current) return;
    const ro = new ResizeObserver((entries) => {
      const w = entries[0]?.contentRect?.width;
      if (w) setContainerWidth(w);
    });
    ro.observe(containerRef.current);
    return () => ro.disconnect();
  }, []);

  const padL = 10, padR = 10, padT = 14, padB = 30;
  const n = Math.max(1, groups.length);
  // Κάθε group-cluster χρειάζεται τουλάχιστον ~20px ανά μπαρ + κενά ώστε τα 5 πεδία να μη
  // στριμωχτούν — ίδιο responsive-πλάτος-με-οριζόντιο-scroll convention με το LineChart.
  const minClusterW = fields.length * 24 + 16;
  const width = Math.max(containerWidth, n * minClusterW);
  const plotW = width - padL - padR, plotH = height - padT - padB;
  const clusterW = plotW / n;
  const barGap = 3;
  const barW = Math.min(30, (clusterW - barGap * (fields.length + 1)) / fields.length);

  const maxBar = Math.max(1, ...groups.flatMap((g) => fields.map(([f]) => (barValues[g] && barValues[g][f]) || 0)));
  const maxLine = Math.max(1, ...groups.flatMap((g) => fields.map(([f]) => (lineValues[g] && lineValues[g][f]) || 0)));
  const clusterX = (gi) => padL + gi * clusterW;
  const fieldX = (gi, fi) => clusterX(gi) + barGap + fi * (barW + barGap) + barW / 2;
  const barY = (v) => padT + plotH - (v / maxBar) * plotH;
  const lineY = (v) => padT + plotH - (v / maxLine) * plotH;

  return (
    <div className="groupedbarlinechart" ref={containerRef}>
      <div className="linechart-scroll" onMouseLeave={() => setHoveredGroup(null)}>
        <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
          {[0, .25, .5, .75, 1].map((f) => (
            <line key={f} x1={padL} x2={width - padR} y1={padT + plotH * (1 - f)} y2={padT + plotH * (1 - f)} stroke="var(--line-soft)" strokeWidth="1" />
          ))}
          {/* Γραμμές — σχεδιάζονται ΠΡΩΤΕΣ (πίσω από τις μπάρες, ζητήθηκε ρητά). */}
          {fields.map(([field, label], fi) => {
            const color = colorFor(label);
            const d = groups.map((g, gi) => `${gi === 0 ? 'M' : 'L'} ${fieldX(gi, fi)} ${lineY((lineValues[g] && lineValues[g][field]) || 0)}`).join(' ');
            const dimmed = hoveredGroup != null;
            return (
              <g key={field} opacity={dimmed ? 0.35 : 0.9}>
                <path d={d} fill="none" stroke={color} strokeWidth="2" strokeDasharray="4 3" />
                {groups.map((g) => (
                  <circle key={g} cx={fieldX(groups.indexOf(g), fi)} cy={lineY((lineValues[g] && lineValues[g][field]) || 0)} r="3" fill={color} />
                ))}
              </g>
            );
          })}
          {/* Μπάρες — μπροστά από τις γραμμές, ίδιο χρώμα ανά πεδίο σε κάθε cluster. */}
          {groups.map((g, gi) => (
            <g key={g}>
              {fields.map(([field, label], fi) => {
                const v = (barValues[g] && barValues[g][field]) || 0;
                const h = (v / maxBar) * plotH;
                return (
                  <rect key={field} x={clusterX(gi) + barGap + fi * (barW + barGap)} y={padT + plotH - h}
                    width={barW} height={h} rx="3" fill={colorFor(label)}
                    opacity={hoveredGroup != null && hoveredGroup !== g ? 0.35 : 1} />
                );
              })}
              <rect x={clusterX(gi)} y={padT} width={clusterW} height={plotH} fill="transparent"
                onMouseEnter={() => setHoveredGroup(g)} />
            </g>
          ))}
          {groups.map((g, gi) => (
            <text key={g} x={clusterX(gi) + clusterW / 2} y={height - 10} textAnchor="middle" fontSize="11.5" fontWeight="600" fill="var(--ink-soft)">{g}</text>
          ))}
        </svg>
        {hoveredGroup != null ? (
          <div className="linechart-tooltip" style={{ left: Math.min(Math.max(clusterX(groups.indexOf(hoveredGroup)) + clusterW / 2, 95), Math.min(width, containerWidth) - 95) }}>
            <div className="linechart-tooltip-head">{hoveredGroup}</div>
            {fields.map(([field, label]) => (
              <div key={field} className="linechart-tooltip-row">
                <span className="linechart-swatch" style={{ background: colorFor(label) }} />
                <span className="linechart-tooltip-label">{label}</span>
                <span className="linechart-tooltip-value">
                  {bfmt((barValues[hoveredGroup] && barValues[hoveredGroup][field]) || 0)} · {lfmt((lineValues[hoveredGroup] && lineValues[hoveredGroup][field]) || 0)}
                </span>
              </div>
            ))}
          </div>
        ) : null}
      </div>
      <div className="linechart-legend">
        {fields.map(([field, label]) => (
          <div key={field} className="linechart-legend-item" style={{ cursor: 'default' }}>
            <span className="linechart-swatch" style={{ background: colorFor(label) }} />
            <span className="linechart-legend-label">{label}</span>
          </div>
        ))}
        <span className="linechart-legend-overflow">Μπάρες: {barAxisLabel} · Γραμμές (διακεκομμένες): {lineAxisLabel}</span>
      </div>
    </div>
  );
}

function fieldTotalColor(label) {
  if (label === 'Live') return '#1f8a4c';
  if (label === 'Open') return '#2f52b3';
  if (label === 'Box Moving') return '#b7791f';
  if (label === 'Non-Display') return '#64708a';
  return '#8a4cb3'; // Feed
}
function storeGroupColorOrFallback(label, i) {
  return STORE_GROUP_COLOR[label] || CHART_PALETTE[i % CHART_PALETTE.length];
}

// Widget shell — τίτλος, loading/error state, edit-mode controls (✕ κρύψιμο, drag handle
// αναδιάταξη, size picker). 6 μεγέθη (όχι πια 4) πάνω σε 6-column grid — ζητήθηκε ρητά πιο
// λεπτόκοκκη κλίμακα ώστε συνδυασμοί widgets να αφήνουν λιγότερα κενά στη διάταξη (βλ.
// .dash-grid/grid-auto-flow:dense στο styles.css για το άλλο μισό της ίδιας λύσης).
const WIDGET_SIZES = ['XS', 'S', 'M', 'L', 'XL', 'Full'];

// id: μοναδικό key για DndKit.useSortable (undefined -> καμία drag λειτουργικότητα, βλ.
// loading-skeleton call site στο DashboardPage που δεν έχει ακόμα DndContext γύρω του).
// size: 'XS'|'S'|'M'|'L'|'XL'|'Full' -> αντιστοιχεί σε .widgetcard-size-* CSS class
// (grid-column span, βλ. styles.css). onSizeChange(size): μόνο σε editMode, size picker δίπλα στο ✕.
function WidgetCard({ id, title, loading, error, size, onSizeChange, alignStart, editMode, onHide, children }) {
  const sortable = id && editMode && window.DndKit ? window.DndKit.useSortable({ id }) : null;
  const style = sortable ? {
    transform: window.DndKit.CSS.Transform.toString(sortable.transform),
    transition: sortable.transition,
  } : undefined;
  return (
    <div
      ref={sortable ? sortable.setNodeRef : undefined}
      style={style}
      className={'widgetcard widgetcard-size-' + (size || 'M') + (sortable && sortable.isDragging ? ' dragging' : '')}
    >
      <div className="widgetcard-head">
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
          {editMode ? (
            <span className="widgetcard-draghandle" title="Σύρε για αναδιάταξη"
              {...(sortable ? { ...sortable.attributes, ...sortable.listeners } : {})}>⠿</span>
          ) : null}
          <span className="widgetcard-title">{title}</span>
        </div>
        {editMode ? (
          <div className="widgetcard-controls">
            <div className="widgetcard-sizepicker">
              {WIDGET_SIZES.map((s) => (
                <button key={s} type="button" className={s === (size || 'M') ? 'active' : ''}
                  title={'Μέγεθος ' + s} onClick={() => onSizeChange(s)}>{s}</button>
              ))}
            </div>
            <button type="button" title="Απόκρυψη" onClick={onHide}>✕</button>
          </div>
        ) : null}
      </div>
      <div className={'widgetcard-body' + (alignStart ? ' align-start' : '')}>
        {loading ? <span className="empty" style={{ padding: 0 }}>Φόρτωση…</span> :
          error ? <span className="widgetcard-error">{error}</span> : children}
      </div>
    </div>
  );
}

// rows: [{ value, topSpacegroups: [{id,name,totalFaces}] }] — μία γραμμή ανά τιμή της
// διάστασης (π.χ. ανά division), με το/τα top spacegroup(s) ΜΕΣΑ σε αυτή την τιμή, ΟΧΙ ένα
// global top 10 — ζητήθηκε ρητά "ποιο spacegroup του κάθε division έχει τα περισσότερα".
function TopByDimensionTable({ rows, dimensionLabel }) {
  return (
    <table className="dash-table dash-table-wrap">
      <colgroup><col style={{ width: '32%' }} /><col /></colgroup>
      <thead><tr><th>{dimensionLabel}</th><th>Top spacegroups</th></tr></thead>
      <tbody>
        {(rows || []).slice(0, 10).map((r) => (
          <tr key={r.value}>
            <td>{r.value}</td>
            <td>{r.topSpacegroups.map((sg) => `${sg.name || sg.id} (${sg.totalFaces.toLocaleString('el')})`).join(' · ')}</td>
          </tr>
        ))}
        {!(rows || []).length ? <tr><td colSpan={2} className="empty-cell">Χωρίς δεδομένα</td></tr> : null}
      </tbody>
    </table>
  );
}

// Σταθερά widgets (developer-added, βλ. plan) — κάθε entry ξέρει πώς να ρεντεράρει τον εαυτό
// του δοθέντος του πλήρους GET /api/dashboard/stats response. Προσθήκη νέου stat αργότερα
// ("σε επόμενη φάση θα σου πω κι άλλα πολλά stats") = μία ακόμα καταχώρηση εδώ. Πρώτο σύνολο
// μετά το "διέγραψε όλα τα charts" wipe — 5 stats ζητήθηκαν ρητά:
// Καταστήματα+τετραγωνικά, Tiers (ομάδες καταστήματος) με το πλήθος καταστημάτων τους,
// σύνολο clusters, σύνολο faces ανά τύπο display, και faces/m² πυκνότητα. Οι reusable chart
// primitives (BarChart/DonutChart/StatTile/fieldTotalColor/storeGroupColorOrFallback κ.λπ.)
// ήδη υπήρχαν, έτοιμα να ξαναχρησιμοποιηθούν. Κάθε entry: { key, title, render(stats),
// alignStart?, wide? } — βλ. DashboardPage.
const DASHBOARD_WIDGET_DEFS = [
  {
    key: 'networkOverview',
    title: 'Σύνολα δικτύου',
    wide: true,
    render: (stats) => (
      <div className="stattile-grid">
        <StatTile compact label="Καταστήματα" value={stats.totalStores} />
        <StatTile compact label="Σύνολο Net m²" value={Math.round(stats.totalNetSqft || 0)} />
        <StatTile compact label="Σύνολο Gross m²" value={Math.round(stats.totalGrossSqft || 0)} />
        <StatTile compact label="Clusters" value={stats.totalClusters} />
      </div>
    ),
  },
  {
    key: 'storeTiers',
    title: 'Καταστήματα ανά Tier',
    render: (stats) => (
      <BarChart
        entries={Object.entries(stats.storeGroupCounts || {}).map(([label, value]) => ({ label, value }))}
        colorFor={storeGroupColorOrFallback}
      />
    ),
  },
  {
    key: 'displayFacesByType',
    title: 'Faces ανά τύπο (Display)',
    // Μόνο τα 4 display-domain πεδία (Live/Open/Box Moving/Non-Display) — ΟΧΙ Feed, που είναι
    // ξεχωριστό domain (on-demand), ζητήθηκε ρητά "ανά τύπο display".
    render: (stats) => (
      <BarChart
        entries={[
          { label: 'Live', value: (stats.fieldTotals || {}).live || 0 },
          { label: 'Open', value: (stats.fieldTotals || {}).open || 0 },
          { label: 'Box Moving', value: (stats.fieldTotals || {}).boxMoving || 0 },
          { label: 'Non-Display', value: (stats.fieldTotals || {}).nonDisplay || 0 },
        ]}
        colorFor={fieldTotalColor}
      />
    ),
  },
  {
    key: 'displayDensity',
    title: 'Πυκνότητα Display (faces/m²)',
    // (Live+Open+Box Moving)/τετραγωνικά — ζητήθηκε ρητά αυτός ο συνδυασμός (ΟΧΙ Non-Display/
    // Feed). displayFacesByGroup ήδη είναι ακριβώς live+open+boxMoving ΑΝΑΤΕΘΕΙΜΕΝΟ σε
    // πραγματικά καταστήματα (βλ. server.js #5) — το πραγματικό "τι φαίνεται σήμερα σε ράφι",
    // όχι το ωμό άθροισμα όλων των cluster ορισμών (θα υπερεκτιμούσε, βλ. fieldTotals παραπάνω).
    render: (stats) => {
      const totalDisplayFaces = Object.values(stats.displayFacesByGroup || {}).reduce((a, b) => a + b, 0);
      const netSqft = stats.totalNetSqft || 0;
      const ratio = netSqft ? Math.round((totalDisplayFaces / netSqft) * 100) / 100 : null;
      return (
        <StatTile
          label="Live+Open+Box Moving ανά m² (Net)"
          value={ratio != null ? ratio : '—'}
          sub={netSqft ? `${totalDisplayFaces.toLocaleString('el')} faces / ${Math.round(netSqft).toLocaleString('el')} m²` : 'Χωρίς εισαγμένα τετραγωνικά'}
        />
      );
    },
  },
  {
    key: 'staleRequests',
    title: 'Παλιά αιτήματα',
    // Πόσα change_requests εκκρεμούν πάνω από STALE_REQUEST_REMINDER_DAYS μέρες (ίδιο
    // threshold με το server-side scanStaleRequests() — βλ. §NOTIFICATIONS) — ίδια στιγμιότυπη
    // εικόνα με την ειδοποίηση bell, εδώ σαν σύνολο δικτύου αντί για ανά χρήστη.
    render: (stats) => (
      <StatTile
        label={`Εκκρεμούν > ${STALE_REQUEST_REMINDER_DAYS} ημέρες`}
        value={stats.staleRequestCount != null ? stats.staleRequestCount : '—'}
      />
    ),
  },
  {
    key: 'tierPositionBreakdown',
    title: 'Θέση cluster ανά Store Tier',
    wide: true,
    // Γραμμές = store tier, στήλες = θέση (tier_order) στη δηλωμένη λίστα cluster αυτού του
    // tier (βλ. spacegroup_cluster_tiers, tab "Clusters ανά Store Tier") — δείχνει αν οι
    // αναθέσεις καταστημάτων πέφτουν στο δηλωμένο min/mid/max ή αλλού. Δύο ξεχωριστά "άλλα"
    // buckets: "Χωρίς κανόνα" = το spacegroup δεν έχει ΚΑΝΕΝΑ κανόνα για αυτό το tier ακόμα
    // (αναμενόμενο, opt-in) vs "Παραβίαση" = ΥΠΑΡΧΕΙ κανόνας αλλά ο ανατεθειμένος κωδικός δεν
    // είναι μέσα σε αυτόν (πραγματικό πρόβλημα — βλ. widget tierViolations για τη λεπτομερή λίστα).
    render: (stats) => {
      const counts = stats.tierPositionCounts || {};
      const maxOrder = stats.maxTierOrder || 0;
      const orderCols = Array.from({ length: maxOrder }, (_, i) => i + 1);
      const extraTiers = Object.keys(counts).filter((t) => !STORE_GROUP_LABELS.includes(t));
      const tiers = [...STORE_GROUP_LABELS, ...extraTiers].filter((t) => counts[t]);
      if (!tiers.length) return <p className="muted">Χωρίς δηλωμένους κανόνες ή αναθέσεις ακόμα.</p>;
      return (
        <table className="dash-table">
          <thead>
            <tr>
              <th>Store Tier</th>
              {orderCols.map((o) => <th key={o}>{o === 1 ? 'Θέση 1 (Min)' : `Θέση ${o}`}</th>)}
              <th>Χωρίς κανόνα</th>
              <th>Παραβίαση</th>
            </tr>
          </thead>
          <tbody>
            {tiers.map((tier) => (
              <tr key={tier}>
                <td style={{ color: STORE_GROUP_COLOR[tier], fontWeight: 700 }}>{tier}</td>
                {orderCols.map((o) => <td key={o}>{(counts[tier] && counts[tier][o]) || 0}</td>)}
                <td className="muted">{counts[tier].noRule || 0}</td>
                <td className={(counts[tier].violation || 0) > 0 ? 'sqft-mismatch' : 'muted'}>{counts[tier].violation || 0}</td>
              </tr>
            ))}
          </tbody>
        </table>
      );
    },
  },
  {
    key: 'tierViolations',
    title: 'Παραβιάσεις κανόνα tier',
    // Καταστήματα με ήδη ανατεθειμένο κωδικό εκτός του δηλωμένου κανόνα tier τους — οι κανόνες
    // δεν εφαρμόζονται retroactively, οπότε τέτοιο drift χρειάζεται ενεργό εντοπισμό. Λεπτομερής
    // λίστα με ποια καταστήματα ακριβώς στο tab "Clusters ανά Store Tier" · "Παραβιάσεις".
    render: (stats) => (
      <div style={{ cursor: 'pointer' }} onClick={() => { location.hash = '#/cluster-tiers?mode=violations'; }}>
        <StatTile label="Καταστήματα εκτός δηλωμένων κωδικών"
          value={stats.tierViolationCount != null ? stats.tierViolationCount : '—'} />
      </div>
    ),
  },
];
const DASHBOARD_WIDGET_KEYS = DASHBOARD_WIDGET_DEFS.map((w) => w.key);
// Θεματικά sections. Σχήμα: { key, title, widgetKeys: [...] } — κάθε widget key χωρίς
// αντιστοίχιση εδώ πέφτει αυτόματα στο "Άλλα" section (βλ. DashboardPage), οπότε δεν είναι
// υποχρεωτικό να προστεθεί section εδώ πριν προστεθεί το ίδιο το widget.
const DASHBOARD_SECTIONS = [
  { key: 'overview', title: 'Επισκόπηση δικτύου', widgetKeys: ['networkOverview', 'storeTiers', 'displayFacesByType', 'displayDensity', 'staleRequests', 'tierPositionBreakdown', 'tierViolations'] },
];
const DASHBOARD_SECTION_BY_KEY = new Map(DASHBOARD_SECTIONS.flatMap((sec) => sec.widgetKeys.map((k) => [k, sec.key])));

// Τυλίγει ένα section's .dash-grid σε DndKit.DndContext/SortableContext ΜΟΝΟ όταν χρειάζεται
// (editMode + το vendored dnd-kit.min.js φορτώθηκε σωστά) — εκτός editMode το drag δεν έχει
// νόημα (WidgetCard δεν εκθέτει καν drag handle τότε), οπότε γλυτώνουμε το context overhead.
// Αν το window.DndKit λείπει για οποιοδήποτε λόγο (π.χ. δεν φορτώθηκε το vendor script),
// πέφτει σε απλό .dash-grid χωρίς drag — ΠΟΤΕ δεν σπάει η σελίδα, απλά χάνεται το reorder.
function DndKitSectionGrid({ editMode, sensors, sectionKeys, onDragEnd, children }) {
  if (!editMode || !window.DndKit) {
    return <div className={'dash-grid' + (editMode ? ' edit-mode' : '')}>{children}</div>;
  }
  const { DndContext, SortableContext, closestCenter, rectSortingStrategy } = window.DndKit;
  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
      <SortableContext items={sectionKeys} strategy={rectSortingStrategy}>
        <div className="dash-grid edit-mode">{children}</div>
      </SortableContext>
    </DndContext>
  );
}

function DashboardPage() {
  const [stats, setStats] = useState(null);
  const [error, setError] = useState(null);
  const viewPrefs = useViewPrefs('dashboard');
  // order/hidden/sizes: per-account, server-side (users.view_prefs) — ζητήθηκε ρητά ώστε η
  // προσαρμογή να ακολουθεί τον χρήστη cross-device, όχι μόνο ένα browser. sizes: { [key]:
  // 'S'|'M'|'L'|'Full' }, missing key -> 'M' (default, βλ. WidgetCard).
  const [order, setOrder] = usePersistedField(viewPrefs, 'order', DASHBOARD_WIDGET_KEYS);
  const [hidden, setHidden] = usePersistedField(viewPrefs, 'hidden', []);
  const [sizes, setSizes] = usePersistedField(viewPrefs, 'sizes', {});
  const [editMode, setEditMode] = useState(false);
  const dndSensors = window.DndKit ? window.DndKit.useSensors(window.DndKit.useSensor(window.DndKit.PointerSensor, { activationConstraint: { distance: 4 } })) : null;

  useEffect(() => {
    api.get('/api/dashboard/stats').then(setStats).catch((e) => setError(e.message));
  }, []);

  // order μπορεί να έχει μείνει "παλιό" αν προστέθηκε νέο widget μετά την τελευταία φορά που
  // αποθηκεύτηκε — ενώνει τυχόν λείποντα keys στο τέλος αντί να τα κρύβει σιωπηλά.
  const effectiveOrder = useMemo(() => {
    const known = new Set(DASHBOARD_WIDGET_KEYS);
    const kept = order.filter((k) => known.has(k));
    const missing = DASHBOARD_WIDGET_KEYS.filter((k) => !kept.includes(k));
    return [...kept, ...missing];
  }, [order]);
  const hiddenSet = useMemo(() => new Set(hidden), [hidden]);
  const visibleOrder = effectiveOrder.filter((k) => !hiddenSet.has(k));
  const widgetByKey = useMemo(() => Object.fromEntries(DASHBOARD_WIDGET_DEFS.map((w) => [w.key, w])), []);

  // Drag-and-drop reorder ΜΕΣΑ στο ίδιο section — ίδιο σκεπτικό με το παλιό move(): cross-section
  // drag θα σήμαινε ασαφώς "άλλαξε και section", εκτός scope εδώ. effectiveOrder παραμένει ΕΝΑ
  // flat array (ίδια αποθήκευση με πριν) — το DragEnd παίρνει τα δύο keys (ίδιου section, αφού
  // κάθε section έχει δικό του DndContext) και τα αναδιατάσσει μέσα στο ίδιο flat array.
  function handleDragEnd(sectionKeys) {
    return (event) => {
      const { active, over } = event;
      if (!over || active.id === over.id) return;
      setOrder((cur) => {
        const list = [...effectiveOrder];
        const oldSectionIndex = sectionKeys.indexOf(active.id);
        const newSectionIndex = sectionKeys.indexOf(over.id);
        if (oldSectionIndex === -1 || newSectionIndex === -1) return cur;
        const reorderedSection = window.DndKit.arrayMove(sectionKeys, oldSectionIndex, newSectionIndex);
        // Αντικαθιστά ΜΟΝΟ τα section keys μέσα στο flat order, στις ΘΕΣΕΙΣ που ήδη κατείχε
        // το section (διατηρεί τη σχετική θέση του section μέσα στο συνολικό array).
        const positions = sectionKeys.map((k) => list.indexOf(k)).sort((a, b) => a - b);
        const result = [...list];
        positions.forEach((pos, i) => { result[pos] = reorderedSection[i]; });
        return result;
      });
    };
  }
  function hide(key) { setHidden((cur) => [...new Set([...cur, key])]); }
  function unhide(key) { setHidden((cur) => cur.filter((k) => k !== key)); }
  function setSize(key, size) { setSizes((cur) => ({ ...cur, [key]: size })); }

  // Ομαδοποιεί τα ήδη-ταξινομημένα visibleOrder keys ανά section, ΔΙΑΤΗΡΩΝΤΑΣ τη σχετική
  // σειρά μέσα σε κάθε section (χρειάζεται για το move() swap παραπάνω). "Άλλα" section
  // (fallback) πιάνει οποιοδήποτε νέο key δεν έχει ακόμα αντιστοίχιση στο DASHBOARD_SECTIONS.
  const sectionsWithKeys = useMemo(() => {
    const sections = DASHBOARD_SECTIONS.map((sec) => ({ ...sec, keys: [] }));
    const otherKeys = [];
    for (const key of visibleOrder) {
      const secKey = DASHBOARD_SECTION_BY_KEY.get(key);
      const sec = sections.find((s) => s.key === secKey);
      if (sec) sec.keys.push(key); else otherKeys.push(key);
    }
    return otherKeys.length ? [...sections, { key: 'other', title: 'Άλλα', keys: otherKeys }] : sections;
  }, [visibleOrder]);

  return (
    <div className="page">
      <div className="dash-toolbar">
        <div className="field grow"><b>Dashboard</b></div>
        <button className={'btn ' + (editMode ? 'primary' : 'ghost') + ' sm'} onClick={() => setEditMode((v) => !v)}>
          {editMode ? 'Έτοιμο' : 'Προσαρμογή'}
        </button>
      </div>

      {editMode && hidden.length ? (
        <div className="dash-hidden-tray">
          <span>Κρυμμένα widgets:</span>
          {hidden.map((key) => (
            <button key={key} type="button" className="btn ghost sm" onClick={() => unhide(key)}>
              + {(widgetByKey[key] || {}).title || key}
            </button>
          ))}
        </div>
      ) : null}

      {error ? <div className="empty">Σφάλμα: {error}</div> : !stats ? (
        <div className="dash-grid">
          {DASHBOARD_WIDGET_DEFS.map((w) => (
            <WidgetCard key={w.key} title={w.title} loading size={w.wide ? 'Full' : 'M'} />
          ))}
        </div>
      ) : (
        sectionsWithKeys.map((sec) => sec.keys.length ? (
          <div className="dash-section" key={sec.key}>
            <h3 className="dash-section-title">{sec.title}</h3>
            <DndKitSectionGrid
              editMode={editMode} sensors={dndSensors} sectionKeys={sec.keys} onDragEnd={handleDragEnd(sec.keys)}
            >
              {sec.keys.map((key) => {
                const w = widgetByKey[key];
                if (!w) return null;
                return (
                  <WidgetCard key={key} id={key} title={w.title} size={sizes[key] || (w.wide ? 'Full' : 'M')}
                    alignStart={w.alignStart} editMode={editMode}
                    onSizeChange={(s) => setSize(key, s)} onHide={() => hide(key)}>
                    {w.render(stats)}
                  </WidgetCard>
                );
              })}
            </DndKitSectionGrid>
          </div>
        ) : null)
      )}
    </div>
  );
}

// Πλήρες, φιλτραρίσιμο/ταξινομήσιμο matrix ΟΛΩΝ των καταστημάτων (ομάδα/χώρα/net m²).
// Δικό του filter state (fetch το ήδη μικρό GET /api/stores dataset μία φορά, client-side
// filter/sort — ίδιο "fetch once, δούλεψε στη Node/JS" με το SqftPage), γι' αυτό το registry
// entry (βλ. DASHBOARD_WIDGET_DEFS) καλεί render() χωρίς να χρησιμοποιεί το stats param.
// Επιστρέφει μόνο το εσωτερικό περιεχόμενο (toolbar+table) — το WidgetCard δίνει chrome/
// τίτλο/size-picker/hide, ίδιο μοτίβο με κάθε άλλο widget στο registry.
function AllStoresMatrixWidget() {
  const [stores, setStores] = useState(null);
  const [q, setQ] = useState('');
  const [pickStoreGroups, setPickStoreGroups] = useState(() => new Set());
  const [pickCountries, setPickCountries] = useState(() => new Set());
  const [netMin, setNetMin] = useState('');
  const [netMax, setNetMax] = useState('');
  const [sortBy, setSortBy] = useState('netSpaceTotal');
  const [sortDir, setSortDir] = useState('desc');

  useEffect(() => {
    api.get('/api/stores').then(setStores).catch((e) => toastErr(e.message));
  }, []);

  function toggleSort(field) {
    if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    else { setSortBy(field); setSortDir('desc'); }
  }

  const shown = useMemo(() => {
    if (!stores) return [];
    let list = stores;
    const nq = q.trim().toLowerCase();
    if (nq) list = list.filter((s) => s.code.toLowerCase().includes(nq) || (s.name || '').toLowerCase().includes(nq));
    if (pickStoreGroups.size) list = list.filter((s) => s.storeGroup && pickStoreGroups.has(s.storeGroup));
    if (pickCountries.size) list = list.filter((s) => pickCountries.has(s.country || 'GR'));
    if (netMin) list = list.filter((s) => s.netSpaceTotal != null && s.netSpaceTotal >= Number(netMin));
    if (netMax) list = list.filter((s) => s.netSpaceTotal != null && s.netSpaceTotal <= Number(netMax));
    return list.slice().sort((a, b) => {
      const valueFor = (s) => (sortBy === 'code' ? s.code : sortBy === 'storeGroup' ? (s.storeGroup || '') : sortBy === 'country' ? (s.country || 'GR') : s.netSpaceTotal);
      const av = valueFor(a), bv = valueFor(b);
      const an = av == null, bn = bv == null;
      if (an && bn) return 0;
      if (an) return 1;
      if (bn) return -1;
      const cmp = typeof av === 'string' ? String(av).localeCompare(String(bv)) : av - bv;
      return sortDir === 'asc' ? cmp : -cmp;
    });
  }, [stores, q, pickStoreGroups, pickCountries, netMin, netMax, sortBy, sortDir]);

  const anyFilterActive = q || pickStoreGroups.size || pickCountries.size || netMin || netMax;
  function clearFilters() { setQ(''); setPickStoreGroups(new Set()); setPickCountries(new Set()); setNetMin(''); setNetMax(''); }

  return (
    <>
      <div className="toolbar" style={{ margin: 0 }}>
        <div className="field">
          <label>Αναζήτηση</label>
          <input className="search" placeholder="κωδικός ή όνομα…" value={q} onChange={(e) => setQ(e.target.value)} />
        </div>
        <div className="field" style={{ minWidth: 180 }}>
          <MultiSelectDropdown label="Ομάδα καταστήματος" options={STORE_GROUP_LABELS} selected={pickStoreGroups}
            onToggle={(label) => setPickStoreGroups((s) => toggleInSet(s, label))} placeholder="Όλες οι ομάδες" />
        </div>
        <div className="field" style={{ minWidth: 140 }}>
          <MultiSelectDropdown label="Χώρα" options={['GR', 'CY']} selected={pickCountries}
            onToggle={(label) => setPickCountries((s) => toggleInSet(s, label))} placeholder="Όλες" />
        </div>
        <div className="field">
          <label>Net m² (από-έως)</label>
          <div style={{ display: 'flex', gap: 4 }}>
            <input style={{ width: 70 }} type="number" value={netMin} onChange={(e) => setNetMin(e.target.value)} placeholder="από" />
            <input style={{ width: 70 }} type="number" value={netMax} onChange={(e) => setNetMax(e.target.value)} placeholder="έως" />
          </div>
        </div>
        <div className="field">
          <label>&nbsp;</label>
          <button className="btn ghost sm" onClick={clearFilters} disabled={!anyFilterActive}>Καθαρισμός φίλτρων</button>
        </div>
        <div className="spacer" />
        <div className="count" style={{ alignSelf: 'flex-end', paddingBottom: 6 }}>{shown.length.toLocaleString('el')} καταστήματα</div>
      </div>
      <div className="tablewrap" style={{ marginTop: 10 }}>
        <table className="dash-table">
          <thead>
            <tr>
              <SortableTh field="code" label="Κατάστημα" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="storeGroup" label="Ομάδα" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="country" label="Χώρα" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
              <SortableTh field="netSpaceTotal" label="Net m²" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} />
            </tr>
          </thead>
          <tbody>
            {!stores ? (
              <tr><td colSpan={4} className="empty-cell">Φόρτωση…</td></tr>
            ) : shown.length === 0 ? (
              <tr><td colSpan={4} className="empty-cell">Κανένα κατάστημα</td></tr>
            ) : shown.map((s) => (
              <tr key={s.code}>
                <td>{s.code}{s.name ? ` · ${s.name}` : ''}</td>
                <td>
                  {s.storeGroup && STORE_GROUP_COLOR[s.storeGroup] ? (
                    <span className="chip" style={{ background: STORE_GROUP_COLOR[s.storeGroup], color: '#fff' }}>{s.storeGroup}</span>
                  ) : '—'}
                </td>
                <td>{s.country || 'GR'}</td>
                <td>{s.netSpaceTotal != null ? formatSqm(s.netSpaceTotal) : <span className="cellempty">—</span>}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </>
  );
}

function DashboardCompareWidget() {
  const [taxonomy, setTaxonomy] = useState({ family: [], category: [] });
  const [family, setFamily] = useState('');
  const [category, setCategory] = useState('');
  const [pickStoreCodes, setPickStoreCodes] = useState(() => new Set());
  const [pickStoreGroups, setPickStoreGroups] = useState(() => new Set());
  const [storeOptions, setStoreOptions] = useState([]);
  const [result, setResult] = useState(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    api.get('/api/spacegroups/taxonomy').then((t) => setTaxonomy({ family: t.family || [], category: t.category || [] })).catch(() => {});
    api.get('/api/stores').then((rows) => setStoreOptions(rows.map((s) => `${s.code} · ${s.name}`))).catch(() => {});
  }, []);

  const storeCodeByLabel = useMemo(() => new Map(storeOptions.map((label) => [label, label.split(' · ')[0]])), [storeOptions]);
  const pickStoreLabels = useMemo(() => new Set([...pickStoreCodes].map((c) => storeOptions.find((l) => l.startsWith(c + ' · '))).filter(Boolean)), [pickStoreCodes, storeOptions]);

  async function run() {
    if (!family && !category) { setError('Διάλεξε Family ή Category'); return; }
    setBusy(true); setError(null);
    try {
      const params = qs({
        family: family || undefined, category: category || undefined,
        storeCodes: [...pickStoreCodes].join(','), storeGroups: [...pickStoreGroups].join(','),
      });
      const data = await api.get('/api/dashboard/compare' + params);
      setResult(data);
    } catch (e) { setError(e.message); }
    setBusy(false);
  }

  return (
    <>
      <div className="toolbar" style={{ margin: 0 }}>
        <div className="field">
          <label>Family</label>
          <select value={family} onChange={(e) => { setFamily(e.target.value); if (e.target.value) setCategory(''); }}>
            <option value="">—</option>
            {taxonomy.family.map((f) => <option key={f} value={f}>{f}</option>)}
          </select>
        </div>
        <div className="field">
          <label>Category</label>
          <select value={category} onChange={(e) => { setCategory(e.target.value); if (e.target.value) setFamily(''); }}>
            <option value="">—</option>
            {taxonomy.category.map((c) => <option key={c} value={c}>{c}</option>)}
          </select>
        </div>
        <div className="field" style={{ minWidth: 220 }}>
          <MultiSelectDropdown label="Καταστήματα (κενό = όλα)" options={storeOptions} selected={pickStoreLabels}
            onToggle={(label) => setPickStoreCodes((s) => toggleInSet(s, storeCodeByLabel.get(label)))}
            placeholder="Όλα τα καταστήματα" />
        </div>
        <div className="field" style={{ minWidth: 180 }}>
          <MultiSelectDropdown label="Ομάδα καταστήματος" options={STORE_GROUP_LABELS} selected={pickStoreGroups}
            onToggle={(label) => setPickStoreGroups((s) => toggleInSet(s, label))} placeholder="Όλες οι ομάδες" />
        </div>
        <div className="field">
          <label>&nbsp;</label>
          <button className="btn primary sm" disabled={busy} onClick={run}>{busy ? 'Φόρτωση…' : 'Εμφάνιση'}</button>
        </div>
      </div>
      {error ? <div className="widgetcard-error">{error}</div> : null}
      {result ? (
        result.stores.length === 0 || (result.series || []).length === 0 ? (
          <div className="empty">Καμία αντιστοιχία.</div>
        ) : (
          <>
            <LineChart
              series={result.series}
              labels={result.stores.map((s) => s.code)}
              fullLabels={result.stores.map((s) => `${s.code} · ${s.name}`)}
              colorFor={(name, i) => CHART_PALETTE[i % CHART_PALETTE.length]}
            />
            {result.displayTotal != null ? (
              <div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
                <BarChart
                  entries={[{ label: 'Display', value: result.displayTotal }, { label: 'Non-Display', value: result.nonDisplayTotal }]}
                  colorFor={(label) => (label === 'Display' ? '#1f8a4c' : '#64708a')} />
              </div>
            ) : null}
          </>
        )
      ) : null}
    </>
  );
}

// Idle-lock — 15 λεπτά χωρίς δραστηριότητα (βλ. AppShell). MM:SS, χωρίς κορυφαία μηδενικά στα
// λεπτά (π.χ. "9:05", όχι "09:05" — ταιριάζει με τη σύντομη διάρκεια, ποτέ >15).
const IDLE_TIMEOUT_SECONDS = 15 * 60;
const IDLE_TIMEOUT_MS = IDLE_TIMEOUT_SECONDS * 1000;
function formatCountdown(totalSeconds) {
  const m = Math.floor(totalSeconds / 60);
  const s = totalSeconds % 60;
  return `${m}:${String(s).padStart(2, '0')}`;
}

function AppShell({ me, onLoggedOut, onMeRefresh, onIdleLogout }) {
  const [route, setRoute] = useState(currentRoute());
  const [reference, setReference] = useState(null);
  const [cartOpen, setCartOpen] = useState(false);
  const [profileOpen, setProfileOpen] = useState(false);
  const [changePasswordOpen, setChangePasswordOpen] = useState(false);
  const cart = useRequestCart();

  // Idle timeout — 15 λεπτά χωρίς καμία δραστηριότητα (click/πλήκτρο) -> πραγματικό logout:
  // καθαρίζουμε το session cookie server-side (POST /api/auth/logout) ΚΑΙ unmount-άρουμε το
  // AppShell ΑΜΕΣΩΣ (onIdleLogout, βλ. App) αντί να περιμένουμε ένα click πάνω σε ένα overlay.
  // Κρίσιμο ΓΙΑΤΙ αμέσως: όσο το AppShell μένει mounted (π.χ. περιμένοντας το click), οι δικοί
  // του background pollers (NotificationsProvider κτλ) συνεχίζουν να τρέχουν με το ήδη-νεκρό
  // cookie, παίρνουν 401, και πυροδοτούν το ΓΕΝΙΚΟ onUnauthorized handler — race που μπορεί να
  // ξαναγράψει το state πριν προλάβει ο χρήστης να κάνει click. Unmount αμέσως = καμία τέτοια
  // κούρσα, ΚΑΙ ξαναχρησιμοποιεί το ήδη υπάρχον anon/locked→LoginPage flow (ίδιο click-to-reveal
  // clock screen, βλ. LockScreen) αντί για ξεχωριστό overlay-only μηχανισμό. ref (όχι μόνο state)
  // ώστε το setInterval να μη χρειάζεται να ξαναδημιουργείται σε κάθε tick.
  const idleLockedRef = useRef(false);
  const lastActivityRef = useRef(Date.now());
  const [idleSecondsLeft, setIdleSecondsLeft] = useState(IDLE_TIMEOUT_SECONDS);
  useEffect(() => {
    function markActivity() { lastActivityRef.current = Date.now(); }
    window.addEventListener('click', markActivity);
    window.addEventListener('keydown', markActivity);
    const tick = setInterval(() => {
      if (idleLockedRef.current) return;
      const remaining = IDLE_TIMEOUT_MS - (Date.now() - lastActivityRef.current);
      if (remaining <= 0) {
        idleLockedRef.current = true;
        setIdleSecondsLeft(0);
        api.send('POST', '/api/auth/logout').catch(() => {});
        onIdleLogout();
      } else {
        setIdleSecondsLeft(Math.ceil(remaining / 1000));
      }
    }, 1000);
    return () => {
      window.removeEventListener('click', markActivity);
      window.removeEventListener('keydown', markActivity);
      clearInterval(tick);
    };
  }, []);

  useEffect(() => {
    const onHash = () => setRoute(currentRoute());
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);

  // Ξαναφορτώνει σε ΚΑΘΕ αλλαγή route, όχι μόνο στο mount — π.χ. ένα φρέσκο Spacegroups CSV
  // import στο Διαχείριση → Import και μετά αμέσως άνοιγμα "Νέα γραμμή" στο Clusters (χωρίς
  // reload σελίδας) έδειχνε άδειο spacegroup dropdown, γιατί το reference (spaceGroupNames
  // κτλ) είχε φορτωθεί ΜΙΑ φορά στην αρχή του session και ποτέ ξανά.
  useEffect(() => {
    // latestReferenceCache ενημερώνεται εδώ ώστε το NotificationsProvider (ζει ΕΝΑ επίπεδο πιο
    // πάνω από το AppShell, χωρίς δικό του πρόσβαση στο reference) να μπορεί να δείξει σωστά
    // ονόματα spacegroup/store στο toast ενός notification που μόλις έφτασε.
    api.get('/api/reference').then((r) => { setReference(r); latestReferenceCache = r; }).catch(() => {});
  }, [route]);

  // blur(): χωρίς αυτό, ένα sidebar-item κρατάει focus μετά το κλικ και το :focus-within
  // (βλ. .sidebar CSS, χρειάζεται για πλοήγηση με πληκτρολόγιο/Tab) κρατάει το sidebar
  // "κολλημένο" ανοιχτό ακόμα και αφού φύγει το ποντίκι — bug, όχι ζητούμενο.
  const go = (r) => { document.activeElement?.blur(); location.hash = '#/' + r; };

  async function logout() {
    try { await api.send('POST', '/api/auth/logout'); } catch {}
    onLoggedOut();
  }

  const showRequestsTab = CAN_SEE_REQUESTS_TAB(me.role);
  const requestCounts = useRequestCounts();
  const activePendingCount = requestCounts ? requestCounts.counts.cluster.pending + requestCounts.counts.store_assignment.pending + requestCounts.counts.spacegroup.pending : 0;
  const requestsTabTitle = activePendingCount ? `${activePendingCount} εκκρεμή αιτήματα` : 'Αιτήματα';

  let page;
  if (route === 'users' && (CAN_MANAGE_USERS(me.role) || CAN_VIEW_TEAM(me.role))) page = <UsersAdminPage />;
  else if (route === 'import' && CAN_MANAGE_USERS(me.role)) page = <ImportPage />;
  else if (route === 'pmm' && CAN_VIEW_PMM(me.role)) page = <PmmAdminPage />;
  else if (route === 'spacegroups' && CAN_MANAGE_SPACEGROUPS(me.role)) page = <SpacegroupsAdminPage reference={reference} />;
  else if (route === 'cluster-tiers' && CAN_VIEW_SPACEGROUPS(me.role)) page = <ClusterTiersPage />;
  else if (route === 'requests' && showRequestsTab) page = <RequestsPage reference={reference} />;
  else if (route === 'stores-admin' && CAN_MANAGE_SPACEGROUPS(me.role)) page = <StoresAdminPage />;
  else if (route === 'sqft' && CAN_VIEW_SPACEGROUPS(me.role)) page = <SqftPage />;
  else if (route === 'dashboard' && CAN_VIEW_DASHBOARD(me.role)) page = <DashboardPage />;
  else if (route === 'docs') page = <DocsPage />;
  else if (route === 'stores') page = <StoresPage reference={reference} />;
  else page = <ClustersPage reference={reference} />;

  // Ενιαία, επίπεδη λίστα nav items — όχι πια χωρισμένη σε κύριο rail + "Διαχείριση" dropdown
  // (ζητήθηκε ρητά "βγάλε όλο το μενού έξω από dropdown και βάλε το στο sidebar"). Κάθε admin
  // item κρατάει το ΔΙΚΟ του CAN_* gate (ίδιο σκεπτικό με πριν: ένας moderator βλέπει λιγότερα
  // items από έναν super_admin, στο ΙΔΙΟ flat sidebar). Τεκμηρίωση ΕΞΑΙΡΕΙΤΑΙ από αυτή τη λίστα
  // (ζητήθηκε ρητά να μένει κάτω, εκτός reorder/hide pool) — βλ. docsItem παρακάτω.
  const allNavItems = [
    CAN_VIEW_DASHBOARD(me.role) ? { key: 'dashboard', label: 'Dashboard', icon: 'dashboard' } : null,
    { key: 'clusters', label: 'Clusters', icon: 'layers' },
    { key: 'stores', label: 'Cluster ανά κατάστημα', icon: 'store' },
    showRequestsTab ? { key: 'requests', label: 'Αιτήματα', icon: 'inbox', title: requestsTabTitle, badge: activePendingCount } : null,
    CAN_MANAGE_SPACEGROUPS(me.role) ? { key: 'spacegroups', label: 'Spacegroups', icon: 'tag' } : null,
    CAN_VIEW_SPACEGROUPS(me.role) ? { key: 'cluster-tiers', label: 'Clusters ανά Store Tier', icon: 'sliders' } : null,
    CAN_MANAGE_USERS(me.role) ? { key: 'users', label: 'Χρήστες', icon: 'user' } : null,
    CAN_VIEW_PMM(me.role) ? { key: 'pmm', label: 'PMM', icon: 'clipboard' } : null,
    CAN_MANAGE_USERS(me.role) ? { key: 'import', label: 'Import', icon: 'download' } : null,
    CAN_MANAGE_SPACEGROUPS(me.role) ? { key: 'stores-admin', label: 'Καταστήματα', icon: 'pin' } : null,
    CAN_VIEW_SPACEGROUPS(me.role) ? { key: 'sqft', label: 'Τετραγωνικά', icon: 'maximize' } : null,
  ].filter(Boolean);
  const navByKey = new Map(allNavItems.map((i) => [i.key, i]));
  const navKeys = allNavItems.map((i) => i.key);
  // Τεκμηρίωση: ζητήθηκε ρητά ορατή σε ΟΛΟΥΣ τους συνδεδεμένους χρήστες ("να ξέρει ο καθένας τι
  // κάνει και που εμπλέκεται") — πριν ήταν αόρατη/απρόσιτη έξω από super_admin, παρότι η ίδια η
  // σελίδα ήδη έκρυβε εσωτερικά τα super_admin-only sections (βλ. DOCS_SECTIONS superAdminOnly).
  const showDocs = true;

  // Σειρά/κρυφά sidebar items — per-account, server-side (users.view_prefs, tab 'sidebar'),
  // ζητήθηκε ρητά. Ίδιο "άγνωστο key -> πάει στο τέλος" fallback με το Dashboard's order/hidden
  // (βλ. DashboardPage.effectiveOrder) — ένα item που ο χρήστης δεν έχει ξαναδεί (νέος ρόλος,
  // νέο tab) εμφανίζεται πάντα, απλά στο τέλος της λίστας.
  const sidebarPrefs = useViewPrefs('sidebar');
  const [navOrder, setNavOrder] = usePersistedField(sidebarPrefs, 'order', []);
  const [navHidden, setNavHidden] = usePersistedField(sidebarPrefs, 'hidden', []);
  const effectiveNavOrder = useMemo(() => {
    const known = new Set(navKeys);
    const kept = navOrder.filter((k) => known.has(k));
    const missing = navKeys.filter((k) => !kept.includes(k));
    return [...kept, ...missing];
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [navOrder, navKeys.join(',')]);
  const navHiddenSet = useMemo(() => new Set(navHidden.filter((k) => navKeys.includes(k))), [navHidden, navKeys.join(',')]);
  const visibleNavKeys = effectiveNavOrder.filter((k) => !navHiddenSet.has(k));
  const hiddenNavKeys = effectiveNavOrder.filter((k) => navHiddenSet.has(k));

  // Edit mode: μπαίνεις κρατώντας παρατεταμένο κλικ στο λογότυπο (βλ. sidebar-brand παρακάτω),
  // βγαίνεις με ένα κανονικό (σύντομο) κλικ πάνω του ενώ είσαι ήδη σε edit mode — ίδιο στοιχείο,
  // δύο interactions, καμία επιπλέον κουμπί/chrome χρειάζεται. longPressFiredRef ξεχωρίζει ένα
  // "μεγάλο" mousedown→mouseup (που ήδη άνοιξε edit mode μέσω του timer) από ένα κανονικό κλικ,
  // ώστε το click μετά από ένα επιτυχημένο long-press να ΜΗΝ πλοηγήσει επιπλέον.
  const [sidebarEditMode, setSidebarEditMode] = useState(false);
  const longPressTimerRef = useRef(null);
  const longPressFiredRef = useRef(false);
  const LONG_PRESS_MS = 550;
  function startLongPress() {
    longPressFiredRef.current = false;
    longPressTimerRef.current = setTimeout(() => { longPressFiredRef.current = true; setSidebarEditMode(true); }, LONG_PRESS_MS);
  }
  function cancelLongPress() {
    if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; }
  }
  function onBrandClick() {
    if (longPressFiredRef.current) { longPressFiredRef.current = false; return; }
    if (sidebarEditMode) { setSidebarEditMode(false); return; }
    go(me.role === 'super_admin' ? 'dashboard' : 'clusters');
  }
  function hideNavItem(key) { setNavHidden((cur) => [...new Set([...cur, key])]); }
  function unhideNavItem(key) { setNavHidden((cur) => cur.filter((k) => k !== key)); }

  const sidebarDndSensors = window.DndKit
    ? window.DndKit.useSensors(window.DndKit.useSensor(window.DndKit.PointerSensor, { activationConstraint: { distance: 4 } }))
    : null;
  // Ίδιο "reorder μέσα σε ΕΝΑ flat array" pattern με DashboardPage.handleDragEnd, εδώ χωρίς
  // sections (ένα μόνο, ολόκληρο sidebar-nav) — reorder μόνο ανάμεσα στα ΟΡΑΤΑ items, τα κρυφά
  // κρατάνε τη θέση τους μέσα στο effectiveNavOrder μέχρι να ξανα-εμφανιστούν.
  function handleSidebarDragEnd(event) {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    setNavOrder(() => {
      const list = [...effectiveNavOrder];
      const oldVisIndex = visibleNavKeys.indexOf(active.id);
      const newVisIndex = visibleNavKeys.indexOf(over.id);
      if (oldVisIndex === -1 || newVisIndex === -1) return list;
      const reorderedVisible = window.DndKit.arrayMove(visibleNavKeys, oldVisIndex, newVisIndex);
      const positions = visibleNavKeys.map((k) => list.indexOf(k)).sort((a, b) => a - b);
      positions.forEach((pos, i) => { list[pos] = reorderedVisible[i]; });
      return list;
    });
  }

  const pageTitles = {
    dashboard: 'Dashboard', clusters: 'Clusters', stores: 'Cluster ανά κατάστημα', requests: 'Αιτήματα', docs: 'Documentation',
    spacegroups: 'Spacegroups', 'cluster-tiers': 'Clusters ανά Store Tier', users: 'Χρήστες', pmm: 'PMM', import: 'Import', 'stores-admin': 'Καταστήματα', sqft: 'Τετραγωνικά',
  };
  const pageTitle = pageTitles[route] || 'Clusters';

  return (
    <>
      <div className="app-shell">
        <aside className={'sidebar' + (sidebarEditMode ? ' sidebar-editing' : '')}>
          <div className="sidebar-brand" role="button" tabIndex={0}
            onMouseDown={startLongPress} onMouseUp={cancelLongPress} onMouseLeave={cancelLongPress}
            onTouchStart={startLongPress} onTouchEnd={cancelLongPress}
            onClick={onBrandClick} onKeyDown={(e) => { if (e.key === 'Enter') onBrandClick(); }}
            title={sidebarEditMode ? 'Πάτα για έξοδο από την επεξεργασία' : 'Κράτα πατημένο για επεξεργασία μενού'}>
            <div className="sidebar-logo-crop">
              <div className="wordmark wordmark-p sidebar-logo-p" role="img" aria-label="Public" />
              <div className="wordmark sidebar-logo" role="img" aria-label="Public" />
            </div>
          </div>

          {sidebarEditMode ? (
            <div className="sidebar-edit-hint">Σύρε <b>⠿</b> για αλλαγή σειράς · <b>✕</b> κρύβει ένα item · πάτα ξανά το λογότυπο για έξοδο</div>
          ) : null}

          <SidebarNavList editMode={sidebarEditMode} sensors={sidebarDndSensors} keys={visibleNavKeys} onDragEnd={handleSidebarDragEnd}>
            {visibleNavKeys.map((key) => {
              const item = navByKey.get(key);
              if (!item) return null;
              return (
                <SidebarNavItem key={key} id={key} item={item} active={route === key} editMode={sidebarEditMode}
                  onNavigate={() => go(key)} onHide={() => hideNavItem(key)} />
              );
            })}
          </SidebarNavList>

          {sidebarEditMode && hiddenNavKeys.length ? (
            <div className="sidebar-hidden-tray">
              <span className="sidebar-hidden-tray-label">Κρυμμένα:</span>
              {hiddenNavKeys.map((key) => {
                const item = navByKey.get(key);
                if (!item) return null;
                return (
                  <button key={key} type="button" className="btn ghost sm" onClick={() => unhideNavItem(key)}>
                    + {item.label}
                  </button>
                );
              })}
            </div>
          ) : null}

          {showDocs || CAN_VIEW_TEAM(me.role) ? (
            <nav className="sidebar-nav sidebar-nav-bottom">
              {/* "Διαχείριση ομάδας" — ζητήθηκε ρητά ξεχωριστό, κάτω-κάτω στο sidebar πάνω από
                  την Τεκμηρίωση (ίδιο σταθερό/μη-drag pool, ΟΧΙ μέσα στο reorderable
                  allNavItems) — routing στο ίδιο 'users' key/UsersAdminPage με τους admin/
                  super_admin, απλά με δικό της label. Ορατό ΚΑΙ σε space_user/on_demand_user
                  πλέον (broader read-only view, βλ. CAN_VIEW_TEAM/TEAM_VIEW_ROLES), όχι μόνο
                  στους managers. */}
              {CAN_VIEW_TEAM(me.role) ? (
                <button type="button" className={'sidebar-item ' + (route === 'users' ? 'active' : '')}
                  onClick={() => go('users')} title="Διαχείριση ομάδας">
                  <span className="sidebar-item-icon"><Icon name="team" size={19} /></span>
                  <span className="sidebar-label">Διαχείριση ομάδας</span>
                </button>
              ) : null}
              {showDocs ? (
                <button type="button" className={'sidebar-item ' + (route === 'docs' ? 'active' : '')}
                  onClick={() => go('docs')} title="Documentation">
                  <span className="sidebar-item-icon"><Icon name="book" size={19} /></span>
                  <span className="sidebar-label">Documentation</span>
                </button>
              ) : null}
              {/* Platform Summary — link προς το δημοσιευμένο interactive deck (Claude artifact),
                  ζητήθηκε ρητά "κάτω από το Documentation". Ανοίγει σε νέα καρτέλα (target="_blank")
                  αντί για in-app route/iframe — το artifact frame-runtime κάνει handshake με το
                  claude.ai host του, δεν είναι embeddable μέσα σε άλλο origin. Ίδιο showDocs
                  audience (ορατό σε όλους) με το Documentation ακριβώς από πάνω του. */}
              {showDocs ? (
                <a className="sidebar-item" href="https://claude.ai/code/artifact/a6e75e65-4f57-474a-84ca-ae32b2e25e50"
                  target="_blank" rel="noopener noreferrer" title="Platform Summary — ανοίγει σε νέα καρτέλα">
                  <span className="sidebar-item-icon"><Icon name="external" size={19} /></span>
                  <span className="sidebar-label">Platform Summary</span>
                </a>
              ) : null}
            </nav>
          ) : null}
        </aside>

        <div className="app-main">
          <header className="app-header">
            <div className="app-header-title">
              <span className="app-header-crumb">Cluster Console</span>
              <span className="app-header-sep">/</span>
              <b>{pageTitle}</b>
            </div>
            <div className="app-header-actions">
              <span className="idle-timer" title="Αυτόματο κλείδωμα μετά από 15' αδράνειας">
                <Icon name="history" size={13} />
                {formatCountdown(idleSecondsLeft)}
              </span>
              <NotificationBell reference={reference} />
              {CAN_SUBMIT_DISPLAY_REQUESTS(me.role) || CAN_SUBMIT_FEED_REQUESTS(me.role) ? (
                <button className="btn cartbtn" onClick={() => setCartOpen(true)}>
                  Σύνολο Αλλαγών{cart.items.length ? <span className="cartbadge">{cart.items.length}</span> : null}
                </button>
              ) : null}
              <ProfileMenu me={me} onLogout={logout}
                onOpenProfile={() => setProfileOpen(true)} onOpenChangePassword={() => setChangePasswordOpen(true)} />
            </div>
          </header>

          <main className="app-content">
            {page}
          </main>
        </div>
      </div>

      {cartOpen ? (
        <RequestCartDrawer reference={reference} onClose={() => setCartOpen(false)} onSubmitted={() => setCartOpen(false)} />
      ) : null}

      {profileOpen ? (
        <ProfileDrawer me={me} onClose={() => setProfileOpen(false)} onUpdated={() => { onMeRefresh(true); setProfileOpen(false); }}
          onSilentRefresh={() => onMeRefresh(true)} />
      ) : null}

      {changePasswordOpen ? (
        <ChangePasswordDrawer onClose={() => setChangePasswordOpen(false)} />
      ) : null}

      <Toasts />
    </>
  );
}

function App() {
  const [status, setStatus] = useState('loading'); // loading | anon | changepw | ready
  const [me, setMe] = useState(null);
  // Lock screen (βλ. LockScreen πιο πάνω) — ξεκλειδώνεται μία φορά ανά "anon" περίοδο, ξανά-
  // κλειδώνεται σε κάθε επιστροφή στο anon (logout ή session expiry), σαν πραγματικό lock screen.
  const [locked, setLocked] = useState(true);
  // true όταν φτάσαμε εδώ μέσω πραγματικού idle-logout (βλ. AppShell/onIdleLogout) — δείχνει ένα
  // ενημερωτικό μήνυμα στο LoginPage ("αποσυνδεθήκατε λόγω αδράνειας") αντί να εξαφανίζεται
  // σιωπηλά σαν κανονική λήξη session.
  const [idleNotice, setIdleNotice] = useState(false);

  // silent=true: lightweight refresh από ήδη-φορτωμένο AppShell (π.χ. μετά από avatar upload) —
  // ΔΕΝ περνάει από 'loading' (που θα unmount-άρε όλο το AppShell και θα έδειχνε ολοσέλιδο
  // "Φόρτωση…" flash). Το boot-time/login/changepw-transition flow (default, silent=false)
  // θέλει το 'loading' state κανονικά.
  const checkAuth = useCallback((silent) => {
    if (!silent) setStatus('loading');
    api.get('/api/auth/me')
      .then((d) => {
        setMe(d);
        setStatus(d.mustChangePassword ? 'changepw' : 'ready');
      })
      .catch(() => { setMe(null); setStatus('anon'); setLocked(true); });
  }, []);

  useEffect(() => {
    onUnauthorized = () => { setMe(null); setStatus('anon'); setLocked(true); };
    checkAuth();
    return () => { onUnauthorized = null; };
  }, [checkAuth]);

  // ThemeProvider ΕΚΤΟΣ status-branches, πριν καν ξέρουμε αν είμαστε loading/anon/ready —
  // login/changepw screens πρέπει επίσης να ζουν μέσα σε αυτό (ίδιο system-preference CSS
  // behavior ισχύει είτε το component υπάρχει είτε όχι, αλλά αυτό επιτρέπει μελλοντική
  // επέκταση χωρίς restructure). serverThemePref γίνεται null μέχρι να φορτώσει το `me`.
  const themeContent = (() => {
    if (status === 'loading') return <div className="boot">Φόρτωση…</div>;
    if (status === 'anon') {
      if (locked) return <LockScreen onUnlock={() => setLocked(false)} />;
      return <><LoginPage onLoggedIn={() => { setIdleNotice(false); checkAuth(); }} idleNotice={idleNotice} /><Toasts /></>;
    }
    if (status === 'changepw') return <><ChangePasswordPage onChanged={checkAuth} /><Toasts /></>;
    return (
      <AuthContext.Provider value={me}>
        <RequestCartProvider>
          <RequestCountsProvider>
            <NotificationsProvider>
              <UserProfileModalProvider>
                <AppShell me={me} onLoggedOut={() => { setMe(null); setStatus('anon'); setLocked(true); }}
                  onIdleLogout={() => { setIdleNotice(true); setMe(null); setStatus('anon'); setLocked(true); }}
                  onMeRefresh={checkAuth} />
              </UserProfileModalProvider>
            </NotificationsProvider>
          </RequestCountsProvider>
        </RequestCartProvider>
      </AuthContext.Provider>
    );
  })();

  return (
    <ThemeProvider serverThemePref={me && me.themePref}>
      {themeContent}
    </ThemeProvider>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
