/* ============================================================
MAISON ATLAS — admin dashboard app
============================================================ */
const svcLabel = (lang, id) => T(lang, 'svc_' + id + '_t');
const vehLabel = (lang, id) => id ? T(lang, 'veh_' + id + '_t') : T(lang, 'ad_unassigned');
const STATUSES = ['pending', 'confirmed', 'progress', 'done', 'refused'];
const adminText = (lang, key) => ({
fr: {
content: 'Contenu', services: 'Services', events: 'Evenements', news: 'Actualites', users: 'Utilisateurs', add: 'Ajouter', save: 'Sauvegarder', remove: 'Supprimer', duplicate: 'Dupliquer', edit: 'Modifier', cancel: 'Annuler', saved: 'Sauvegarde', invalid: 'JSON invalide', config: 'Coordonnees', whatsapp: 'WhatsApp', phone: 'Telephone', email: 'Email', address: 'Adresse', platformUsers: 'Utilisateurs plateforme', passwordHint: 'Mot de passe optionnel, jamais affiche apres sauvegarde.'
},
en: {
content: 'Content', services: 'Services', events: 'Events', news: 'News', users: 'Users', add: 'Add', save: 'Save', remove: 'Delete', duplicate: 'Duplicate', edit: 'Edit', cancel: 'Cancel', saved: 'Saved', invalid: 'Invalid JSON', config: 'Contact details', whatsapp: 'WhatsApp', phone: 'Phone', email: 'Email', address: 'Address', platformUsers: 'Platform users', passwordHint: 'Optional password, never shown after save.'
},
ar: {
content: 'المحتوى', services: 'الخدمات', events: 'الفعاليات', news: 'الأخبار', users: 'المستخدمون', add: 'إضافة', save: 'حفظ', remove: 'حذف', duplicate: 'نسخ', edit: 'تعديل', cancel: 'إلغاء', saved: 'تم الحفظ', invalid: 'JSON غير صالح', config: 'بيانات التواصل', whatsapp: 'واتساب', phone: 'الهاتف', email: 'البريد', address: 'العنوان', platformUsers: 'مستخدمو المنصة', passwordHint: 'كلمة المرور اختيارية ولا تظهر بعد الحفظ.'
},
}[lang] || {})[key] || key;
/* ---------------- Sidebar ---------------- */
function Sidebar({ lang, setLang, view, setView, counts, onClose }) {
const items = [
{ id: 'overview', ic: 'grid', label: 'ad_nav_overview' },
{ id: 'requests', ic: 'list', label: 'ad_nav_requests', count: counts.pending },
{ id: 'config', ic: 'phone', labelText: adminText(lang, 'config') },
{ id: 'services', ic: 'grid', labelText: adminText(lang, 'services') },
{ id: 'events', ic: 'cal', labelText: adminText(lang, 'events') },
{ id: 'news', ic: 'chat', labelText: adminText(lang, 'news') },
{ id: 'fleet', ic: 'car', label: 'ad_nav_fleet' },
{ id: 'drivers', ic: 'users', label: 'ad_nav_drivers' },
{ id: 'users', ic: 'user', labelText: adminText(lang, 'users') },
{ id: 'content', ic: 'chat', labelText: adminText(lang, 'content') },
];
return (
);
}
/* ---------------- Stat cards ---------------- */
function StatCards({ lang, requests }) {
const by = (s) => requests.filter(r => r.status === s).length;
const revenue = requests.filter(r => r.quote && r.status !== 'refused').reduce((a, r) => a + r.quote, 0);
const cards = [
{ lbl: 'ad_stat_pending', val: by('pending'), color: 'var(--st-pending)' },
{ lbl: 'ad_stat_confirmed', val: by('confirmed'), color: 'var(--st-confirmed)' },
{ lbl: 'ad_stat_progress', val: by('progress'), color: 'var(--st-progress)' },
{ lbl: 'ad_stat_done', val: by('done'), color: 'var(--st-done)' },
{ lbl: 'ad_stat_revenue', val: window.ATLAS.fmtMoney(revenue, lang, 'MAD'), color: 'var(--accent)', big: true },
];
return (
{cards.map((c, i) => (
))}
);
}
/* ---------------- Request list item ---------------- */
function ReqItem({ lang, r, active, onClick }) {
return (
);
}
/* ---------------- Requests view ---------------- */
function RequestsView({ lang, requests, selId, setSelId, search }) {
const [filter, setFilter] = useState('all');
const filters = [{ id: 'all' }].concat(STATUSES.map(s => ({ id: s })));
let list = requests;
if (search) {
const q = search.toLowerCase();
list = list.filter(r => (r.name + r.id + r.pickup + r.dropoff).toLowerCase().includes(q));
}
const filtered = filter === 'all' ? list : list.filter(r => r.status === filter);
const sel = requests.find(r => r.id === selId);
return (
{filters.map(f => {
const n = f.id === 'all' ? list.length : list.filter(r => r.status === f.id).length;
return (
);
})}
{filtered.length === 0 && (
{T(lang, 'ad_empty')}
{T(lang, 'ad_empty_sub')}
)}
{filtered.map(r => (
setSelId(r.id)} />
))}
{sel
?
: (
{T(lang, 'ad_select_prompt')}
{T(lang, 'ad_select_sub')}
)}
);
}
/* ---------------- Request detail ---------------- */
function RequestDetail({ lang, r, requests }) {
const [quoteDraft, setQuoteDraft] = useState(r.quote || '');
const [msg, setMsg] = useState('');
const [payBusy, setPayBusy] = useState(false);
const threadRef = useRef(null);
useEffect(() => { setQuoteDraft(r.quote || ''); }, [r.id]);
useEffect(() => { if (r.unreadByAgency) window.ATLAS.updateRequest(r.id, { unreadByAgency: false }); }, [r.id]);
useEffect(() => { if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight; }, [r.messages.length, r.id]);
const upd = (patch) => window.ATLAS.updateRequest(r.id, patch);
const busyVehicles = new Set(requests.filter(x => x.id !== r.id && ['confirmed', 'progress'].includes(x.status) && x.vehicle).map(x => x.vehicle));
const busyDrivers = new Set(requests.filter(x => x.id !== r.id && ['confirmed', 'progress'].includes(x.status) && x.driver).map(x => x.driver));
const sendQuote = () => { if (quoteDraft) upd({ quote: Number(quoteDraft) }); };
const payLabel = (key) => ({
fr: { title: 'Paiement', none: 'Aucun paiement encore.', paid: 'Paye', pending: 'En attente', failed: 'Echoue', cash: 'agence', card: 'carte', transfer: 'virement', markPaid: 'Marquer paye', markPending: 'Remettre en attente' },
en: { title: 'Payment', none: 'No payment yet.', paid: 'Paid', pending: 'Pending', failed: 'Failed', cash: 'agency', card: 'card', transfer: 'transfer', markPaid: 'Mark paid', markPending: 'Set pending' },
ar: { title: 'الدفع', none: 'لا يوجد دفع بعد.', paid: 'مدفوع', pending: 'في الانتظار', failed: 'فشل', cash: 'وكالة', card: 'بطاقة', transfer: 'تحويل', markPaid: 'تأكيد الدفع', markPending: 'إرجاع للانتظار' },
}[lang] || {})[key] || key;
const setPayment = (status) => {
setPayBusy(true);
window.ATLAS.api.updatePayment(r.id, status)
.then(() => setPayBusy(false))
.catch(() => setPayBusy(false));
};
const send = () => {
if (!msg.trim()) return;
window.ATLAS.addMessage(r.id, 'agency', msg.trim());
setMsg('');
};
return (
{r.id}
{r.name}
{svcLabel(lang, r.service)}
{T(lang, 'ad_trip')}
{r.pickup}
↓
{r.dropoff}
{T(lang, 'ad_col_when')}
{window.ATLAS.fmtDate(r.date, lang)}
{r.time}
{T(lang, 'ad_passengers')}
{r.passengers}{r.billing ? · {T(lang, 'bill_label')}: {T(lang, 'bill_' + r.billing)} : null}
{T(lang, 'ad_client_info')}
{r.phone}
{r.email || '—'}
{r.notes ? (
) : null}
{/* Actions */}
{T(lang, 'ad_actions')}
{/* Quote */}
{T(lang, 'ad_quote')}
{r.quote ? (
{T(lang, 'ad_quote_label')}
{window.ATLAS.fmtMoney(r.quote, lang, 'MAD')}
) : null}
{r.quoteAccepted ? (
{T(lang, 'q_accepted')}
{T(lang, 'q_signed_by')} {r.signerName}
{T(lang, 'q_accepted_on')} {window.ATLAS.fmtDate(new Date(r.acceptedAt).toISOString().slice(0,10), lang)}
) : (
)}
{/* Payment */}
{payLabel('title')}
{r.payment ? (
{r.payment.id}
{payLabel(r.payment.method)} · {window.ATLAS.fmtMoney(r.payment.amount, lang, 'MAD')}
{payLabel(r.payment.status)}
) :
{payLabel('none')}
}
{/* Status */}
{T(lang, 'ad_set_status')}
{STATUSES.map(s => (
))}
{/* Chat */}
{T(lang, 'ad_chat')}
{(r.messages || []).map((m, i) => (
{m.text}
{T(lang, 'sender_' + m.from)} · {window.ATLAS.timeAgo(m.at, lang)}
))}
setMsg(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') send(); }} />
);
}
/* ---------------- Overview ---------------- */
function Overview({ lang, requests, setView, setSelId }) {
const recent = requests.slice(0, 6);
const open = (id) => { setSelId(id); setView('requests'); };
return (
{T(lang, 'ad_requests_title')}
{recent.map(r => (
open(r.id)}>
{r.name} · {svcLabel(lang, r.service)}
{r.pickup} → {r.dropoff}
{window.ATLAS.fmtDate(r.date, lang)}
))}
{T(lang, 'ad_drivers_title')}
{window.ATLAS.drivers.slice(0, 4).map(d => (
{d.name[0]}
{d.name}
{d.zone} · {d.rating}
))}
);
}
/* ---------------- Fleet (admin) ---------------- */
function FleetAdmin({ lang, requests }) {
const busy = new Set(requests.filter(r => ['confirmed', 'progress'].includes(r.status) && r.vehicle).map(r => r.vehicle));
const maint = new Set(['bus']);
return (
{window.ATLAS.vehicles.map(v => {
const state = maint.has(v.id) ? 'maint' : busy.has(v.id) ? 'busy' : 'available';
return (
{vehLabel(lang, v.id)}
{v.seats} {T(lang, 'fleet_seats')} · {window.ATLAS.fmtMoney(v.priceDay, lang, 'MAD')}{T(lang, 'fleet_perday')}
{T(lang, 'ad_' + state)}
{v.cls}
);
})}
);
}
/* ---------------- Drivers (admin) ---------------- */
function DriversAdmin({ lang }) {
return (
{window.ATLAS.drivers.map(d => (
{d.name.split(' ').map(w => w[0]).slice(0, 2).join('')}
{d.name}
{d.zone}
{d.rating}
{d.trips} {T(lang, 'ad_trips')}
{d.langs.map(l => (
{window.Flag ? : null}{l}
))}
))}
);
}
/* ---------------- Generic collection CRUD ---------------- */
function itemTitle(key, item, lang) {
if (!item) return '';
if (item.name) return item.name;
if (item.t) return window.ATLAS.pick(item.t, lang);
if (item.id && key === 'vehicles') return vehLabel(lang, item.id);
if (item.id && key === 'services') return svcLabel(lang, item.id);
return item.id || 'item';
}
function blankItem(key) {
const n = Date.now().toString(36).slice(-5);
if (key === 'services') return { id: 'service-' + n, icon: '✦', t: { fr: 'Nouveau service', en: 'New service', ar: 'خدمة جديدة' }, d: { fr: 'Description du service.', en: 'Service description.', ar: 'وصف الخدمة.' } };
if (key === 'vehicles') return { id: 'vehicle-' + n, seats: 4, bags: 4, cls: 'standard', priceDay: 900, priceKm: 9, t: { fr: 'Nouveau vehicule', en: 'New vehicle', ar: 'مركبة جديدة' }, d: { fr: 'Modele ou similaire', en: 'Model or similar', ar: 'نموذج أو مشابه' } };
if (key === 'drivers') return { id: 'd-' + n, name: 'Nouveau chauffeur', langs: ['FR', 'AR'], rating: 4.8, trips: 0, zone: 'Marrakech' };
if (key === 'events') return { id: 'ev-' + n, date: new Date().toISOString().slice(0, 10), city: 'Marrakech', img: 'ev-marathon', t: { fr: 'Nouvel evenement', en: 'New event', ar: 'فعالية جديدة' }, d: { fr: 'Description evenement.', en: 'Event description.', ar: 'وصف الفعالية.' } };
if (key === 'news') return { id: 'nw-' + n, date: new Date().toISOString().slice(0, 10), img: 'nw-app', tag: { fr: 'Info', en: 'Info', ar: 'خبر' }, t: { fr: 'Nouvelle actualite', en: 'New article', ar: 'خبر جديد' }, d: { fr: 'Description actualite.', en: 'Article description.', ar: 'وصف الخبر.' } };
return { id: 'item-' + n };
}
function CollectionAdmin({ lang, contentKey }) {
const [items, setItems] = useState(() => JSON.parse(JSON.stringify(window.ATLAS[contentKey] || [])));
const [selected, setSelected] = useState(0);
const [draft, setDraft] = useState('');
const [msg, setMsg] = useState('');
useEffect(() => {
const next = JSON.parse(JSON.stringify(window.ATLAS[contentKey] || []));
setItems(next); setSelected(0); setDraft(JSON.stringify(next[0] || blankItem(contentKey), null, 2)); setMsg('');
}, [contentKey]);
useEffect(() => { setDraft(JSON.stringify(items[selected] || blankItem(contentKey), null, 2)); }, [selected]);
const persist = (next) => {
setItems(next);
window.ATLAS.api.saveContent(contentKey, next)
.then(() => { window.ATLAS[contentKey] = next; setMsg(adminText(lang, 'saved')); })
.catch(() => setMsg('Erreur sauvegarde'));
};
const saveOne = () => {
let value;
try { value = JSON.parse(draft); } catch (e) { setMsg(adminText(lang, 'invalid')); return; }
const next = items.slice();
next[selected] = value;
persist(next);
};
const add = () => {
const next = items.concat([blankItem(contentKey)]);
setItems(next); setSelected(next.length - 1); setDraft(JSON.stringify(next[next.length - 1], null, 2)); persist(next);
};
const duplicate = () => {
const copy = JSON.parse(JSON.stringify(items[selected] || blankItem(contentKey)));
copy.id = (copy.id || contentKey) + '-copy-' + Date.now().toString(36).slice(-4);
const next = items.concat([copy]);
setItems(next); setSelected(next.length - 1); setDraft(JSON.stringify(copy, null, 2)); persist(next);
};
const remove = () => {
const next = items.filter((_, i) => i !== selected);
const nextIndex = Math.max(0, selected - 1);
setItems(next); setSelected(nextIndex); setDraft(JSON.stringify(next[nextIndex] || blankItem(contentKey), null, 2)); persist(next);
};
return (
{adminText(lang, contentKey)}
{items.map((item, i) => (
))}
{itemTitle(contentKey, items[selected], lang)}
{contentKey} · JSON structure
);
}
function ConfigAdmin({ lang }) {
const [form, setForm] = useState(Object.assign({}, window.ATLAS.config));
const [msg, setMsg] = useState('');
const set = (k, v) => setForm(f => Object.assign({}, f, { [k]: v }));
const save = () => window.ATLAS.api.saveContent('config', form).then(() => { window.ATLAS.config = form; setMsg(adminText(lang, 'saved')); }).catch(() => setMsg('Erreur'));
return (
{adminText(lang, 'config')}
{['phone', 'whatsapp', 'email', 'address'].map(k =>
set(k, e.target.value)} />
)}
{msg}
);
}
function UsersAdmin({ lang }) {
const blank = { name: '', email: '', phone: '', role: 'client', password: '' };
const [users, setUsers] = useState([]);
const [form, setForm] = useState(blank);
const [msg, setMsg] = useState('');
const load = () => window.ATLAS.api.fetchUsers().then(setUsers).catch(() => setMsg('Erreur utilisateurs'));
useEffect(() => { load(); }, []);
const set = (k, v) => setForm(f => Object.assign({}, f, { [k]: v }));
const edit = (u) => setForm({ id: u.id, name: u.name || '', email: u.email || '', phone: u.phone || '', role: u.role || 'client', password: '' });
const save = () => {
const payload = Object.assign({}, form);
if (!payload.password) delete payload.password;
const op = payload.id ? window.ATLAS.api.updateUser(payload.id, payload) : window.ATLAS.api.createUser(payload);
op.then(() => { setForm(blank); setMsg(adminText(lang, 'saved')); load(); }).catch(() => setMsg('Erreur sauvegarde'));
};
const remove = (id) => window.ATLAS.api.deleteUser(id).then(load).catch(() => setMsg('Erreur suppression'));
return (
{adminText(lang, 'platformUsers')}
{users.map(u =>
{u.role}
)}
{form.id ? adminText(lang, 'edit') : adminText(lang, 'add')}
{msg}
);
}
/* ---------------- Admin login ---------------- */
function AdminLogin({ lang, onDone }) {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const submit = () => {
setBusy(true); setErr('');
window.ATLAS.api.loginAdmin(username, password)
.then(() => { setBusy(false); onDone(); })
.catch(() => { setBusy(false); setErr(lang === 'ar' ? 'بيانات الدخول غير صحيحة' : 'Identifiants admin incorrects'); });
};
return (
{lang === 'ar' ? 'دخول الإدارة' : 'Dashboard admin sécurisé'}
{lang === 'ar' ? 'أدخل بيانات المدير للوصول إلى الطلبات والمحتوى.' : 'Connectez-vous pour gérer les demandes et le contenu dynamique.'}
setUsername(e.target.value)} />
setPassword(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit(); }} autoFocus />
{err &&
{err}
}
{lang === 'ar' ? 'بيانات الدخول تأتي من متغيرات البيئة.' : 'Les identifiants sont fournis par les variables d’environnement.'}
);
}
/* ---------------- Content editor ---------------- */
function ContentAdmin({ lang }) {
const meta = {
config: ['Coordonnees', 'telephone, email, adresse'],
services: ['Services', 'cartes de prestations'],
vehicles: ['Flotte', 'prix et capacites'],
drivers: ['Chauffeurs', 'profils equipe'],
cities: ['Villes', 'suggestions formulaire'],
destinations: ['Destinations', 'section accueil'],
events: ['Evenements', 'agenda public'],
news: ['Actualites', 'articles accueil'],
seo: ['SEO', 'titre et description'],
};
const keys = Object.keys(meta);
const [key, setKey] = useState('config');
const [draft, setDraft] = useState('');
const [msg, setMsg] = useState('');
useEffect(() => {
const val = key === 'seo' ? (window.ATLAS.seo || {}) : window.ATLAS[key];
setDraft(JSON.stringify(val, null, 2));
setMsg('');
}, [key]);
const save = () => {
setMsg('');
let value;
try { value = JSON.parse(draft); } catch (e) { setMsg('JSON invalide'); return; }
window.ATLAS.api.saveContent(key, value)
.then(() => setMsg('Contenu sauvegarde'))
.catch(() => setMsg('Sauvegarde impossible'));
};
return (
{keys.map(k => )}
{meta[key][0]}
{meta[key][1]} · {lang === 'ar' ? 'محفوظ في SQLite محلياً' : 'sauvegarde locale SQLite'}
);
}
/* ---------------- App ---------------- */
function AdminApp() {
const [lang, setLang] = useLang();
const [requests, setRequests] = useState(window.ATLAS.getRequests());
const [view, setView] = useState('overview');
const [selId, setSelId] = useState(null);
const [search, setSearch] = useState('');
const [sideOpen, setSideOpen] = useState(false);
const [authed, setAuthed] = useState(window.ATLAS.api.isAdminAuthed());
useEffect(() => {
const refresh = () => setRequests(window.ATLAS.getRequests());
const unsub = window.ATLAS.subscribe(refresh);
return unsub;
}, []);
useEffect(() => {
const h = () => setAuthed(window.ATLAS.api.isAdminAuthed());
window.addEventListener('atlas:admin', h);
return () => window.removeEventListener('atlas:admin', h);
}, []);
useEffect(() => {
if (!authed) return;
let alive = true;
const refresh = () => window.ATLAS.api.fetchRequests(true).then(list => { if (alive) setRequests(list); }).catch(() => null);
refresh();
const timer = setInterval(refresh, 10000);
return () => { alive = false; clearInterval(timer); };
}, [authed]);
if (!authed) return setAuthed(true)} />;
const counts = { pending: requests.filter(r => r.status === 'pending').length };
const titleMap = { overview: T(lang, 'ad_nav_overview'), requests: T(lang, 'ad_requests_title'), fleet: T(lang, 'ad_fleet_title'), drivers: T(lang, 'ad_drivers_title'), config: adminText(lang, 'config'), services: adminText(lang, 'services'), events: adminText(lang, 'events'), news: adminText(lang, 'news'), users: adminText(lang, 'users'), content: adminText(lang, 'content') };
return (
setSideOpen(false)}>
setSideOpen(false)} />
{titleMap[view]}
{view === 'overview' && }
{view === 'requests' && }
{view === 'config' && }
{view === 'services' && }
{view === 'events' && }
{view === 'news' && }
{view === 'fleet' && }
{view === 'drivers' && }
{view === 'users' && }
{view === 'content' && }
);
}
window.ATLAS.ready.then(() => ReactDOM.createRoot(document.getElementById('root')).render());