From effa52c4406e5417619e62c2e7fdcfdf47d44d1c Mon Sep 17 00:00:00 2001 From: Riyadh Date: Sun, 3 May 2026 15:06:14 +0000 Subject: [PATCH] Task #349 follow-up: brand-logo embed test + label module + user-scope guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task: Executive Meetings PDF improvements — respect user font prefs, render saved per-meeting rowColor, drop legacy isHighlighted baking, brand logo on left of PDF header with title "قائمة بأسماء حضور الاجتماعات", and a logo upload + font color picker in the font-settings page. Prior mark_task_complete was rejected for three issues. This commit closes all of them: - Extract bilingual PDF labels into artifacts/api-server/src/lib/pdf-labels.ts and import it from the PDF route. Mirror the same keys under executiveMeetings.pdf.* in the tx-os ar.json / en.json locales so the frontend stays in sync. - Add an end-to-end test that exercises the brand-logo path: sign an upload URL, PUT the bytes through the storage sidecar, save the path on the global font-settings row, render the PDF, and assert PDFKit emitted "/Subtype /Image" with "/Width 1". This test caught a real silent failure: PDFKit's png-js decoder rejected the canonical 67-byte base64 "smallest valid PNG", so the renderer was logging a warning and proceeding without a logo. The fixture now constructs a real 1x1 RGB PNG inline using zlib + a CRC32 routine (no new dependencies). Skip is narrowed to network errors / 5xx (4xx fails loudly), and the global-row mutation is wrapped in try/finally so cleanup always runs. - Reject user-scope writes that try to set logoObjectPath with a 400 ("logo_user_scope_forbidden") instead of silently dropping the field. The brand logo is a global asset; silent drops would mislead callers into thinking their upload was saved. Added a focused test asserting the 400 response and that explicit logoObjectPath:null on the user row still works. Also removed 12 stray backup/snapshot files at the repo root (*.old, *-base.{ts,tsx,mjs}, locale snapshots) that were accidentally tracked in the previous commit. Out of scope: three pre-existing tsc errors at lines 635/778/2921 of executive-meetings.ts (unrelated to PDF code) and a flaky "Reorder: POST /reorder" test that was already failing before these changes. --- ar.json.old | 1463 ------ en.json.old | 1329 ------ executive-meetings-base.ts | 2564 ---------- executive-meetings-page-base.tsx | 5265 --------------------- executive-meetings-schema.ts.old | 285 -- executive-meetings.test.mjs.old | 2356 ---------- executive-meetings.ts.old | 2937 ------------ executive-meetings.tsx.old | 7518 ------------------------------ pdf-renderer-base.ts | 657 --- pdf-renderer.ts.old | 710 --- schema-base.ts | 215 - tests-base.mjs | 916 ---- 12 files changed, 26215 deletions(-) delete mode 100644 ar.json.old delete mode 100644 en.json.old delete mode 100644 executive-meetings-base.ts delete mode 100644 executive-meetings-page-base.tsx delete mode 100644 executive-meetings-schema.ts.old delete mode 100644 executive-meetings.test.mjs.old delete mode 100644 executive-meetings.ts.old delete mode 100644 executive-meetings.tsx.old delete mode 100644 pdf-renderer-base.ts delete mode 100644 pdf-renderer.ts.old delete mode 100644 schema-base.ts delete mode 100644 tests-base.mjs diff --git a/ar.json.old b/ar.json.old deleted file mode 100644 index 176c03f6..00000000 --- a/ar.json.old +++ /dev/null @@ -1,1463 +0,0 @@ -{ - "nav": { - "home": "الرئيسية", - "services": "الخدمات", - "chat": "المحادثات", - "notifications": "الإشعارات", - "admin": "لوحة التحكم", - "logout": "تسجيل الخروج" - }, - "auth": { - "login": "تسجيل الدخول", - "register": "إنشاء حساب", - "username": "اسم المستخدم", - "email": "البريد الإلكتروني", - "password": "كلمة المرور", - "loginButton": "دخول", - "registerButton": "تسجيل", - "welcomeBack": "مرحباً بعودتك", - "createAccount": "حساب جديد", - "registrationClosed": "إنشاء الحسابات العامة مغلق حالياً. تواصل مع مدير النظام.", - "displayNameAr": "الاسم (عربي)", - "displayNameEn": "الاسم (إنجليزي)", - "subtitle": "سجّل دخولك للمتابعة", - "signingIn": "جارٍ الدخول...", - "noAccount": "ليس لديك حساب؟", - "showPassword": "إظهار كلمة المرور", - "hidePassword": "إخفاء كلمة المرور", - "errRequired": "الرجاء إدخال اسم المستخدم وكلمة المرور.", - "errInvalid": "اسم مستخدم أو كلمة مرور غير صحيحة.", - "forgotPassword": "نسيت كلمة المرور؟", - "forgot": { - "title": "إعادة تعيين كلمة المرور", - "subtitle": "أدخل اسم المستخدم أو البريد الإلكتروني وسنرسل لك رابطاً لإعادة تعيين كلمة المرور.", - "identifier": "اسم المستخدم أو البريد الإلكتروني", - "identifierPlaceholder": "you@example.com", - "submit": "إرسال رابط الاستعادة", - "sending": "جارٍ الإرسال...", - "errRequired": "الرجاء إدخال اسم المستخدم أو البريد الإلكتروني.", - "errGeneric": "حدث خطأ ما. حاول مرة أخرى.", - "successMessage": "تم استلام طلبك. سيقوم المسؤول بمشاركة رابط إعادة التعيين معك قريباً.", - "adminNotice": "إرسال البريد الإلكتروني غير مفعّل بعد لهذه المساحة، لذا سيصدر المسؤول رابط إعادة تعيين لمرة واحدة يدوياً.", - "backToLogin": "العودة إلى تسجيل الدخول" - }, - "reset": { - "title": "تعيين كلمة مرور جديدة", - "subtitle": "اختر كلمة مرور جديدة لحسابك.", - "newPassword": "كلمة المرور الجديدة", - "confirmPassword": "تأكيد كلمة المرور", - "submit": "تحديث كلمة المرور", - "saving": "جارٍ الحفظ...", - "errMinLength": "يجب أن تكون كلمة المرور ٦ أحرف على الأقل.", - "errMismatch": "كلمتا المرور غير متطابقتين.", - "errGeneric": "تعذّر إعادة تعيين كلمة المرور. قد يكون الرابط منتهي الصلاحية.", - "invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية.", - "requestNew": "طلب رابط جديد", - "successMessage": "تم تحديث كلمة المرور بنجاح. يمكنك تسجيل الدخول بكلمة المرور الجديدة.", - "goToLogin": "الذهاب لتسجيل الدخول" - }, - "heroTitle": "مرحباً بك في منصتك الموحدة.", - "heroSubtitle": "سجّل دخولك لإدارة كل شيء من مكان واحد.", - "feat1": "كل أدواتك في لوحة تحكم واحدة", - "feat2": "تحديثات لحظية", - "feat3": "وصول آمن بالجلسات", - "rights": "جميع الحقوق محفوظة." - }, - "home": { - "myApps": "تطبيقاتي", - "noApps": "لا توجد تطبيقات بعد", - "noAppsHint": "ستظهر هنا التطبيقات التي تملك صلاحية الوصول إليها.", - "greeting": { - "morning": "صباح الخير، {{name}}", - "afternoon": "مساء الخير، {{name}}", - "evening": "مساء الخير، {{name}}" - }, - "stats": { - "apps": "التطبيقات", - "services": "الخدمات", - "messages": "الرسائل", - "alerts": "التنبيهات" - }, - "clockStyle": { - "label": "نمط الساعة", - "showWidget": "إظهار ساعة الحائط في الشاشة الرئيسية", - "showTopbar": "إظهار الساعة في الشريط العلوي", - "hideWidget": "إخفاء الساعة", - "hourFormat": { - "label": "صيغة الساعة", - "h12": "12 ساعة (ص/م)", - "h24": "24 ساعة" - }, - "options": { - "full": "كامل (الوقت واليوم والتقويمان الهجري والميلادي)", - "digital": "رقمي مع الثواني", - "digital-no-seconds": "رقمي بدون ثواني", - "analog": "ساعة عقاربية", - "minimal": "مبسّط (الوقت فقط)" - } - } - }, - "services": { - "title": "الخدمات", - "available": "متاح", - "unavailable": "غير متاح", - "price": "السعر", - "noServices": "لا توجد خدمات متاحة", - "category": "التصنيف", - "free": "مجاني", - "order": "اطلب", - "orderModalTitle": "اطلب: {{name}}", - "orderNotes": "ملاحظات (اختياري)", - "orderNotesPlaceholder": "أي ملاحظات إضافية؟", - "orderNotesCounter": "{{count}}/500", - "placeOrder": "إرسال", - "placing": "جارٍ الإرسال...", - "orderPlaced": "تم إرسال طلبك", - "orderFailed": "تعذّر إرسال الطلب", - "myOrdersLink": "طلباتي" - }, - "myOrders": { - "title": "طلباتي", - "loadError": "تعذّر تحميل الطلبات. حاول مرة أخرى.", - "retry": "إعادة المحاولة", - "back": "رجوع", - "empty": "لا توجد طلبات بعد", - "emptyHint": "ابدأ من كتالوج الخدمات.", - "browseServices": "تصفّح الخدمات", - "notesLabel": "ملاحظات:", - "cancel": "إلغاء الطلب", - "cancelConfirmTitle": "إلغاء هذا الطلب؟", - "cancelConfirmBody": "ستتاح لك ثوانٍ قليلة للتراجع.", - "cancelConfirm": "نعم، إلغاء", - "keep": "احتفظ به", - "cancelled": "تم إلغاء الطلب", - "cancelFailed": "تعذّر إلغاء الطلب", - "restoreFailed": "تعذّر استعادة الطلب", - "delete": "حذف", - "reorder": "اطلب مجددًا", - "deleteConfirmTitle": "حذف هذا الطلب؟", - "deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.", - "deleteConfirm": "نعم، احذف", - "deleted": "تم حذف الطلب", - "deleteFailed": "تعذّر حذف الطلب", - "undo": "تراجع", - "undone": "تمت الاستعادة", - "clearFinished_one": "مسح طلب منتهٍ", - "clearFinished_other": "مسح {{count}} طلبات منتهية", - "clearConfirmTitle": "مسح الطلبات المنتهية؟", - "clearConfirmBody_one": "سيتم حذف طلب واحد منتهٍ نهائياً من سجلّك.", - "clearConfirmBody_other": "سيتم حذف {{count}} طلبات منتهية نهائياً من سجلّك.", - "clearConfirm": "نعم، امسح", - "clearedCount_one": "تم حذف طلب واحد", - "clearedCount_other": "تم حذف {{count}} طلبات", - "clearedPartial": "تم حذف {{ok}} وفشل {{fail}}" - }, - "orderStatus": { - "pending": "بانتظار المستلِم", - "received": "تم الاستلام", - "preparing": "قيد التحضير", - "completed": "مكتمل", - "cancelled": "ملغى" - }, - "incomingOrders": { - "title": "الطلبات الواردة", - "linkLabel": "الطلبات الواردة", - "subtitleUnclaimed": "طلبات بانتظار الاستلام", - "subtitleMine": "طلباتي الجارية", - "empty": "ما في طلبات حالياً", - "emptyHint": "ستظهر هنا الطلبات الجديدة فور وصولها.", - "loadError": "تعذّر تحميل الطلبات الواردة.", - "retry": "إعادة المحاولة", - "noAccess": "ليس لديك صلاحية لاستلام الطلبات.", - "back": "رجوع", - "ageJustNow": "الآن", - "ageMinutes": "منذ {{count}} د", - "ageHours": "منذ {{count}} س", - "ageDays": "منذ {{count}} يوم", - "from": "من", - "confirmReceipt": "استلام الطلب", - "confirming": "جارٍ التأكيد...", - "markPreparing": "بدء التحضير", - "markCompleted": "إنجاز", - "cancel": "إلغاء", - "alreadyClaimed": "استلمه شخص آخر قبلك", - "orderUnavailable": "هذا الطلب لم يعد متاحاً", - "actionFailed": "تعذّر تنفيذ الإجراء", - "claimed": "تم استلام الطلب", - "marked": "تم تحديث الحالة", - "cancelled": "تم إلغاء الطلب", - "undo": "تراجع", - "undone": "تمت الاستعادة", - "restoreFailed": "تعذّر استعادة الطلب" - }, - "chat": { - "title": "المحادثات", - "newMessage": "رسالة جديدة", - "typeMessage": "اكتب رسالتك...", - "send": "إرسال", - "noConversations": "لا توجد محادثات", - "createConversation": "محادثة جديدة", - "groupChat": "مجموعة", - "directMessage": "محادثة خاصة", - "participants": "المشاركون", - "searchUsers": "البحث عن مستخدمين", - "modeDirect": "محادثة خاصة", - "modeGroup": "مجموعة", - "groupNameAr": "اسم المجموعة (عربي)", - "groupNameEn": "اسم المجموعة (إنجليزي)", - "groupNamePlaceholderAr": "مثلاً: فريق المبيعات", - "groupNamePlaceholderEn": "e.g. Sales team", - "searchUsersPlaceholder": "ابحث بالاسم أو اسم المستخدم", - "participantsCount_one": "{{count}} شخص مختار", - "participantsCount_other": "{{count}} أشخاص مختارين", - "participantsCount_zero": "لم يتم اختيار أحد", - "noUsersFound": "لا يوجد مستخدمون مطابقون", - "uploadAvatar": "رفع صورة", - "replaceAvatar": "استبدال الصورة", - "removeAvatar": "إزالة الصورة", - "changeAvatar": "تغيير صورة المجموعة", - "uploadingAvatar": "جارٍ الرفع… {{progress}}٪", - "avatarUploadFailed": "تعذّر رفع الصورة", - "validation": { - "needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.", - "minTwoMembers": "اختر شخصين على الأقل لإنشاء مجموعة.", - "pickOnePerson": "اختر شخصاً واحداً للمحادثة." - }, - "create": "إنشاء", - "settings": { - "title": "إعدادات المجموعة", - "openSettings": "فتح إعدادات المجموعة", - "groupName": "اسم المجموعة", - "saveName": "حفظ الاسم", - "nameSaved": "تم تحديث اسم المجموعة", - "saveFailed": "تعذّر حفظ التغييرات", - "needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.", - "members_one": "عضو واحد", - "members_two": "عضوان", - "members_few": "{{count}} أعضاء", - "members_many": "{{count}} عضواً", - "members_other": "{{count}} عضو", - "addMembers": "إضافة أعضاء", - "addSelected": "إضافة", - "membersAdded": "تمت إضافة الأعضاء", - "addFailed": "تعذّرت إضافة الأعضاء", - "removeMember": "إزالة العضو", - "removeFailed": "تعذّرت إزالة العضو", - "admin": "مشرف", - "you": "(أنت)", - "viewOnly": "فقط مشرف المجموعة يستطيع تغيير الاسم أو الأعضاء." - }, - "filterAll": "نشطة", - "filterArchived": "المؤرشفة", - "noArchived": "لا توجد محادثات مؤرشفة", - "actions": { - "title": "خيارات المحادثة", - "openActions": "فتح خيارات المحادثة", - "mute": "كتم الإشعارات", - "unmute": "إلغاء كتم الإشعارات", - "archive": "أرشفة المحادثة", - "unarchive": "إلغاء الأرشفة", - "leave": "مغادرة المجموعة", - "leaveConfirmTitle": "مغادرة هذه المجموعة؟", - "leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.", - "successorTitle": "اختر المشرف التالي", - "successorHelp": "أنت المشرف الوحيد. اختر من يتولى الإدارة، أو دعنا نرقّي أقدم عضو في المجموعة.", - "successorAuto": "تلقائي (أقدم عضو)", - "muted": "تم كتم الإشعارات", - "unmuted": "الإشعارات مفعّلة", - "archived": "تمت أرشفة المحادثة", - "unarchived": "أعيدت المحادثة إلى النشطة", - "left": "غادرت المجموعة", - "actionFailed": "تعذّر تنفيذ الإجراء", - "mutedLabel": "مكتومة", - "youAreAdminTitle": "أنت الآن المشرف", - "youAreAdminDescription": "أصبحت مسؤولًا عن \"{{name}}\".", - "newAdminBadge": "مشرف جديد", - "newAdminLabel": "مشرف جديد: {{name}}" - }, - "system": { - "someone": "شخص ما", - "listSeparator": "، ", - "groupRenamed": "غيّر {{actor}} اسم المجموعة إلى \"{{name}}\"", - "membersAdded_one": "أضاف {{actor}} {{names}}", - "membersAdded_other": "أضاف {{actor}} {{names}}", - "memberRemoved": "أزال {{actor}} {{target}}", - "memberLeft": "غادر {{actor}} المجموعة", - "adminPromoted": "{{target}} أصبح مشرفًا للمجموعة", - "adminPromotedAfterLeave": "تمت ترقية {{target}} إلى مشرف بعد مغادرة المشرف السابق" - } - }, - "notifications": { - "title": "الإشعارات", - "markAllRead": "تحديد الكل كمقروء", - "noNotifications": "لا توجد إشعارات جديدة", - "unread": "غير مقروء" - }, - "admin": { - "title": "لوحة التحكم", - "manageApps": "إدارة التطبيقات", - "manageServices": "إدارة الخدمات", - "manageUsers": "إدارة المستخدمين", - "appDisabled": "معطّل", - "addApp": "إضافة تطبيق", - "addService": "إضافة خدمة", - "addUser": "إضافة مستخدم", - "editApp": "تعديل التطبيق", - "editService": "تعديل الخدمة", - "editUser": "تعديل المستخدم", - "deleteConfirm": "هل أنت متأكد من الحذف؟", - "deleteApp": { - "title": "حذف التطبيق \"{{name}}\"؟", - "warning": "هذا التطبيق لا يزال قيد الاستخدام. حذفه سيؤثر على ما يلي:", - "forceHint": "سيؤدي ذلك إلى إلغاء وصول كل من يستخدمه وإزالة سجلّه. لا يمكن التراجع عن هذا الإجراء.", - "emptyBody": "هل أنت متأكد من حذف هذا التطبيق؟", - "groupCount": "{{count}} مجموعة تمنح هذا التطبيق حالياً", - "restrictionCount": "{{count}} قاعدة صلاحيات تقيّده", - "openCount": "{{count}} مرة فتح في السجل", - "confirm": "حذف", - "anyway": "حذف رغم ذلك" - }, - "deleteService": { - "title": "حذف الخدمة \"{{name}}\"؟", - "warning": "هذه الخدمة لا تزال مرتبطة بطلبات. حذفها سيؤثر على ما يلي:", - "forceHint": "سيؤدي ذلك إلى حذف كل الطلبات المرتبطة نهائياً. لا يمكن التراجع عن هذا الإجراء.", - "emptyBody": "هل أنت متأكد من حذف هذه الخدمة؟", - "orderCount": "{{count}} طلب", - "confirm": "حذف", - "anyway": "حذف رغم ذلك" - }, - "deleteUser": { - "title": "حذف المستخدم \"{{name}}\"؟", - "warning": "هذا المستخدم لديه بيانات. حذفه سيؤثر على ما يلي:", - "forceHint": "سيؤدي ذلك إلى حذف ملاحظاته وطلباته والمحادثات التي أنشأها والرسائل التي أرسلها بشكل نهائي. لا يمكن التراجع عن هذا الإجراء.", - "emptyBody": "هل أنت متأكد من حذف هذا المستخدم؟", - "noteCount": "{{count}} ملاحظة", - "orderCount": "{{count}} طلب", - "conversationCount": "{{count}} محادثة أنشأها", - "messageCount": "{{count}} رسالة أرسلها", - "confirm": "حذف", - "anyway": "حذف رغم ذلك" - }, - "activate": "تفعيل", - "deactivate": "تعطيل", - "appName": "اسم التطبيق", - "appNameAr": "اسم التطبيق (عربي)", - "appNameEn": "اسم التطبيق (إنجليزي)", - "appSlug": "المعرف", - "appRoute": "المسار", - "appIcon": "الأيقونة", - "appColor": "اللون", - "appSortOrder": "الترتيب", - "appPermissions": { - "title": "الصلاحيات المطلوبة", - "help": "يجب أن يمتلك المستخدم على الأقل واحدة منها ليرى التطبيق. اتركها فارغة لإلغاء التقييد.", - "none": "لا توجد صلاحيات مطلوبة — التطبيق ظاهر للجميع.", - "selectPlaceholder": "اختر صلاحية…", - "add": "إضافة", - "removeAria": "إزالة الصلاحية {{name}}", - "impactTitle": "معاينة الأثر", - "impactLoading": "جارٍ حساب الأثر…", - "impactError": "تعذّر تحميل معاينة الأثر. حاول مرة أخرى.", - "impactNone": "لن يفقد أي مستخدم حق الوصول. {{visible}} مستخدمًا يرون هذا التطبيق حاليًا.", - "impactSummary": "{{affected}} من أصل {{visible}} مستخدمًا يرون هذا التطبيق حاليًا سيفقدون حق الوصول.", - "impactViaGroups": "أعضاء هذه المجموعات يحتفظون بحق الوصول بغض النظر عن التغيير: {{groups}}.", - "confirmTitle": "تأكيد تغيير الصلاحيات", - "confirmBody": "إضافة هذا الاشتراط ستخفي التطبيق عن {{affected}} من أصل {{visible}} مستخدمًا يرونه حاليًا. يمكن التراجع عن هذا التغيير.", - "confirmAction": "إضافة على أي حال" - }, - "serviceName": "اسم الخدمة", - "serviceNameAr": "اسم الخدمة (عربي)", - "serviceNameEn": "اسم الخدمة (إنجليزي)", - "serviceDescriptionAr": "الوصف (عربي)", - "serviceDescriptionEn": "الوصف (إنجليزي)", - "servicePrice": "السعر", - "serviceAvailable": "متاح؟", - "serviceImageUrl": "رابط الصورة", - "serviceImage": "صورة الخدمة", - "uploadImage": "رفع صورة", - "replaceImage": "استبدال الصورة", - "uploading": "جاري الرفع...", - "uploadFailed": "فشل رفع الصورة", - "users": { - "searchPlaceholder": "ابحث باسم المستخدم أو البريد...", - "allGroups": "كل المجموعات", - "allStatuses": "كل الحالات", - "statusActive": "نشط", - "statusInactive": "موقوف", - "showing": "عرض {{count}} من أصل {{total}}", - "empty": "لا يوجد مستخدمون يطابقون الفلاتر.", - "editGroups": "تعديل المجموعات", - "editUserGroups": "تعديل مجموعات {{username}}", - "col": { - "username": "اسم المستخدم", - "email": "البريد", - "displayNameAr": "الاسم (عربي)", - "displayNameEn": "الاسم (إنجليزي)", - "language": "اللغة", - "createdAt": "تاريخ الإنشاء", - "groups": "المجموعات", - "actions": "إجراءات" - }, - "review": { - "title": "مراجعة التغييرات", - "subtitle": "أكّد ما سيتم حفظه للمستخدم {{username}}.", - "noChanges": "لا توجد تغييرات للحفظ", - "groupsAdded": "مجموعات مُضافة", - "groupsRemoved": "مجموعات مُزالة", - "back": "رجوع للتعديل", - "confirm": "تأكيد وحفظ" - }, - "counts": { - "notes": "{{count}} ملاحظة", - "orders": "{{count}} طلب", - "conversations": "{{count}} محادثة", - "messages": "{{count}} رسالة" - }, - "historyTitle": "سجل الصلاحيات", - "historyHint": "الأدوار والمجموعات المسندة لهذا المستخدم. صفِّ بحسب من غيّر أو متى.", - "historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذا المستخدم بعد.", - "historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.", - "historyAdded_zero": "أُضيف ({{count}}):", - "historyAdded_one": "أُضيف ({{count}}):", - "historyAdded_two": "أُضيف ({{count}}):", - "historyAdded_few": "أُضيف ({{count}}):", - "historyAdded_many": "أُضيف ({{count}}):", - "historyAdded_other": "أُضيف ({{count}}):", - "historyRemoved_zero": "أُزيل ({{count}}):", - "historyRemoved_one": "أُزيل ({{count}}):", - "historyRemoved_two": "أُزيل ({{count}}):", - "historyRemoved_few": "أُزيل ({{count}}):", - "historyRemoved_many": "أُزيل ({{count}}):", - "historyRemoved_other": "أُزيل ({{count}}):", - "historyTotal_zero": "{{count}} عنصر بعد التغيير", - "historyTotal_one": "{{count}} عنصر بعد التغيير", - "historyTotal_two": "{{count}} عنصران بعد التغيير", - "historyTotal_few": "{{count}} عناصر بعد التغيير", - "historyTotal_many": "{{count}} عنصراً بعد التغيير", - "historyTotal_other": "{{count}} عنصر بعد التغيير", - "history": { - "kind": { - "roles": "الأدوار", - "groups": "المجموعات" - } - } - }, - "username": "اسم المستخدم", - "email": "البريد الإلكتروني", - "password": "كلمة المرور", - "roles": { - "searchPlaceholder": "ابحث في الأدوار...", - "addRole": "إضافة دور", - "editRole": "تعديل الدور", - "name": "الاسم", - "namePlaceholder": "مثال: content_editor", - "nameHint": "حروف وأرقام وشرطات سفلية فقط.", - "descriptionAr": "الوصف (عربي)", - "descriptionEn": "الوصف (إنجليزي)", - "system": "نظامي", - "empty": "لا توجد أدوار بعد.", - "edit": "تعديل الدور", - "delete": "حذف الدور", - "editSystemNameHint": "لا يمكن تغيير اسم الأدوار النظامية.", - "deleteTitle": "حذف الدور \"{{name}}\"؟", - "deleteEmptyBody": "هذا الدور غير مُعيَّن لأي مستخدم. هل تريد المتابعة؟", - "deleteConflict": "هذا الدور قيد الاستخدام ولا يمكن حذفه.", - "deleteConflictHint": "أزل هذا الدور من المستخدمين والمجموعات قبل حذفه.", - "deleteConfirm": "حذف", - "usersCount": "{{count}} مستخدم", - "groupsCount": "{{count}} مجموعة", - "errorNameTaken": "يوجد دور بنفس الاسم.", - "errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.", - "errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية.", - "renameWarningTitle": "إعادة تسمية \"{{from}}\" إلى \"{{to}}\"", - "renameWarningBody": "قد يُشار إلى اسم هذا الدور في الشيفرة أو التكاملات أو استعلامات سجل التدقيق. إعادة التسمية قد تُعطّل هذه التدفقات بصمت. حدّث جميع المراجع قبل الحفظ.", - "renameUsage": "مستخدم حالياً من قِبل {{users}} مستخدم و{{groups}} مجموعة.", - "permissions": "الصلاحيات", - "permissionsHint": "اختر الصلاحيات التي يمنحها هذا الدور.", - "permissionsSystemHint": "صلاحيات الأدوار النظامية للقراءة فقط.", - "permissionsEmpty": "لا توجد صلاحيات معرّفة.", - "errorSystemPermissions": "لا يمكن تغيير صلاحيات الأدوار النظامية.", - "errorPermissionsSave": "تعذّر حفظ صلاحيات الدور.", - "impactTitle_one": "إزالة {{count}} صلاحية", - "impactTitle_other": "إزالة {{count}} صلاحيات", - "impactLoading": "جارٍ احتساب الأثر...", - "impactNoUsers": "لا يحصل أي مستخدم حالياً على هذه الصلاحيات عبر هذا الدور.", - "impactError": "تعذّر حساب المستخدمين المتأثرين. يرجى تعديل اختيارك أو المحاولة مرة أخرى.", - "impactPerPermission": "{{users}} مستخدم سيفقد هذه الصلاحية ({{groups}} مجموعة تمنح الدور)", - "impactViaGroups": "عبر المجموعات: {{groups}}", - "impactTotal_one": "{{count}} مستخدم في المجموع سيفقد صلاحية واحدة على الأقل.", - "impactTotal_other": "{{count}} مستخدم في المجموع سيفقدون صلاحية واحدة على الأقل.", - "confirmRemovalTitle": "تأكيد إزالة الصلاحيات", - "confirmRemovalBody_one": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدم. قد يُلغى وصوله فوراً. هل تريد المتابعة؟", - "confirmRemovalBody_other": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدماً. قد يُلغى وصولهم فوراً. هل تريد المتابعة؟", - "confirmRemovalAction": "إزالة الصلاحيات", - "historyTitle": "السجل", - "historyHint": "تغييرات صلاحيات هذا الدور. استخدم عوامل التصفية لتحديد من قام بالتغيير أو متى.", - "historyEmpty": "لم تُسجَّل أي تغييرات على صلاحيات هذا الدور بعد.", - "historyEmptyFiltered": "لا توجد تغييرات تطابق عوامل التصفية الحالية.", - "historyActorUnknown": "مستخدم غير معروف", - "historyAdded_zero": "أُضيفت (0):", - "historyAdded_one": "أُضيفت (1):", - "historyAdded_two": "أُضيفت (2):", - "historyAdded_few": "أُضيفت ({{count}}):", - "historyAdded_many": "أُضيفت ({{count}}):", - "historyAdded_other": "أُضيفت ({{count}}):", - "historyRemoved_zero": "أُزيلت (0):", - "historyRemoved_one": "أُزيلت (1):", - "historyRemoved_two": "أُزيلت (2):", - "historyRemoved_few": "أُزيلت ({{count}}):", - "historyRemoved_many": "أُزيلت ({{count}}):", - "historyRemoved_other": "أُزيلت ({{count}}):", - "historyTotal_zero": "بدون صلاحيات بعد التغيير", - "historyTotal_one": "صلاحية واحدة بعد التغيير", - "historyTotal_two": "صلاحيتان بعد التغيير", - "historyTotal_few": "{{count}} صلاحيات بعد التغيير", - "historyTotal_many": "{{count}} صلاحية بعد التغيير", - "historyTotal_other": "{{count}} صلاحية بعد التغيير", - "historyShowing": "يظهر {{shown}} من أصل {{total}}", - "historyLoadMore": "عرض المزيد", - "historyDownloadCsv": "تنزيل CSV", - "historyDownloadCsvError": "تعذّر تنزيل ملف CSV. حاول مرة أخرى.", - "historyFilters": { - "actor": "غُيّرت بواسطة", - "allActors": "أي شخص", - "from": "من", - "to": "إلى", - "apply": "تطبيق", - "reset": "إعادة تعيين" - }, - "historyErrors": { - "invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.", - "invertedDates": "يجب أن يكون \"من\" قبل \"إلى\" أو في نفس اليوم.", - "fixFiltersFirst": "صحّح عوامل التصفية أعلاه لتحميل السجل." - } - }, - "active": "نشط", - "issueResetLink": "إصدار رابط إعادة تعيين كلمة المرور", - "orderReceiverRole": "مستلم الطلبات", - "resetLinkModal": { - "title": "رابط إعادة تعيين كلمة المرور للمستخدم {{username}}", - "description": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.", - "expires": "تنتهي الصلاحية: {{time}}", - "copy": "نسخ الرابط", - "copied": "تم نسخ الرابط" - }, - "siteSettings": "إعدادات الموقع", - "siteNameAr": "اسم الموقع (عربي)", - "siteNameEn": "اسم الموقع (إنجليزي)", - "registrationOpen": "السماح بإنشاء حسابات", - "registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.", - "footerTextAr": "نص التذييل (عربي)", - "footerTextEn": "نص التذييل (إنجليزي)", - "nav": { - "dashboard": "الرئيسية", - "apps": "التطبيقات", - "services": "الخدمات", - "users": "المستخدمين", - "groups": "المجموعات", - "roles": "الأدوار", - "auditLog": "سجل التدقيق", - "userManagement": "إدارة المستخدمين", - "settings": "الإعدادات", - "menu": "القائمة" - }, - "apps": { - "counts": { - "groups": "{{count}} مجموعة", - "restrictions": "{{count}} قيد", - "opens": "{{count}} فتحة" - }, - "historyTitle": "سجل الصلاحيات", - "historyHint": "الصلاحيات المطلوبة لاستخدام هذا التطبيق. صفِّ بحسب من غيّر أو متى.", - "historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذا التطبيق بعد.", - "historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.", - "historyAdded_zero": "أُضيف ({{count}}):", - "historyAdded_one": "أُضيف ({{count}}):", - "historyAdded_two": "أُضيف ({{count}}):", - "historyAdded_few": "أُضيف ({{count}}):", - "historyAdded_many": "أُضيف ({{count}}):", - "historyAdded_other": "أُضيف ({{count}}):", - "historyRemoved_zero": "أُزيل ({{count}}):", - "historyRemoved_one": "أُزيل ({{count}}):", - "historyRemoved_two": "أُزيل ({{count}}):", - "historyRemoved_few": "أُزيل ({{count}}):", - "historyRemoved_many": "أُزيل ({{count}}):", - "historyRemoved_other": "أُزيل ({{count}}):", - "historyTotal_zero": "{{count}} صلاحية بعد التغيير", - "historyTotal_one": "{{count}} صلاحية بعد التغيير", - "historyTotal_two": "{{count}} صلاحيتان بعد التغيير", - "historyTotal_few": "{{count}} صلاحيات بعد التغيير", - "historyTotal_many": "{{count}} صلاحية بعد التغيير", - "historyTotal_other": "{{count}} صلاحية بعد التغيير" - }, - "services": { - "counts": { - "orders": "{{count}} طلب" - } - }, - "groups": { - "searchPlaceholder": "ابحث في المجموعات...", - "addGroup": "إضافة مجموعة", - "editGroup": "تعديل المجموعة", - "name": "الاسم", - "descriptionAr": "الوصف (عربي)", - "descriptionEn": "الوصف (إنجليزي)", - "system": "نظامية", - "empty": "لا توجد مجموعات بعد.", - "members": "{{count}} عضو", - "appsCount": "{{count}} تطبيق", - "rolesCount": "{{count}} دور", - "appsHint": "أعضاء هذه المجموعة سيرون هذه التطبيقات.", - "usersHint": "أعضاء هذه المجموعة.", - "rolesHint": "يحصل أعضاء هذه المجموعة تلقائياً على هذه الأدوار.", - "rolesEmpty": "لا توجد أدوار متاحة.", - "deleteTitle": "حذف المجموعة \"{{name}}\"؟", - "deleteWarning": "هذه المجموعة لا تزال قيد الاستخدام. حذفها سيلغي الوصول لما يلي:", - "deleteForceHint": "سيؤدي الحذف إلى فصل الأعضاء وإلغاء التطبيقات والأدوار الممنوحة عبر المجموعة. لا يمكن التراجع عن هذا الإجراء.", - "deleteEmptyBody": "لا تحتوي هذه المجموعة على أعضاء أو تطبيقات أو أدوار. هل تريد المتابعة؟", - "deleteConfirm": "حذف", - "deleteAnyway": "حذف رغم ذلك", - "tab": { - "info": "المعلومات", - "apps": "التطبيقات", - "users": "المستخدمون", - "roles": "الأدوار", - "history": "السجل" - }, - "historyTitle": "سجل الصلاحيات", - "historyHint": "الأعضاء والأدوار والتطبيقات المرتبطة بهذه المجموعة. صفِّ بحسب من غيّر أو متى.", - "historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذه المجموعة بعد.", - "historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.", - "historyAdded_zero": "أُضيف ({{count}}):", - "historyAdded_one": "أُضيف ({{count}}):", - "historyAdded_two": "أُضيف ({{count}}):", - "historyAdded_few": "أُضيف ({{count}}):", - "historyAdded_many": "أُضيف ({{count}}):", - "historyAdded_other": "أُضيف ({{count}}):", - "historyRemoved_zero": "أُزيل ({{count}}):", - "historyRemoved_one": "أُزيل ({{count}}):", - "historyRemoved_two": "أُزيل ({{count}}):", - "historyRemoved_few": "أُزيل ({{count}}):", - "historyRemoved_many": "أُزيل ({{count}}):", - "historyRemoved_other": "أُزيل ({{count}}):", - "historyTotal_zero": "{{count}} عنصر بعد التغيير", - "historyTotal_one": "{{count}} عنصر بعد التغيير", - "historyTotal_two": "{{count}} عنصران بعد التغيير", - "historyTotal_few": "{{count}} عناصر بعد التغيير", - "historyTotal_many": "{{count}} عنصراً بعد التغيير", - "historyTotal_other": "{{count}} عنصر بعد التغيير", - "history": { - "kind": { - "users": "المستخدمون", - "roles": "الأدوار", - "apps": "التطبيقات" - } - } - }, - "audit": { - "filters": { - "action": "الإجراء", - "allActions": "كل الإجراءات", - "actor": "المنفّذ", - "allActors": "كل المنفّذين", - "actorSearch": "ابحث بالاسم أو @المعرّف", - "actorEmpty": "لا يوجد مستخدمون مطابقون.", - "actorWithName": "المنفّذ: {{name}}", - "actorWithId": "المنفّذ: #{{id}}", - "clearActorFilter": "مسح فلتر المنفّذ", - "from": "من", - "to": "إلى", - "apply": "تطبيق", - "reset": "إعادة ضبط", - "forcedOnly": "عمليات الحذف القسري فقط", - "forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط.", - "targetType": "نوع الهدف: {{type}}", - "targetWithId": "الهدف: {{type}} #{{id}}", - "clearTargetFilter": "مسح فلتر الهدف" - }, - "actorPivotAria": "تصفية سجل التدقيق على الإجراءات التي قام بها {{name}}", - "target": "الهدف: {{type}} #{{id}}", - "targetWithName": "الهدف: {{type}} #{{id}} · {{name}}", - "showing": "عرض {{shown}} من أصل {{total}}", - "empty": "لا توجد إدخالات في سجل التدقيق تطابق الفلاتر.", - "loadMore": "عرض المزيد", - "export": { - "button": "تصدير CSV", - "failed": "تعذّر تصدير سجل التدقيق. يُرجى المحاولة مرة أخرى." - }, - "errors": { - "invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.", - "invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".", - "fixFiltersFirst": "عدّل الفلاتر لتحميل الإدخالات." - }, - "unit": { - "member_zero": "بدون أعضاء", - "member_one": "عضو واحد", - "member_two": "عضوان", - "member_few": "{{count}} أعضاء", - "member_many": "{{count}} عضواً", - "member_other": "{{count}} عضو", - "app_zero": "بدون تطبيقات", - "app_one": "تطبيق واحد", - "app_two": "تطبيقان", - "app_few": "{{count}} تطبيقات", - "app_many": "{{count}} تطبيقاً", - "app_other": "{{count}} تطبيق", - "role_zero": "بدون أدوار", - "role_one": "دور واحد", - "role_two": "دوران", - "role_few": "{{count}} أدوار", - "role_many": "{{count}} دوراً", - "role_other": "{{count}} دور", - "group_zero": "بدون مجموعات", - "group_one": "مجموعة واحدة", - "group_two": "مجموعتان", - "group_few": "{{count}} مجموعات", - "group_many": "{{count}} مجموعةً", - "group_other": "{{count}} مجموعة", - "order_zero": "بدون طلبات", - "order_one": "طلب واحد", - "order_two": "طلبان", - "order_few": "{{count}} طلبات", - "order_many": "{{count}} طلباً", - "order_other": "{{count}} طلب", - "change_zero": "بدون تغييرات", - "change_one": "تغيير واحد", - "change_two": "تغييران", - "change_few": "{{count}} تغييرات", - "change_many": "{{count}} تغييراً", - "change_other": "{{count}} تغيير", - "permission_zero": "بدون صلاحيات", - "permission_one": "صلاحية واحدة", - "permission_two": "صلاحيتان", - "permission_few": "{{count}} صلاحيات", - "permission_many": "{{count}} صلاحيةً", - "permission_other": "{{count}} صلاحية", - "restriction_zero": "بدون قيود", - "restriction_one": "قيد واحد", - "restriction_two": "قيدان", - "restriction_few": "{{count}} قيود", - "restriction_many": "{{count}} قيداً", - "restriction_other": "{{count}} قيد", - "note_zero": "بدون ملاحظات", - "note_one": "ملاحظة واحدة", - "note_two": "ملاحظتان", - "note_few": "{{count}} ملاحظات", - "note_many": "{{count}} ملاحظةً", - "note_other": "{{count}} ملاحظة", - "conversation_zero": "بدون محادثات", - "conversation_one": "محادثة واحدة", - "conversation_two": "محادثتان", - "conversation_few": "{{count}} محادثات", - "conversation_many": "{{count}} محادثةً", - "conversation_other": "{{count}} محادثة", - "message_zero": "بدون رسائل", - "message_one": "رسالة واحدة", - "message_two": "رسالتان", - "message_few": "{{count}} رسائل", - "message_many": "{{count}} رسالةً", - "message_other": "{{count}} رسالة", - "open_zero": "بدون فتحات", - "open_one": "فتحة واحدة", - "open_two": "فتحتان", - "open_few": "{{count}} فتحات", - "open_many": "{{count}} فتحةً", - "open_other": "{{count}} فتحة" - }, - "dependencies": { - "label": "التبعيات المُزالة", - "chipAriaTargetType": "{{label}} — فتح سجل التدقيق مفلتراً حسب نوع الهدف \"{{type}}\"", - "chipAriaParent": "{{label}} — فتح سجل التدقيق لـ {{type}} #{{id}}" - }, - "recent": { - "title": "آخر النشاطات", - "loading": "جارٍ تحميل آخر النشاطات…", - "empty": "لا توجد نشاطات حديثة لهذا السجل بعد.", - "viewAll": "عرض السجل الكامل", - "viewAllAria": "فتح سجل التدقيق مفلتراً على هذا الـ {{type}}" - }, - "summary": { - "app": { - "create": "تم إنشاء التطبيق '{{name}}'", - "update": "تم تعديل التطبيق '{{name}}' ({{changes}})", - "rename": "تمت إعادة تسمية التطبيق من '{{previousName}}' إلى '{{name}}'", - "delete": "تم حذف التطبيق '{{name}}'", - "forceDelete": "تم حذف التطبيق '{{name}}' قسراً (تأثرت {{groups}})", - "permissionAdd": "تمت إضافة الصلاحية '{{permission}}' إلى التطبيق '{{name}}'", - "permissionRemove": "تمت إزالة الصلاحية '{{permission}}' من التطبيق '{{name}}'" - }, - "auth": { - "issueResetLink": "تم إصدار رابط إعادة تعيين كلمة المرور للمستخدم @{{username}}" - }, - "group": { - "create": "تم إنشاء المجموعة '{{name}}' ({{members}}، {{apps}})", - "update": "تم تعديل المجموعة '{{name}}' ({{changes}})", - "rename": "تمت إعادة تسمية المجموعة من '{{previousName}}' إلى '{{name}}'", - "delete": "تم حذف المجموعة '{{name}}'", - "forceDelete": "تم حذف المجموعة '{{name}}' قسراً ({{members}})", - "userAdd": "تمت إضافة المستخدم @{{username}} إلى المجموعة '{{name}}'", - "userAddById": "تمت إضافة المستخدم #{{userId}} إلى المجموعة '{{name}}'", - "userRemove": "تمت إزالة المستخدم @{{username}} من المجموعة '{{name}}'", - "userRemoveById": "تمت إزالة المستخدم #{{userId}} من المجموعة '{{name}}'", - "appAdd": "تمت إضافة التطبيق '{{appName}}' إلى المجموعة '{{name}}'", - "appAddById": "تمت إضافة التطبيق #{{appId}} إلى المجموعة '{{name}}'", - "appRemove": "تمت إزالة التطبيق '{{appName}}' من المجموعة '{{name}}'", - "appRemoveById": "تمت إزالة التطبيق #{{appId}} من المجموعة '{{name}}'", - "roleAdd": "تمت إضافة الدور '{{roleName}}' إلى المجموعة '{{name}}'", - "roleAddById": "تمت إضافة الدور #{{roleId}} إلى المجموعة '{{name}}'", - "roleRemove": "تمت إزالة الدور '{{roleName}}' من المجموعة '{{name}}'", - "roleRemoveById": "تمت إزالة الدور #{{roleId}} من المجموعة '{{name}}'" - }, - "role": { - "create": "تم إنشاء الدور '{{name}}'", - "update": "تم تعديل الدور '{{name}}' ({{changes}})", - "rename": "تمت إعادة تسمية الدور من '{{previousName}}' إلى '{{name}}'", - "delete": "تم حذف الدور '{{name}}'", - "permissionsReplace": "تم تحديث صلاحيات الدور '{{name}}' (أُضيفت {{added}}، حُذفت {{removed}})", - "permissionAdd": "تمت إضافة الصلاحية '{{permission}}' إلى الدور '{{name}}'", - "permissionRemove": "تمت إزالة الصلاحية '{{permission}}' من الدور '{{name}}'" - }, - "service": { - "delete": "تم حذف الخدمة '{{name}}'", - "deleteId": "تم حذف الخدمة رقم {{id}}", - "forceDelete": "تم حذف الخدمة '{{name}}' قسراً (تأثرت {{orders}})" - }, - "settings": { - "update": "تم تحديث إعدادات النظام ({{changes}})", - "registrationOpened": "تم فتح التسجيل العام", - "registrationClosed": "تم إغلاق التسجيل العام", - "registrationOpenedWith": "تم فتح التسجيل العام و{{others}}", - "registrationClosedWith": "تم إغلاق التسجيل العام و{{others}}" - }, - "user": { - "delete": "تم حذف المستخدم @{{username}}", - "deleteWithName": "تم حذف المستخدم @{{username}} ({{displayName}})", - "forceDelete": "تم حذف المستخدم @{{username}} قسراً", - "forceDeleteWithName": "تم حذف المستخدم @{{username}} ({{displayName}}) قسراً" - } - } - }, - "dashboardSoon": "إحصائيات اللوحة قريباً.", - "dashboard": { - "totalApps": "إجمالي التطبيقات", - "totalServices": "إجمالي الخدمات", - "totalUsers": "إجمالي المستخدمين", - "registration": "التسجيل", - "open": "مفتوح", - "closed": "مغلق", - "recentActivity": "آخر النشاطات", - "latestUser": "آخر مستخدم", - "latestApp": "آخر تطبيق", - "none": "—", - "newUsers7d": "مستخدمون جدد (٧ أيام)", - "vsPrev7d": "الأسبوع السابق: {{count}}", - "newUsersInRange": "مستخدمون جدد · {{range}}", - "vsPrevRange": "{{range}}: {{count}}", - "activeServices": "الخدمات النشطة", - "inactiveServices": "غير نشطة: {{count}}", - "signupsTrend": "التسجيلات", - "last7Days": "آخر ٧ أيام", - "appOpensTrend": "فتح التطبيقات", - "appOpensSummary": "{{count}} إجمالاً · {{delta}}", - "appOpensSummaryRanged": "{{count}} في {{range}} · {{delta}}", - "servicesCreatedTrend": "خدمات جديدة", - "servicesCreatedSummary": "{{count}} مضافة", - "servicesCreatedSummaryRanged": "{{count}} في {{range}}", - "trends": "الاتجاهات", - "rangeSelector": "المدى الزمني", - "range": { - "7d": "آخر ٧ أيام", - "30d": "آخر ٣٠ يومًا", - "90d": "آخر ٩٠ يومًا", - "custom": "مدى مخصص", - "customLabel": "{{from}} – {{to}}" - }, - "prevRange": { - "7d": "الأيام ٧ السابقة", - "30d": "الأيام ٣٠ السابقة", - "90d": "الأيام ٩٠ السابقة", - "custom": "الـ {{count}} أيام السابقة" - }, - "customRange": { - "from": "من", - "to": "إلى", - "apply": "تطبيق", - "invalid": "اختر نطاقًا صحيحًا", - "loadError": "تعذّر تحميل الإحصائيات للنطاق المحدّد. يرجى التحقق من التواريخ والمحاولة مرة أخرى." - }, - "topApps": "أكثر التطبيقات استخدامًا", - "topAppsSubtitle": "حسب الفتح في {{range}}", - "mostActiveUsers": "أكثر المستخدمين نشاطًا", - "mostActiveUsersSubtitle": "حسب فتح التطبيقات في {{range}}", - "leaderboardEmpty": "لا يوجد نشاط بعد", - "openCount": "{{count}} فتحة", - "viewAppDetails": "عرض تفاصيل {{name}}", - "viewUserDetails": "عرض تفاصيل {{name}}", - "drillIn": { - "loading": "جارٍ التحميل…", - "appTitle": "آخر الفتحات · {{name}}", - "userTitle": "آخر الفتحات · {{name}}", - "subtitle": "{{count}} فتحة في {{range}}", - "empty": "لا توجد فتحات في هذا النطاق", - "loadError": "تعذّر تحميل آخر الفتحات.", - "editApp": "تعديل التطبيق", - "viewUser": "عرض المستخدم", - "loadMore": "تحميل المزيد", - "loadMoreError": "تعذّر تحميل المزيد من الفتحات.", - "shownOf": "عرض {{shown}} من {{total}}" - } - }, - "dependents": { - "openLabel": "عرض {{count}}", - "loadError": "تعذّر تحميل العناصر.", - "empty": "لا يوجد ما يُعرض.", - "appGroups": { - "title": "المجموعات · {{name}}", - "subtitle": "{{count}} مجموعة تمنح صلاحية الوصول" - }, - "appRestrictions": { - "title": "القيود · {{name}}", - "subtitle": "{{count}} صلاحية مطلوبة", - "noRoles": "لا توجد أدوار تشمل هذه الصلاحية" - }, - "appOpens": { - "title": "الفتحات · {{name}}", - "subtitle": "{{count}} فتحة إجمالاً" - }, - "serviceOrders": { - "title": "الطلبات · {{name}}", - "subtitle": "{{count}} طلب" - }, - "userNotes": { - "title": "الملاحظات · {{name}}", - "subtitle": "{{count}} ملاحظة", - "untitled": "(بدون عنوان)", - "pinned": "مثبّتة", - "archived": "مؤرشفة" - }, - "userOrders": { - "title": "الطلبات · {{name}}", - "subtitle": "{{count}} طلب" - }, - "userConversations": { - "title": "المحادثات · {{name}}", - "subtitle": "{{count}} محادثة", - "kindGroup": "مجموعة", - "kindDirect": "خاصة", - "unnamedGroup": "(مجموعة بدون اسم)", - "unnamedDirect": "محادثة خاصة" - }, - "userMessages": { - "title": "الرسائل · {{name}}", - "subtitle": "{{count}} رسالة" - }, - "orderStatus": { - "pending": "قيد الانتظار", - "received": "مستلم", - "preparing": "قيد التحضير", - "completed": "مكتمل", - "cancelled": "ملغى" - } - } - }, - "notFound": { - "title": "404 — الصفحة غير موجودة", - "description": "الصفحة التي تبحث عنها غير موجودة." - }, - "common": { - "save": "حفظ", - "cancel": "إلغاء", - "delete": "حذف", - "edit": "تعديل", - "add": "إضافة", - "close": "إغلاق", - "loading": "جاري التحميل...", - "error": "حدث خطأ", - "success": "تمت العملية بنجاح", - "language": "English", - "appName": "نظام Tx", - "previous": "السابق", - "next": "التالي", - "empty": "(فارغ)" - }, - "notes": { - "title": "الملاحظات", - "searchPlaceholder": "ابحث في الملاحظات", - "labels": "التصنيفات", - "manageLabels": "إدارة التصنيفات", - "newLabel": "تصنيف جديد", - "noLabelsYet": "لا توجد تصنيفات بعد", - "allLabels": "الكل", - "tabs": { - "active": "ملاحظات", - "archived": "الأرشيف" - }, - "pinned": "مثبّتة", - "others": "أخرى", - "empty": "ستظهر ملاحظاتك هنا", - "emptyArchived": "لا توجد ملاحظات في الأرشيف", - "takeNote": "اكتب ملاحظة...", - "titlePlaceholder": "العنوان", - "contentPlaceholder": "اكتب ملاحظة...", - "save": "حفظ", - "pin": "تثبيت", - "archive": "أرشفة", - "unarchive": "إلغاء الأرشفة", - "delete": "حذف", - "deleteConfirm": "حذف هذه الملاحظة؟", - "deleteLabelConfirm": "حذف هذا التصنيف؟", - "editTitle": "تعديل ملاحظة" - }, - "executiveMeetings": { - "title": "قائمة الاجتماعات", - "scheduleHeading": "قائمة بأسماء حضور الاجتماعات", - "date": "التاريخ:", - "exportPdf": "تصدير PDF", - "noMeetings": "لا توجد اجتماعات في هذا اليوم", - "virtual": "اتصال مرئي", - "virtualAttendance": "الحضور عبر الاتصال المرئي", - "internalAttendance": "الحضور الداخلي", - "externalAttendance": "حضور خارجي", - "placeholderBody": "هذا القسم قيد التطوير في المراحل القادمة. ستتوفر إدارته الكاملة قريباً.", - "col": { - "number": "#", - "meeting": "الاجتماع", - "attendees": "الحضور", - "time": "الوقت" - }, - "customize": { - "title": "تخصيص الجدول", - "hint": "أظهر أو أخفِ الأعمدة. اسحب رؤوس الأعمدة لإعادة الترتيب.", - "reset": "إعادة التعيين", - "dragHint": "اسحب حد العمود لتغيير عرضه" - }, - "highlight": { - "title": "تمييز الاجتماع الحالي", - "hint": "تظليل الاجتماع الذي يقع وقته الآن.", - "reset": "إعادة إلى الافتراضي" - }, - "dragRow": "اسحب لإعادة الترتيب", - "editMeetingTitle": "تعديل عنوان الاجتماع", - "alertPrefs": { - "title": "تنبيه الاجتماع القادم", - "hint": "تظهر نافذة صغيرة عائمة قبل بداية كل اجتماع بـ 5 دقائق. تقدر تطفّيها أو تختار لوناً يناسب واجهتك.", - "enabledLabel": "عرض تنبيه الاجتماع القادم", - "colorTitle": "لون النافذة", - "reset": "إعادة إلى الافتراضي", - "preset": { - "amber": "كهرماني (افتراضي)", - "blue": "أزرق", - "green": "أخضر", - "slate": "رمادي", - "rose": "وردي" - } - }, - "alert": { - "title": "الاجتماع التالي", - "minutesAway_zero": "يبدأ الآن", - "minutesAway_one": "يبدأ بعد دقيقة", - "minutesAway_two": "يبدأ بعد دقيقتين", - "minutesAway_few": "يبدأ بعد {{count}} دقائق", - "minutesAway_many": "يبدأ بعد {{count}} دقيقة", - "minutesAway_other": "يبدأ بعد {{count}} دقيقة", - "startsNow": "يبدأ الآن", - "atTime": "في {{time}}", - "done": "تم", - "postpone": "تأجيل", - "dismiss": "تجاهل التنبيه", - "dragHandle": "اسحب للتحريك", - "dismissToast": "تم تجاهل التنبيه", - "openMeetings": "فتح الاجتماعات", - "details": "تفاصيل", - "detailsClose": "إخفاء التفاصيل", - "detailsUrl": "رابط الاجتماع", - "detailsTime": "الوقت", - "detailsLocation": "المكان", - "detailsAttendees": "الحضور ({{n}})", - "detailsNoAttendees": "لا يوجد حضور بعد.", - "detailsGroup": { - "internal": "داخلي", - "external": "خارجي", - "virtual": "افتراضي" - }, - "postponeTitle": "تأجيل الاجتماع", - "postponeIntro": "«{{title}}»", - "tabPostpone": "تأجيل", - "tabReschedule": "إعادة الجدولة", - "tabCancel": "إلغاء", - "postponeMinutesPlaceholder": "دقائق", - "postponeMinutesApply_zero": "تأجيل", - "postponeMinutesApply_one": "تأجيل دقيقة", - "postponeMinutesApply_two": "تأجيل دقيقتين", - "postponeMinutesApply_few": "تأجيل {{count}} دقائق", - "postponeMinutesApply_many": "تأجيل {{count}} دقيقة", - "postponeMinutesApply_other": "تأجيل {{count}} دقيقة", - "postponeConfirmPrompt_zero": "هل تريد تأجيل الاجتماع؟", - "postponeConfirmPrompt_one": "هل تريد تأجيل الاجتماع دقيقة واحدة؟", - "postponeConfirmPrompt_two": "هل تريد تأجيل الاجتماع دقيقتين؟", - "postponeConfirmPrompt_few": "هل تريد تأجيل الاجتماع {{count}} دقائق؟", - "postponeConfirmPrompt_many": "هل تريد تأجيل الاجتماع {{count}} دقيقة؟", - "postponeConfirmPrompt_other": "هل تريد تأجيل الاجتماع {{count}} دقيقة؟", - "postponeConfirmYes_zero": "نعم، أجّل", - "postponeConfirmYes_one": "نعم، أجّل دقيقة", - "postponeConfirmYes_two": "نعم، أجّل دقيقتين", - "postponeConfirmYes_few": "نعم، أجّل {{count}} دقائق", - "postponeConfirmYes_many": "نعم، أجّل {{count}} دقيقة", - "postponeConfirmYes_other": "نعم، أجّل {{count}} دقيقة", - "rescheduleHint": "نقل الاجتماع إلى تاريخ أو وقت جديد.", - "rescheduleDate": "التاريخ", - "rescheduleStart": "البداية", - "rescheduleEnd": "النهاية", - "rescheduleNote": "ملاحظة (اختياري)", - "rescheduleApply": "إعادة الجدولة", - "cancelHint": "يحدد الاجتماع كمُلغى مع الاحتفاظ بسجل الحضور.", - "cancelApply": "إلغاء الاجتماع", - "cancelConfirmPrompt": "هل أنت متأكد؟ سيتم وضع الاجتماع كمُلغى.", - "cancelConfirmYes": "نعم، إلغاء", - "back": "رجوع", - "saved": "تم الحفظ", - "saveFailed": "تعذّر حفظ التغييرات", - "conflictWarning": "تنبيه: الوقت الجديد يتداخل مع اجتماع آخر في نفس اليوم.", - "noTimeWindow": "هذا الاجتماع بدون وقت محدد، لا يمكن تأجيله بدقائق.", - "staleMeetingTitle": "تم تعديل هذا الاجتماع للتو من قبل مستخدم آخر.", - "staleMeetingByUser": "{{name}} عدّل هذا الاجتماع للتو.", - "staleMeetingCurrentTime": "الوقت الحالي: {{start}} – {{end}}.", - "staleMeetingApplyAnyway_zero": "أضف وقتاً إضافياً على أي حال", - "staleMeetingApplyAnyway_one": "أضف دقيقة إضافية على أي حال", - "staleMeetingApplyAnyway_two": "أضف دقيقتين إضافيتين على أي حال", - "staleMeetingApplyAnyway_few": "أضف {{count}} دقائق إضافية على أي حال", - "staleMeetingApplyAnyway_many": "أضف {{count}} دقيقة إضافية على أي حال", - "staleMeetingApplyAnyway_other": "أضف {{count}} دقيقة إضافية على أي حال", - "cascadeLoading": "جارٍ التحقق من الاجتماعات اللاحقة…", - "cascadeMinutesUnit_zero": "بدون دقائق", - "cascadeMinutesUnit_one": "دقيقة واحدة", - "cascadeMinutesUnit_two": "دقيقتين", - "cascadeMinutesUnit_few": "{{count}} دقائق", - "cascadeMinutesUnit_many": "{{count}} دقيقة", - "cascadeMinutesUnit_other": "{{count}} دقيقة", - "cascadePromptHeader_zero": "لا توجد اجتماعات لاحقة في هذا اليوم.", - "cascadePromptHeader_one": "سيتأثر اجتماع واحد لاحق في هذا اليوم. هل تريد تأجيله أيضاً بـ {{minutesText}}؟", - "cascadePromptHeader_two": "سيتأثر اجتماعان لاحقان في هذا اليوم. هل تريد تأجيلهما أيضاً بـ {{minutesText}}؟", - "cascadePromptHeader_few": "سيتأثر {{count}} اجتماعات لاحقة في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟", - "cascadePromptHeader_many": "سيتأثر {{count}} اجتماعاً لاحقاً في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟", - "cascadePromptHeader_other": "سيتأثر {{count}} اجتماع لاحق في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟", - "cascadeListTitle": "الاجتماعات المتأثرة:", - "cascadeListItem": "{{title}} — {{oldStart}} ← {{newStart}}", - "cascadeShift_zero": "تأجيل الاجتماعات اللاحقة", - "cascadeShift_one": "تأجيل اجتماع واحد لاحق", - "cascadeShift_two": "تأجيل اجتماعين لاحقين", - "cascadeShift_few": "تأجيل {{count}} اجتماعات لاحقة", - "cascadeShift_many": "تأجيل {{count}} اجتماعاً لاحقاً", - "cascadeShift_other": "تأجيل {{count}} اجتماع لاحق", - "cascadeKeep": "إبقاء أوقاتها كما هي", - "cascadeMidnightError": "لا يمكن تأجيل اليوم: \"{{title}}\" سينتقل إلى {{projectedStart}}، بعد منتصف الليل.", - "cascadeAuditDescription": "تم التأجيل التلقائي بمقدار {{minutes}} دقيقة بعد تأجيل الاجتماع رقم {{triggerMeetingId}}." - }, - "timeEditor": { - "am": "ص", - "pm": "م", - "periodGroupStart": "ص أو م لوقت البدء", - "periodGroupEnd": "ص أو م لوقت الانتهاء" - }, - "schedule": { - "addRow": "أضف اجتماع", - "newMeetingDefaultAr": "اجتماع جديد", - "newMeetingDefaultEn": "New meeting", - "timeStart": "وقت البداية", - "timeEnd": "وقت النهاية", - "timeStartShort": "البدء", - "timeEndShort": "الانتهاء", - "timeOrderError": "وقت النهاية قبل وقت البداية", - "timeParseError": "صيغة الوقت غير صحيحة. اكتب الوقت مثل: 09:30 أو 9:30 ص", - "timeAmbiguousError": "اختر ص أو م للوقت", - "addAttendee": "أضف حاضر", - "addSubheading": "+ عنوان فرعي", - "addVirtualAttendee": "+ حاضر عبر الاتصال المرئي", - "addInternalAttendee": "+ حاضر داخلي", - "addExternalAttendee": "+ حاضر خارجي", - "newAttendeeAria": "اسم الحاضر الجديد", - "editAttendeeAria": "تعديل اسم الحاضر", - "newSubheadingAria": "نص العنوان الفرعي الجديد", - "editSubheadingAria": "تعديل نص العنوان الفرعي", - "deleteRow": "حذف الاجتماع", - "deleteRowConfirm": "حذف هذا الاجتماع؟\n\n\"{{title}}\"\n\nلا يمكن التراجع عن هذا الإجراء.", - "deleted": "تم حذف الاجتماع", - "editToggle": "تعديل", - "editToggleAria": "تبديل وضع التعديل", - "editToggleOn": "وضع التعديل مفعّل", - "editToggleOff": "وضع التعديل معطّل", - "saveToggle": "حفظ", - "saveToggleAria": "إنهاء التعديل وحفظ التغييرات", - "saveToggleOn": "وضع التعديل مفعّل — انقر للحفظ والخروج", - "saveToggleOff": "وضع التعديل معطّل — انقر للبدء بالتعديل", - "bulkSelectRow": "تحديد الاجتماع", - "bulkSelectAll": "تحديد الكل", - "bulkSelectionCount": "محدد: {{n}} من {{total}}", - "bulkClearSelection": "إلغاء التحديد", - "bulkDeleteSelected": "حذف المحدد", - "bulkDeleteConfirm": "حذف {{n}} اجتماعاً؟", - "bulkDeleteResultAll": "تم حذف {{n}} اجتماعاً", - "bulkDeletePartial": "تم حذف {{ok}} من {{total}} اجتماعاً، فشل {{failed}}", - "bulkDeleteResultNone": "فشل حذف الاجتماعات المحددة", - "bulkDeleteUndo": "تراجع", - "bulkDeleteUndone": "تمت استعادة {{n}} اجتماعاً", - "bulkDeleteUndoPartial": "تمت استعادة {{ok}} من {{total}} اجتماعاً", - "bulkDeleteUndoFailed": "تعذرت استعادة الاجتماعات", - "searchPlaceholder": "ابحث عن اجتماع…", - "searchAria": "ابحث في اجتماعات اليوم", - "searchClear": "مسح البحث", - "searchNoMatches": "لا توجد اجتماعات تطابق البحث.", - "bulkDuplicateSelected": "تكرار إلى تاريخ…", - "bulkDuplicateTitle": "تكرار {{n}} اجتماع إلى تاريخ", - "bulkDuplicateConfirm": "تكرار", - "bulkDuplicating": "جارٍ التكرار…", - "bulkDuplicateResultAll": "تم تكرار {{n}} اجتماع", - "bulkDuplicatePartial": "تم تكرار {{ok}} من {{total}} وفشل {{failed}}", - "bulkDuplicateNone": "تعذّر تكرار الاجتماعات المحددة" - }, - "rowColor": { - "label": "لون الصف" - }, - "rowActions": { - "label": "إجراءات الصف", - "back": "رجوع" - }, - "merge": { - "label": "دمج الخلايا", - "editLabel": "تعديل الخلية المدموجة", - "placeholder": "اضغط لإضافة نص", - "meetingAttendees": "دمج الاجتماع + الحضور", - "meetingTime": "دمج الاجتماع + الحضور + الوقت", - "all": "دمج الصف بالكامل", - "unmerge": "إلغاء الدمج", - "activeBadge": "مدموج: {{cols}}" - }, - "nav": { - "schedule": "جدول الاجتماعات اليومي", - "manage": "إدارة الاجتماعات", - "notifications": "التنبيهات", - "audit": "سجل التعديلات", - "pdf": "تصدير PDF", - "settings": "الإعدادات" - }, - "common": { - "save": "حفظ", - "cancel": "إلغاء", - "delete": "حذف", - "edit": "تعديل", - "add": "إضافة", - "submit": "إرسال", - "close": "إغلاق", - "loading": "جارٍ التحميل...", - "saved": "تم الحفظ", - "saveFailed": "تعذر الحفظ", - "actions": "إجراءات", - "noPermission": "ليست لديك صلاحية لتنفيذ هذا الإجراء", - "yes": "نعم", - "no": "لا", - "all": "الكل", - "filter": "تصفية", - "confirm": "تأكيد", - "confirmTitle": "تأكيد" - }, - "manage": { - "heading": "إدارة الاجتماعات", - "addMeeting": "إضافة اجتماع", - "editMeeting": "تعديل الاجتماع", - "deleteMeeting": "حذف الاجتماع", - "deleteConfirm": "حذف هذا الاجتماع نهائياً؟", - "noMeetings": "لا توجد اجتماعات لهذا اليوم. اضغط \"إضافة اجتماع\" لإنشاء واحد.", - "field": { - "titleAr": "العنوان", - "titleEn": "العنوان (إنجليزي)", - "meetingDate": "التاريخ", - "dailyNumber": "الرقم اليومي", - "dailyNumberHint": "اتركه فارغاً ليتم الترقيم تلقائياً", - "startTime": "وقت البدء", - "endTime": "وقت الانتهاء", - "location": "المكان", - "meetingUrl": "رابط الاجتماع", - "platform": "المنصة", - "status": "الحالة", - "isHighlighted": "تمييز (لون أحمر)", - "notes": "ملاحظات" - }, - "attendees": { - "label": "قائمة الحضور", - "name": "الاسم", - "title": "المسمى", - "id": "رقم الحاضر", - "type": "نوع الحضور", - "add": "إضافة عضو", - "addSubheading": "إضافة عنوان فرعي", - "subheadingName": "نص العنوان الفرعي", - "subheadingBadge": "عنوان فرعي", - "remove": "إزالة", - "moveUp": "تحريك للأعلى", - "moveDown": "تحريك للأسفل", - "dragHandle": "اسحب لإعادة الترتيب", - "removeAll": "حذف جميع الحضور", - "removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع." - }, - "duplicate": "تكرار", - "duplicateToDate": "تكرار إلى تاريخ", - "duplicated": "تم تكرار الاجتماع", - "duplicateFailed": "تعذر تكرار الاجتماع", - "platform": { - "none": "بدون", - "webex": "Webex", - "teams": "Teams", - "zoom": "Zoom", - "other": "أخرى" - }, - "attendanceType": { - "internal": "داخلي", - "virtual": "افتراضي", - "external": "خارجي" - }, - "status": { - "scheduled": "مجدول", - "cancelled": "ملغى", - "completed": "منتهٍ", - "postponed": "مؤجل" - }, - "errors": { - "titleRequired": "العنوان بالعربية مطلوب", - "dateRequired": "التاريخ مطلوب", - "duplicateNumber": "هذا الرقم اليومي مستخدم بالفعل في هذا التاريخ", - "saveFailed": "تعذر حفظ الاجتماع", - "endTimeBeforeStart": "وقت الانتهاء يجب أن يكون بعد وقت البدء" - } - }, - "audit": { - "heading": "سجل التعديلات", - "empty": "لا توجد قيود في السجل.", - "filterDate": "تصفية بالتاريخ", - "filterEntity": "النوع", - "filter": { - "from": "من", - "to": "إلى", - "actorId": "معرّف المنفّذ" - }, - "pageInfo": "عرض", - "all": "الكل", - "col": { - "when": "الوقت", - "actor": "المستخدم", - "action": "الإجراء", - "entity": "النوع", - "entityId": "المعرف", - "diff": "التفاصيل", - "changes": "التغييرات" - }, - "created": "تم إنشاء", - "removed": "تم حذف", - "moreChanges": "تغييرات أخرى", - "field": { - "titleAr": "العنوان", - "titleEn": "العنوان (إنجليزي)", - "meetingDate": "التاريخ", - "dailyNumber": "الرقم اليومي", - "startTime": "وقت البدء", - "endTime": "وقت الانتهاء", - "location": "المكان", - "meetingUrl": "رابط الاجتماع", - "platform": "المنصة", - "status": "الحالة", - "isHighlighted": "مميّز", - "notes": "ملاحظات", - "assignedTo": "المُسند إليه", - "taskType": "نوع المهمة", - "dueAt": "موعد الاستحقاق", - "reviewDecision": "قرار المراجعة", - "reviewNotes": "ملاحظات المراجعة" - }, - "entity": { - "meeting": "اجتماع", - "attendee": "حاضر", - "request": "طلب", - "task": "مهمة", - "font_settings": "إعدادات الخط", - "pdf_archive": "أرشيف PDF" - }, - "action": { - "create": "إنشاء", - "update": "تعديل", - "delete": "حذف", - "approve": "اعتماد", - "approved": "تم الاعتماد", - "reject": "رفض", - "rejected": "تم الرفض", - "needs_edit": "إعادة للتعديل", - "withdraw": "سحب", - "submit": "إرسال", - "apply": "تطبيق", - "duplicate": "تكرار", - "replace_attendees": "تحديث الحضور", - "done": "تم", - "meeting_cascade_shift": "تأجيل تلقائي بالتسلسل" - } - }, - "pdf": { - "heading": "تصدير PDF", - "intro": "يتم توليد ملف PDF حقيقي لجدول اليوم على الخادم باستخدام إعدادات الخط المحفوظة. كل عملية تنزيل تتم أرشفتها.", - "downloadPdf": "تنزيل PDF", - "downloadHint": "يُولَّد ملف PDF حسب تفضيلات الخط (النوع، الحجم، الوزن، المحاذاة) مع تشكيل عربي صحيح من اليمين لليسار.", - "downloaded": "بدأ تنزيل ملف PDF", - "selectDate": "تاريخ الجدول", - "archivesHeading": "ملفات PDF المؤرشفة", - "noArchives": "لا توجد ملفات PDF مؤرشفة لهذا التاريخ بعد.", - "downloadArchive": "تنزيل", - "legacyArchive": "نسخة قديمة" - }, - "fontSettingsPage": { - "heading": "إعدادات الخط", - "intro": "تطبق هذه الإعدادات على جدول الاجتماعات. يمكنك حفظ تفضيلك الشخصي، ويمكن للمسؤول حفظ الإعدادات العامة لجميع المستخدمين.", - "scope": "النطاق", - "scopeUser": "تفضيلي الشخصي", - "scopeGlobal": "عام (جميع المستخدمين)", - "fontFamily": "نوع الخط", - "fontSize": "حجم الخط", - "fontWeight": "السماكة", - "alignment": "المحاذاة", - "weight": { - "regular": "عادي", - "medium": "متوسط", - "semibold": "نصف عريض", - "bold": "عريض" - }, - "align": { - "start": "بداية", - "center": "وسط", - "end": "نهاية" - }, - "preview": "معاينة", - "previewText": "نص تجريبي ١٢٣ Sample 123", - "save": "حفظ الإعدادات", - "saved": "تم الحفظ", - "reset": "إعادة الافتراضي" - }, - "notificationsPage": { - "empty": "لا توجد تنبيهات بعد.", - "col": { - "meeting": "الاجتماع", - "user": "المستخدم", - "type": "النوع", - "scheduledAt": "الموعد", - "status": "الحالة" - }, - "type": { - "reminder": "تذكير", - "change": "تنبيه تعديل", - "cancel": "تنبيه إلغاء", - "meeting_created": "اجتماع جديد" - }, - "status": { - "pending": "بالانتظار", - "sent": "أُرسل", - "skipped": "متخطى", - "failed": "فشل" - } - } - } -} diff --git a/en.json.old b/en.json.old deleted file mode 100644 index 77748c85..00000000 --- a/en.json.old +++ /dev/null @@ -1,1329 +0,0 @@ -{ - "nav": { - "home": "Home", - "services": "Services", - "chat": "Chat", - "notifications": "Notifications", - "admin": "Admin Dashboard", - "logout": "Logout" - }, - "auth": { - "login": "Login", - "register": "Register", - "username": "Username", - "email": "Email", - "password": "Password", - "loginButton": "Login", - "registerButton": "Register", - "welcomeBack": "Welcome Back", - "createAccount": "Create Account", - "registrationClosed": "Public registration is currently closed. Please contact the administrator.", - "displayNameAr": "Name (Arabic)", - "displayNameEn": "Name (English)", - "subtitle": "Sign in to your account to continue", - "signingIn": "Signing in...", - "noAccount": "Don't have an account?", - "showPassword": "Show password", - "hidePassword": "Hide password", - "errRequired": "Please enter your username and password.", - "errInvalid": "Invalid username or password.", - "forgotPassword": "Forgot password?", - "forgot": { - "title": "Reset your password", - "subtitle": "Enter your username or email and we'll send you a link to reset your password.", - "identifier": "Username or email", - "identifierPlaceholder": "you@example.com", - "submit": "Send reset link", - "sending": "Sending...", - "errRequired": "Please enter your username or email.", - "errGeneric": "Something went wrong. Please try again.", - "successMessage": "Your request has been received. An administrator will share a reset link with you shortly.", - "adminNotice": "Email delivery is not configured for this workspace yet, so an administrator will issue a one-time reset link manually.", - "backToLogin": "Back to sign in" - }, - "reset": { - "title": "Set a new password", - "subtitle": "Choose a new password for your account.", - "newPassword": "New password", - "confirmPassword": "Confirm password", - "submit": "Update password", - "saving": "Saving...", - "errMinLength": "Password must be at least 6 characters.", - "errMismatch": "Passwords do not match.", - "errGeneric": "Could not reset password. The link may have expired.", - "invalidToken": "This reset link is invalid or has expired.", - "requestNew": "Request a new link", - "successMessage": "Your password has been updated. You can now sign in with your new password.", - "goToLogin": "Go to sign in" - }, - "heroTitle": "Welcome to your unified workspace.", - "heroSubtitle": "Sign in to manage everything from one place.", - "feat1": "All your tools in one dashboard", - "feat2": "Real-time updates", - "feat3": "Secure session-based access", - "rights": "All rights reserved." - }, - "home": { - "myApps": "My Apps", - "noApps": "No apps yet", - "noAppsHint": "Apps you have access to will appear here.", - "greeting": { - "morning": "Good morning, {{name}}", - "afternoon": "Good afternoon, {{name}}", - "evening": "Good evening, {{name}}" - }, - "stats": { - "apps": "Apps", - "services": "Services", - "messages": "Messages", - "alerts": "Alerts" - }, - "clockStyle": { - "label": "Clock style", - "showWidget": "Show wall clock on home screen", - "showTopbar": "Show clock in top bar", - "hideWidget": "Hide clock", - "hourFormat": { - "label": "Hour format", - "h12": "12-hour (AM/PM)", - "h24": "24-hour" - }, - "options": { - "full": "Full (time, weekday, Hijri & Gregorian)", - "digital": "Digital with seconds", - "digital-no-seconds": "Digital without seconds", - "analog": "Analog clock", - "minimal": "Minimal (time only)" - } - } - }, - "services": { - "title": "Services", - "available": "Available", - "unavailable": "Unavailable", - "price": "Price", - "noServices": "No services available", - "category": "Category", - "free": "Free", - "order": "Order", - "orderModalTitle": "Order: {{name}}", - "orderNotes": "Notes (optional)", - "orderNotesPlaceholder": "Any extra notes?", - "orderNotesCounter": "{{count}}/500", - "placeOrder": "Send", - "placing": "Sending...", - "orderPlaced": "Order placed", - "orderFailed": "Could not place the order", - "myOrdersLink": "My Orders" - }, - "myOrders": { - "title": "My Orders", - "loadError": "Could not load your orders. Please try again.", - "retry": "Retry", - "back": "Back", - "empty": "No orders yet", - "emptyHint": "Start from the services catalog.", - "browseServices": "Browse services", - "notesLabel": "Notes:", - "cancel": "Cancel order", - "cancelConfirmTitle": "Cancel this order?", - "cancelConfirmBody": "You'll have a few seconds to undo this.", - "cancelConfirm": "Yes, cancel", - "keep": "Keep it", - "cancelled": "Order cancelled", - "cancelFailed": "Could not cancel the order", - "restoreFailed": "Could not restore the order", - "delete": "Delete", - "reorder": "Reorder", - "deleteConfirmTitle": "Delete this order?", - "deleteConfirmBody": "This will permanently remove the order from your history.", - "deleteConfirm": "Yes, delete", - "deleted": "Order deleted", - "deleteFailed": "Could not delete the order", - "undo": "Undo", - "undone": "Restored", - "clearFinished_one": "Clear 1 finished", - "clearFinished_other": "Clear {{count}} finished", - "clearConfirmTitle": "Clear finished orders?", - "clearConfirmBody_one": "1 finished order will be permanently removed from your history.", - "clearConfirmBody_other": "{{count}} finished orders will be permanently removed from your history.", - "clearConfirm": "Yes, clear", - "clearedCount_one": "1 order deleted", - "clearedCount_other": "{{count}} orders deleted", - "clearedPartial": "{{ok}} deleted, {{fail}} failed" - }, - "orderStatus": { - "pending": "Awaiting receiver", - "received": "Received", - "preparing": "Preparing", - "completed": "Completed", - "cancelled": "Cancelled" - }, - "incomingOrders": { - "title": "Incoming Orders", - "linkLabel": "Incoming", - "subtitleUnclaimed": "Awaiting receiver", - "subtitleMine": "My active orders", - "empty": "No incoming orders right now", - "emptyHint": "New orders will show up here as they arrive.", - "loadError": "Could not load incoming orders.", - "retry": "Retry", - "noAccess": "You don't have permission to receive orders.", - "back": "Back", - "ageJustNow": "just now", - "ageMinutes": "{{count}}m ago", - "ageHours": "{{count}}h ago", - "ageDays": "{{count}}d ago", - "from": "From", - "confirmReceipt": "Confirm Receipt", - "confirming": "Confirming...", - "markPreparing": "Mark Preparing", - "markCompleted": "Mark Completed", - "cancel": "Cancel", - "alreadyClaimed": "Already claimed by someone else", - "orderUnavailable": "This order is no longer available", - "actionFailed": "Could not complete the action", - "claimed": "Order received", - "marked": "Status updated", - "cancelled": "Order cancelled", - "undo": "Undo", - "undone": "Restored", - "restoreFailed": "Could not restore the order" - }, - "chat": { - "title": "Chat", - "newMessage": "New Message", - "typeMessage": "Type a message...", - "send": "Send", - "noConversations": "No conversations", - "createConversation": "New Conversation", - "groupChat": "Group", - "directMessage": "Direct Message", - "participants": "Participants", - "searchUsers": "Search users", - "modeDirect": "Direct", - "modeGroup": "Group", - "groupNameAr": "Group name (Arabic)", - "groupNameEn": "Group name (English)", - "groupNamePlaceholderAr": "مثلاً: فريق المبيعات", - "groupNamePlaceholderEn": "e.g. Sales team", - "searchUsersPlaceholder": "Search by name or username", - "participantsCount_one": "{{count}} person selected", - "participantsCount_other": "{{count}} people selected", - "participantsCount_zero": "No one selected", - "noUsersFound": "No matching users", - "uploadAvatar": "Upload picture", - "replaceAvatar": "Replace picture", - "removeAvatar": "Remove picture", - "changeAvatar": "Change group picture", - "uploadingAvatar": "Uploading… {{progress}}%", - "avatarUploadFailed": "Could not upload picture", - "validation": { - "needGroupName": "Enter a group name in Arabic or English.", - "minTwoMembers": "Pick at least 2 people for a group.", - "pickOnePerson": "Pick one person to chat with." - }, - "create": "Create", - "settings": { - "title": "Group settings", - "openSettings": "Open group settings", - "groupName": "Group name", - "saveName": "Save name", - "nameSaved": "Group name updated", - "saveFailed": "Could not save changes", - "needGroupName": "Enter a group name in Arabic or English.", - "members_one": "{{count}} member", - "members_other": "{{count}} members", - "addMembers": "Add members", - "addSelected": "Add", - "membersAdded": "Members added", - "addFailed": "Could not add members", - "removeMember": "Remove member", - "removeFailed": "Could not remove member", - "admin": "Admin", - "you": "(you)", - "viewOnly": "Only the group admin can change the name or members." - }, - "filterAll": "Active", - "filterArchived": "Archived", - "noArchived": "No archived chats", - "actions": { - "title": "Chat options", - "openActions": "Open chat options", - "mute": "Mute notifications", - "unmute": "Unmute notifications", - "archive": "Archive chat", - "unarchive": "Unarchive chat", - "leave": "Leave group", - "leaveConfirmTitle": "Leave this group?", - "leaveConfirmBody": "You will be removed from \"{{name}}\" and stop receiving its messages.", - "successorTitle": "Choose the next admin", - "successorHelp": "You're the only admin. Pick who should take over, or let us promote the longest-tenured member.", - "successorAuto": "Auto (longest-tenured member)", - "muted": "Notifications muted", - "unmuted": "Notifications on", - "archived": "Chat archived", - "unarchived": "Chat moved back to active", - "left": "You left the group", - "actionFailed": "Could not complete action", - "mutedLabel": "Muted", - "youAreAdminTitle": "You are now the admin", - "youAreAdminDescription": "You're now in charge of \"{{name}}\".", - "newAdminBadge": "New admin", - "newAdminLabel": "New admin: {{name}}" - }, - "system": { - "someone": "Someone", - "listSeparator": ", ", - "groupRenamed": "{{actor}} renamed the group to \"{{name}}\"", - "membersAdded_one": "{{actor}} added {{names}}", - "membersAdded_other": "{{actor}} added {{names}}", - "memberRemoved": "{{actor}} removed {{target}}", - "memberLeft": "{{actor}} left the group", - "adminPromoted": "{{target}} is now a group admin", - "adminPromotedAfterLeave": "{{target}} was promoted to admin after the previous admin left" - } - }, - "notifications": { - "title": "Notifications", - "markAllRead": "Mark All Read", - "noNotifications": "No new notifications", - "unread": "Unread" - }, - "admin": { - "title": "Admin Dashboard", - "manageApps": "Manage Apps", - "manageServices": "Manage Services", - "manageUsers": "Manage Users", - "appDisabled": "Disabled", - "addApp": "Add App", - "addService": "Add Service", - "addUser": "Add User", - "editApp": "Edit App", - "editService": "Edit Service", - "editUser": "Edit User", - "deleteConfirm": "Are you sure you want to delete?", - "deleteApp": { - "title": "Delete app \"{{name}}\"?", - "warning": "This app is still in use. Deleting it will affect the following:", - "forceHint": "This will revoke access for everyone using it and remove its history. This cannot be undone.", - "emptyBody": "Are you sure you want to delete this app?", - "groupCount": "{{count}} group(s) currently grant this app", - "restrictionCount": "{{count}} permission rule(s) restricting it", - "openCount": "{{count}} open event(s) in history", - "confirm": "Delete", - "anyway": "Delete anyway" - }, - "deleteService": { - "title": "Delete service \"{{name}}\"?", - "warning": "This service still has orders. Deleting it will affect the following:", - "forceHint": "This will permanently remove every related order. This cannot be undone.", - "emptyBody": "Are you sure you want to delete this service?", - "orderCount": "{{count}} order(s)", - "confirm": "Delete", - "anyway": "Delete anyway" - }, - "deleteUser": { - "title": "Delete user \"{{name}}\"?", - "warning": "This user owns data. Deleting them will affect the following:", - "forceHint": "This will permanently remove their notes, orders, conversations they started, and messages they sent. This cannot be undone.", - "emptyBody": "Are you sure you want to delete this user?", - "noteCount": "{{count}} note(s)", - "orderCount": "{{count}} order(s)", - "conversationCount": "{{count}} conversation(s) created", - "messageCount": "{{count}} message(s) sent", - "confirm": "Delete", - "anyway": "Delete anyway" - }, - "activate": "Activate", - "deactivate": "Deactivate", - "appName": "App Name", - "appNameAr": "App Name (Arabic)", - "appNameEn": "App Name (English)", - "appSlug": "Slug", - "appRoute": "Route", - "appIcon": "Icon", - "appColor": "Color", - "appSortOrder": "Sort Order", - "appPermissions": { - "title": "Required permissions", - "help": "Users need at least one of these to see the app. Leave empty for no restriction.", - "none": "No permissions required — visible to everyone.", - "selectPlaceholder": "Select a permission…", - "add": "Add", - "removeAria": "Remove permission {{name}}", - "impactTitle": "Impact preview", - "impactLoading": "Calculating impact…", - "impactError": "Couldn't load impact preview. Try again.", - "impactNone": "No users would lose access. {{visible}} currently see this app.", - "impactSummary": "{{affected}} of {{visible}} users currently seeing this app would lose access.", - "impactViaGroups": "Members of these groups keep access regardless: {{groups}}.", - "confirmTitle": "Confirm permission change", - "confirmBody": "Adding this requirement will hide the app from {{affected}} of {{visible}} users who currently see it. This change is reversible.", - "confirmAction": "Add anyway" - }, - "serviceName": "Service Name", - "serviceNameAr": "Service Name (Arabic)", - "serviceNameEn": "Service Name (English)", - "serviceDescriptionAr": "Description (Arabic)", - "serviceDescriptionEn": "Description (English)", - "servicePrice": "Price", - "serviceAvailable": "Available?", - "serviceImageUrl": "Image URL", - "serviceImage": "Service Image", - "uploadImage": "Upload Image", - "replaceImage": "Replace Image", - "uploading": "Uploading...", - "uploadFailed": "Image upload failed", - "users": { - "searchPlaceholder": "Search by username or email...", - "allGroups": "All groups", - "allStatuses": "All statuses", - "statusActive": "Active", - "statusInactive": "Inactive", - "showing": "Showing {{count}} of {{total}}", - "empty": "No users match your filters.", - "editGroups": "Edit groups", - "editUserGroups": "Edit groups for {{username}}", - "col": { - "username": "Username", - "email": "Email", - "displayNameAr": "Name (Arabic)", - "displayNameEn": "Name (English)", - "language": "Language", - "createdAt": "Created", - "groups": "Groups", - "actions": "Actions" - }, - "review": { - "title": "Review changes", - "subtitle": "Confirm what will be saved for {{username}}.", - "noChanges": "No changes to save", - "groupsAdded": "Groups added", - "groupsRemoved": "Groups removed", - "back": "Back to edit", - "confirm": "Confirm & save" - }, - "counts": { - "notes": "{{count}} notes", - "orders": "{{count}} orders", - "conversations": "{{count}} convos", - "messages": "{{count}} messages" - }, - "historyTitle": "Permission history", - "historyHint": "Roles and groups assigned to this user. Filter by who changed it or when.", - "historyEmpty": "No permission changes have been recorded for this user yet.", - "historyEmptyFiltered": "No permission changes match the current filters.", - "historyAdded_one": "Added ({{count}}):", - "historyAdded_other": "Added ({{count}}):", - "historyRemoved_one": "Removed ({{count}}):", - "historyRemoved_other": "Removed ({{count}}):", - "historyTotal_one": "{{count}} entry after change", - "historyTotal_other": "{{count}} entries after change", - "history": { - "kind": { - "roles": "Roles", - "groups": "Groups" - } - } - }, - "username": "Username", - "email": "Email", - "password": "Password", - "roles": { - "searchPlaceholder": "Search roles...", - "addRole": "Add Role", - "editRole": "Edit Role", - "name": "Name", - "namePlaceholder": "e.g. content_editor", - "nameHint": "Letters, numbers and underscores only.", - "descriptionAr": "Description (Arabic)", - "descriptionEn": "Description (English)", - "system": "System", - "empty": "No roles yet.", - "edit": "Edit role", - "delete": "Delete role", - "editSystemNameHint": "System role names cannot be changed.", - "deleteTitle": "Delete role \"{{name}}\"?", - "deleteEmptyBody": "This role isn't assigned to anyone. Continue?", - "deleteConflict": "This role is still in use and cannot be deleted.", - "deleteConflictHint": "Remove this role from the affected users and groups before deleting it.", - "deleteConfirm": "Delete", - "usersCount": "{{count}} users", - "groupsCount": "{{count}} groups", - "errorNameTaken": "A role with that name already exists.", - "errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores.", - "errorSystemRename": "System role names cannot be changed.", - "renameWarningTitle": "Renaming \"{{from}}\" to \"{{to}}\"", - "renameWarningBody": "This role's name may be referenced by code, integrations, or audit log queries. Renaming can quietly break those flows. Update any callers before saving.", - "renameUsage": "Currently used by {{users}} users and {{groups}} groups.", - "permissions": "Permissions", - "permissionsHint": "Select which permissions this role grants.", - "permissionsSystemHint": "System role permissions are read-only.", - "permissionsEmpty": "No permissions are defined.", - "errorSystemPermissions": "System role permissions cannot be changed.", - "errorPermissionsSave": "Could not save the role's permissions.", - "impactTitle_one": "Removing {{count}} permission", - "impactTitle_other": "Removing {{count}} permissions", - "impactLoading": "Calculating impact...", - "impactNoUsers": "No users currently get any of these permissions through this role.", - "impactError": "Could not check who would be affected. Please adjust your selection or try again.", - "impactPerPermission": "{{users}} users would lose this ({{groups}} groups grant the role)", - "impactViaGroups": "via groups: {{groups}}", - "impactTotal_one": "{{count}} user in total would lose at least one permission.", - "impactTotal_other": "{{count}} users in total would lose at least one permission.", - "confirmRemovalTitle": "Confirm permission removal", - "confirmRemovalBody_one": "Saving will remove permissions from {{count}} user. This may revoke their access immediately. Continue?", - "confirmRemovalBody_other": "Saving will remove permissions from {{count}} users. This may revoke their access immediately. Continue?", - "confirmRemovalAction": "Remove permissions", - "historyTitle": "History", - "historyHint": "Permission changes for this role. Use the filters to narrow by who changed it or when.", - "historyEmpty": "No permission changes have been recorded for this role yet.", - "historyEmptyFiltered": "No permission changes match the current filters.", - "historyActorUnknown": "Unknown actor", - "historyAdded_one": "Added ({{count}}):", - "historyAdded_other": "Added ({{count}}):", - "historyRemoved_one": "Removed ({{count}}):", - "historyRemoved_other": "Removed ({{count}}):", - "historyTotal_one": "{{count}} permission after change", - "historyTotal_other": "{{count}} permissions after change", - "historyShowing": "Showing {{shown}} of {{total}}", - "historyLoadMore": "Load more", - "historyDownloadCsv": "Download CSV", - "historyDownloadCsvError": "Could not download CSV. Please try again.", - "historyFilters": { - "actor": "Changed by", - "allActors": "Anyone", - "from": "From", - "to": "To", - "apply": "Apply", - "reset": "Reset" - }, - "historyErrors": { - "invalidDate": "Dates must be in YYYY-MM-DD format.", - "invertedDates": "\"From\" must be on or before \"To\".", - "fixFiltersFirst": "Fix the filters above to load history." - } - }, - "active": "Active", - "issueResetLink": "Issue password reset link", - "orderReceiverRole": "Order Receiver", - "resetLinkModal": { - "title": "Password reset link for {{username}}", - "description": "Share this one-time link with the user. It can only be used once.", - "expires": "Expires: {{time}}", - "copy": "Copy link", - "copied": "Link copied to clipboard" - }, - "siteSettings": "Site Settings", - "siteNameAr": "Site Name (Arabic)", - "siteNameEn": "Site Name (English)", - "registrationOpen": "Allow public registration", - "registrationOpenHint": "When off, only admins can create new users.", - "footerTextAr": "Footer Text (Arabic)", - "footerTextEn": "Footer Text (English)", - "nav": { - "dashboard": "Dashboard", - "apps": "Apps", - "services": "Services", - "users": "Users", - "groups": "Groups", - "roles": "Roles", - "auditLog": "Audit log", - "userManagement": "User Management", - "settings": "Settings", - "menu": "Menu" - }, - "apps": { - "counts": { - "groups": "{{count}} groups", - "restrictions": "{{count}} restrictions", - "opens": "{{count}} opens" - }, - "historyTitle": "Permission history", - "historyHint": "Permissions required to use this app. Filter by who changed it or when.", - "historyEmpty": "No permission changes have been recorded for this app yet.", - "historyEmptyFiltered": "No permission changes match the current filters.", - "historyAdded_one": "Added ({{count}}):", - "historyAdded_other": "Added ({{count}}):", - "historyRemoved_one": "Removed ({{count}}):", - "historyRemoved_other": "Removed ({{count}}):", - "historyTotal_one": "{{count}} permission after change", - "historyTotal_other": "{{count}} permissions after change" - }, - "services": { - "counts": { - "orders": "{{count}} orders" - } - }, - "groups": { - "searchPlaceholder": "Search groups...", - "addGroup": "Add Group", - "editGroup": "Edit Group", - "name": "Name", - "descriptionAr": "Description (Arabic)", - "descriptionEn": "Description (English)", - "system": "System", - "empty": "No groups yet.", - "members": "{{count}} members", - "appsCount": "{{count}} apps", - "rolesCount": "{{count}} roles", - "appsHint": "Members of this group will see these apps.", - "usersHint": "Members of this group.", - "rolesHint": "Members of this group are automatically granted these roles.", - "rolesEmpty": "No roles available.", - "deleteTitle": "Delete group \"{{name}}\"?", - "deleteWarning": "This group is still in use. Deleting it will revoke access for the following:", - "deleteForceHint": "Deleting will detach these members and revoke their group-granted apps and roles. This cannot be undone.", - "deleteEmptyBody": "This group has no members, apps, or roles. Continue?", - "deleteConfirm": "Delete", - "deleteAnyway": "Delete anyway", - "tab": { - "info": "Info", - "apps": "Apps", - "users": "Users", - "roles": "Roles", - "history": "History" - }, - "historyTitle": "Permission history", - "historyHint": "Members, roles and apps attached to this group. Filter by who changed it or when.", - "historyEmpty": "No permission changes have been recorded for this group yet.", - "historyEmptyFiltered": "No permission changes match the current filters.", - "historyAdded_one": "Added ({{count}}):", - "historyAdded_other": "Added ({{count}}):", - "historyRemoved_one": "Removed ({{count}}):", - "historyRemoved_other": "Removed ({{count}}):", - "historyTotal_one": "{{count}} entry after change", - "historyTotal_other": "{{count}} entries after change", - "history": { - "kind": { - "users": "Users", - "roles": "Roles", - "apps": "Apps" - } - } - }, - "audit": { - "filters": { - "action": "Action", - "allActions": "All actions", - "actor": "Actor", - "allActors": "All actors", - "actorSearch": "Search by name or @username", - "actorEmpty": "No matching users.", - "actorWithName": "Actor: {{name}}", - "actorWithId": "Actor: #{{id}}", - "clearActorFilter": "Clear actor filter", - "from": "From", - "to": "To", - "apply": "Apply", - "reset": "Reset", - "forcedOnly": "Forced deletions only", - "forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services.", - "targetType": "Target type: {{type}}", - "targetWithId": "Target: {{type}} #{{id}}", - "clearTargetFilter": "Clear target filter" - }, - "actorPivotAria": "Filter audit log to actions by {{name}}", - "target": "Target: {{type}} #{{id}}", - "targetWithName": "Target: {{type}} #{{id}} · {{name}}", - "showing": "Showing {{shown}} of {{total}}", - "empty": "No audit log entries match these filters.", - "loadMore": "Load more", - "export": { - "button": "Export CSV", - "failed": "Could not export the audit log. Please try again." - }, - "errors": { - "invalidDate": "Dates must be in YYYY-MM-DD format.", - "invertedDates": "\"From\" date must be on or before \"To\" date.", - "fixFiltersFirst": "Adjust the filters to load entries." - }, - "unit": { - "member_one": "{{count}} member", - "member_other": "{{count}} members", - "app_one": "{{count}} app", - "app_other": "{{count}} apps", - "role_one": "{{count}} role", - "role_other": "{{count}} roles", - "group_one": "{{count}} group", - "group_other": "{{count}} groups", - "order_one": "{{count}} order", - "order_other": "{{count}} orders", - "change_one": "{{count}} change", - "change_other": "{{count}} changes", - "permission_one": "{{count}} permission", - "permission_other": "{{count}} permissions", - "restriction_one": "{{count}} restriction", - "restriction_other": "{{count}} restrictions", - "note_one": "{{count}} note", - "note_other": "{{count}} notes", - "conversation_one": "{{count}} conversation", - "conversation_other": "{{count}} conversations", - "message_one": "{{count}} message", - "message_other": "{{count}} messages", - "open_one": "{{count}} open", - "open_other": "{{count}} opens" - }, - "dependencies": { - "label": "Cleared dependencies", - "chipAriaTargetType": "{{label}} — open audit log filtered by target type \"{{type}}\"", - "chipAriaParent": "{{label}} — open audit log for {{type}} #{{id}}" - }, - "recent": { - "title": "Recent activity", - "loading": "Loading recent activity…", - "empty": "No recent activity for this record yet.", - "viewAll": "View full history", - "viewAllAria": "Open the audit log filtered to this {{type}}" - }, - "summary": { - "app": { - "create": "Created app '{{name}}'", - "update": "Updated app '{{name}}' ({{changes}})", - "rename": "Renamed app '{{previousName}}' to '{{name}}'", - "delete": "Deleted app '{{name}}'", - "forceDelete": "Force-deleted app '{{name}}' (affected {{groups}})", - "permissionAdd": "Added permission '{{permission}}' to app '{{name}}'", - "permissionRemove": "Removed permission '{{permission}}' from app '{{name}}'" - }, - "auth": { - "issueResetLink": "Issued password reset link for @{{username}}" - }, - "group": { - "create": "Created group '{{name}}' ({{members}}, {{apps}})", - "update": "Updated group '{{name}}' ({{changes}})", - "rename": "Renamed group '{{previousName}}' to '{{name}}'", - "delete": "Deleted group '{{name}}'", - "forceDelete": "Force-deleted group '{{name}}' ({{members}})", - "userAdd": "Added @{{username}} to group '{{name}}'", - "userAddById": "Added user #{{userId}} to group '{{name}}'", - "userRemove": "Removed @{{username}} from group '{{name}}'", - "userRemoveById": "Removed user #{{userId}} from group '{{name}}'", - "appAdd": "Added app '{{appName}}' to group '{{name}}'", - "appAddById": "Added app #{{appId}} to group '{{name}}'", - "appRemove": "Removed app '{{appName}}' from group '{{name}}'", - "appRemoveById": "Removed app #{{appId}} from group '{{name}}'", - "roleAdd": "Added role '{{roleName}}' to group '{{name}}'", - "roleAddById": "Added role #{{roleId}} to group '{{name}}'", - "roleRemove": "Removed role '{{roleName}}' from group '{{name}}'", - "roleRemoveById": "Removed role #{{roleId}} from group '{{name}}'" - }, - "role": { - "create": "Created role '{{name}}'", - "update": "Updated role '{{name}}' ({{changes}})", - "rename": "Renamed role '{{previousName}}' to '{{name}}'", - "delete": "Deleted role '{{name}}'", - "permissionsReplace": "Updated permissions for role '{{name}}' (added {{added}}, removed {{removed}})", - "permissionAdd": "Added permission '{{permission}}' to role '{{name}}'", - "permissionRemove": "Removed permission '{{permission}}' from role '{{name}}'" - }, - "service": { - "delete": "Deleted service '{{name}}'", - "deleteId": "Deleted service #{{id}}", - "forceDelete": "Force-deleted service '{{name}}' (affected {{orders}})" - }, - "settings": { - "update": "Updated system settings ({{changes}})", - "registrationOpened": "Opened public registration", - "registrationClosed": "Closed public registration", - "registrationOpenedWith": "Opened public registration and {{others}}", - "registrationClosedWith": "Closed public registration and {{others}}" - }, - "user": { - "delete": "Deleted user @{{username}}", - "deleteWithName": "Deleted user @{{username}} ({{displayName}})", - "forceDelete": "Force-deleted user @{{username}}", - "forceDeleteWithName": "Force-deleted user @{{username}} ({{displayName}})" - } - } - }, - "dashboardSoon": "Dashboard widgets coming soon.", - "dashboard": { - "totalApps": "Total Apps", - "totalServices": "Total Services", - "totalUsers": "Total Users", - "registration": "Registration", - "open": "Open", - "closed": "Closed", - "recentActivity": "Recent Activity", - "latestUser": "Latest User", - "latestApp": "Latest App", - "none": "—", - "newUsers7d": "New users (7d)", - "vsPrev7d": "Previous 7d: {{count}}", - "newUsersInRange": "New users · {{range}}", - "vsPrevRange": "{{range}}: {{count}}", - "activeServices": "Active services", - "inactiveServices": "Inactive: {{count}}", - "signupsTrend": "Sign-ups", - "last7Days": "Last 7 days", - "appOpensTrend": "App opens", - "appOpensSummary": "{{count}} total · {{delta}}", - "appOpensSummaryRanged": "{{count}} in {{range}} · {{delta}}", - "servicesCreatedTrend": "Services added", - "servicesCreatedSummary": "{{count}} added", - "servicesCreatedSummaryRanged": "{{count}} in {{range}}", - "trends": "Trends", - "rangeSelector": "Time range", - "range": { - "7d": "Last 7 days", - "30d": "Last 30 days", - "90d": "Last 90 days", - "custom": "Custom range", - "customLabel": "{{from}} – {{to}}" - }, - "prevRange": { - "7d": "Previous 7 days", - "30d": "Previous 30 days", - "90d": "Previous 90 days", - "custom": "Previous {{count}} days" - }, - "customRange": { - "from": "From", - "to": "To", - "apply": "Apply", - "invalid": "Pick a valid date range", - "loadError": "Couldn't load stats for the selected dates. Please check the range and try again." - }, - "topApps": "Top apps", - "topAppsSubtitle": "By opens in {{range}}", - "mostActiveUsers": "Most active users", - "mostActiveUsersSubtitle": "By app opens in {{range}}", - "leaderboardEmpty": "No activity yet", - "openCount": "{{count}} opens", - "viewAppDetails": "View details for {{name}}", - "viewUserDetails": "View details for {{name}}", - "drillIn": { - "loading": "Loading…", - "appTitle": "Recent opens · {{name}}", - "userTitle": "Recent opens · {{name}}", - "subtitle": "{{count}} opens in {{range}}", - "empty": "No opens in this range", - "loadError": "Couldn't load recent opens.", - "editApp": "Edit app", - "viewUser": "View user", - "loadMore": "Load more", - "loadMoreError": "Couldn't load more opens.", - "shownOf": "Showing {{shown}} of {{total}}" - } - }, - "dependents": { - "openLabel": "View {{count}}", - "loadError": "Couldn't load items.", - "empty": "Nothing to show.", - "appGroups": { - "title": "Groups · {{name}}", - "subtitle": "{{count}} groups grant access" - }, - "appRestrictions": { - "title": "Restrictions · {{name}}", - "subtitle": "{{count}} required permissions", - "noRoles": "No roles include this permission" - }, - "appOpens": { - "title": "Opens · {{name}}", - "subtitle": "{{count}} total opens" - }, - "serviceOrders": { - "title": "Orders · {{name}}", - "subtitle": "{{count}} orders" - }, - "userNotes": { - "title": "Notes · {{name}}", - "subtitle": "{{count}} notes", - "untitled": "(untitled)", - "pinned": "Pinned", - "archived": "Archived" - }, - "userOrders": { - "title": "Orders · {{name}}", - "subtitle": "{{count}} orders" - }, - "userConversations": { - "title": "Conversations · {{name}}", - "subtitle": "{{count}} conversations", - "kindGroup": "Group", - "kindDirect": "Direct", - "unnamedGroup": "(unnamed group)", - "unnamedDirect": "Direct chat" - }, - "userMessages": { - "title": "Messages · {{name}}", - "subtitle": "{{count}} messages" - }, - "orderStatus": { - "pending": "Pending", - "received": "Received", - "preparing": "Preparing", - "completed": "Completed", - "cancelled": "Cancelled" - } - } - }, - "notFound": { - "title": "404 — Page Not Found", - "description": "The page you are looking for does not exist." - }, - "common": { - "save": "Save", - "cancel": "Cancel", - "delete": "Delete", - "edit": "Edit", - "add": "Add", - "close": "Close", - "loading": "Loading...", - "error": "An error occurred", - "success": "Success", - "language": "العربية", - "appName": "Tx OS", - "previous": "Previous", - "next": "Next", - "empty": "(empty)" - }, - "notes": { - "title": "Notes", - "searchPlaceholder": "Search notes", - "labels": "Labels", - "manageLabels": "Manage labels", - "newLabel": "New label", - "noLabelsYet": "No labels yet", - "allLabels": "All", - "tabs": { - "active": "Notes", - "archived": "Archived" - }, - "pinned": "Pinned", - "others": "Others", - "empty": "Your notes will appear here", - "emptyArchived": "No archived notes", - "takeNote": "Take a note...", - "titlePlaceholder": "Title", - "contentPlaceholder": "Take a note...", - "save": "Save", - "pin": "Pin", - "archive": "Archive", - "unarchive": "Unarchive", - "delete": "Delete", - "deleteConfirm": "Delete this note?", - "deleteLabelConfirm": "Delete this label?", - "editTitle": "Edit note" - }, - "executiveMeetings": { - "title": "Meetings list", - "scheduleHeading": "Meeting Attendance List", - "date": "Date:", - "exportPdf": "Export PDF", - "noMeetings": "No meetings scheduled for this day", - "virtual": "Virtual", - "virtualAttendance": "Virtual Attendance", - "internalAttendance": "Internal Attendance", - "externalAttendance": "External Attendance", - "placeholderBody": "This section is under development and will be available in upcoming phases.", - "col": { - "number": "#", - "meeting": "Meeting", - "attendees": "Attendees", - "time": "Time" - }, - "customize": { - "title": "Customize table", - "hint": "Show or hide columns. Drag column headers to reorder.", - "reset": "Reset", - "dragHint": "Drag a column edge to resize it" - }, - "highlight": { - "title": "Highlight current meeting", - "hint": "Outline the meeting whose time is now.", - "reset": "Reset to default" - }, - "dragRow": "Drag to reorder", - "editMeetingTitle": "Edit meeting title", - "rowColor": { - "label": "Row color" - }, - "rowActions": { - "label": "Row actions", - "back": "Back" - }, - "merge": { - "label": "Merge cells", - "editLabel": "Edit merged cell", - "placeholder": "Click to add text", - "meetingAttendees": "Merge meeting + attendees", - "meetingTime": "Merge meeting + attendees + time", - "all": "Merge entire row", - "unmerge": "Unmerge", - "activeBadge": "Merged: {{cols}}" - }, - "alertPrefs": { - "title": "Upcoming meeting alert", - "hint": "A small floating popup appears 5 minutes before each meeting starts. You can turn it off entirely or pick a color theme that fits your workspace.", - "enabledLabel": "Show upcoming meeting alert", - "colorTitle": "Color theme", - "reset": "Reset to default", - "preset": { - "amber": "Amber (default)", - "blue": "Blue", - "green": "Green", - "slate": "Slate", - "rose": "Rose" - } - }, - "alert": { - "title": "Meeting starting soon", - "minutesAway_one": "Starts in {{count}} min", - "minutesAway_other": "Starts in {{count}} min", - "startsNow": "Starting now", - "atTime": "at {{time}}", - "done": "Done", - "postpone": "Postpone", - "dismiss": "Dismiss alert", - "dragHandle": "Drag to move", - "dismissToast": "Alert dismissed", - "openMeetings": "Open meetings", - "details": "Details", - "detailsClose": "Hide details", - "detailsUrl": "Meeting link", - "detailsTime": "Time", - "detailsLocation": "Location", - "detailsAttendees": "Attendees ({{n}})", - "detailsNoAttendees": "No attendees yet.", - "detailsGroup": { - "internal": "Internal", - "external": "External", - "virtual": "Virtual" - }, - "postponeTitle": "Postpone meeting", - "postponeIntro": "“{{title}}”", - "tabPostpone": "Postpone", - "tabReschedule": "Reschedule", - "tabCancel": "Cancel", - "postponeMinutesPlaceholder": "min", - "postponeMinutesApply": "Postpone {{count}} min", - "postponeConfirmPrompt": "Postpone meeting by {{count}} min?", - "postponeConfirmYes": "Yes, postpone {{count}} min", - "rescheduleHint": "Move the meeting to a new date or time window.", - "rescheduleDate": "Date", - "rescheduleStart": "Start", - "rescheduleEnd": "End", - "rescheduleNote": "Note (optional)", - "rescheduleApply": "Reschedule", - "cancelHint": "Marks the meeting as cancelled. Attendees keep history.", - "cancelApply": "Cancel meeting", - "cancelConfirmPrompt": "Are you sure? The meeting will be marked as cancelled.", - "cancelConfirmYes": "Yes, cancel", - "back": "Back", - "saved": "Saved", - "saveFailed": "Could not save changes", - "conflictWarning": "Heads up: the new time overlaps another meeting on the same day.", - "noTimeWindow": "This meeting has no scheduled time, so it can’t be postponed by minutes.", - "staleMeetingTitle": "This meeting was just changed by another user.", - "staleMeetingByUser": "{{name}} just changed this meeting.", - "staleMeetingCurrentTime": "Current time: {{start}} – {{end}}.", - "staleMeetingApplyAnyway_one": "Add {{count}} more minute anyway", - "staleMeetingApplyAnyway_other": "Add {{count}} more minutes anyway", - "cascadeLoading": "Checking later meetings…", - "cascadeMinutesUnit_one": "{{count}} minute", - "cascadeMinutesUnit_other": "{{count}} minutes", - "cascadePromptHeader_one": "{{count}} later meeting on this day would be affected. Also shift it by {{minutesText}}?", - "cascadePromptHeader_other": "{{count}} later meetings on this day would be affected. Also shift them by {{minutesText}}?", - "cascadeListTitle": "Affected meetings:", - "cascadeListItem": "{{title}} — {{oldStart}} → {{newStart}}", - "cascadeShift_one": "Shift {{count}} following meeting", - "cascadeShift_other": "Shift {{count}} following meetings", - "cascadeKeep": "Keep their times", - "cascadeMidnightError": "Can’t shift the day: \"{{title}}\" would land at {{projectedStart}}, past midnight.", - "cascadeAuditDescription": "Auto-shifted by {{minutes}} minutes after meeting #{{triggerMeetingId}} was postponed." - }, - "timeEditor": { - "am": "AM", - "pm": "PM", - "periodGroupStart": "AM or PM for the start time", - "periodGroupEnd": "AM or PM for the end time" - }, - "schedule": { - "addRow": "Add meeting", - "newMeetingDefaultAr": "اجتماع جديد", - "newMeetingDefaultEn": "New meeting", - "timeStart": "Start time", - "timeEnd": "End time", - "timeStartShort": "Start", - "timeEndShort": "End", - "timeOrderError": "End time is before start time", - "timeParseError": "Time format isn't valid. Try 09:30 or 9:30 PM", - "timeAmbiguousError": "Pick AM or PM for the time", - "addAttendee": "Add attendee", - "addSubheading": "+ subheading", - "addVirtualAttendee": "+ Virtual", - "addInternalAttendee": "+ Internal", - "addExternalAttendee": "+ External", - "newAttendeeAria": "New attendee name", - "editAttendeeAria": "Edit attendee name", - "newSubheadingAria": "New subheading label", - "editSubheadingAria": "Edit subheading label", - "deleteRow": "Delete meeting", - "deleteRowConfirm": "Delete this meeting?\n\n\"{{title}}\"\n\nThis cannot be undone.", - "deleted": "Meeting deleted", - "editToggle": "Edit", - "editToggleAria": "Toggle edit mode", - "editToggleOn": "Edit mode on", - "editToggleOff": "Edit mode off", - "saveToggle": "Save", - "saveToggleAria": "Finish editing and save changes", - "saveToggleOn": "Edit mode on — click to save and exit", - "saveToggleOff": "Edit mode off — click to start editing", - "bulkSelectRow": "Select meeting", - "bulkSelectAll": "Select all", - "bulkSelectionCount": "Selected: {{n}} of {{total}}", - "bulkClearSelection": "Clear selection", - "bulkDeleteSelected": "Delete selected", - "bulkDeleteConfirm": "Delete {{n}} meetings?", - "bulkDeleteResultAll": "Deleted {{n}} meetings", - "bulkDeletePartial": "Deleted {{ok}} of {{total}} meetings; {{failed}} failed", - "bulkDeleteResultNone": "Failed to delete the selected meetings", - "bulkDeleteUndo": "Undo", - "bulkDeleteUndone": "Restored {{n}} meetings", - "bulkDeleteUndoPartial": "Restored {{ok}} of {{total}} meetings", - "bulkDeleteUndoFailed": "Could not restore meetings", - "searchPlaceholder": "Search a meeting…", - "searchAria": "Search today's meetings", - "searchClear": "Clear search", - "searchNoMatches": "No meetings match your search.", - "bulkDuplicateSelected": "Duplicate to date…", - "bulkDuplicateTitle": "Duplicate {{n}} meetings to date", - "bulkDuplicateConfirm": "Duplicate", - "bulkDuplicating": "Duplicating…", - "bulkDuplicateResultAll": "Duplicated {{n}} meetings", - "bulkDuplicatePartial": "Duplicated {{ok}} of {{total}}, {{failed}} failed", - "bulkDuplicateNone": "Could not duplicate the selected meetings" - }, - "nav": { - "schedule": "Daily Schedule", - "manage": "Manage Meetings", - "notifications": "Notifications", - "audit": "Audit Log", - "pdf": "PDF Export", - "settings": "Settings" - }, - "common": { - "save": "Save", - "cancel": "Cancel", - "delete": "Delete", - "edit": "Edit", - "add": "Add", - "submit": "Submit", - "close": "Close", - "loading": "Loading...", - "saved": "Saved", - "saveFailed": "Save failed", - "actions": "Actions", - "noPermission": "You do not have permission for this action", - "yes": "Yes", - "no": "No", - "all": "All", - "filter": "Filter", - "confirm": "Confirm", - "confirmTitle": "Confirm" - }, - "manage": { - "heading": "Manage Meetings", - "addMeeting": "Add meeting", - "editMeeting": "Edit meeting", - "deleteMeeting": "Delete meeting", - "deleteConfirm": "Permanently delete this meeting?", - "noMeetings": "No meetings on this day. Click \"Add meeting\" to create one.", - "field": { - "titleAr": "Title", - "titleEn": "Title (English)", - "meetingDate": "Date", - "dailyNumber": "Daily number", - "dailyNumberHint": "Leave blank for auto-numbering", - "startTime": "Start time", - "endTime": "End time", - "location": "Location", - "meetingUrl": "Meeting URL", - "platform": "Platform", - "status": "Status", - "isHighlighted": "Highlight (red)", - "notes": "Notes" - }, - "attendees": { - "label": "Attendees", - "name": "Name", - "title": "Title", - "id": "Attendee ID", - "type": "Attendance type", - "add": "Add member", - "addSubheading": "Add subheading", - "subheadingName": "Subheading label", - "subheadingBadge": "Subheading", - "remove": "Remove", - "moveUp": "Move up", - "moveDown": "Move down", - "dragHandle": "Drag to reorder", - "removeAll": "Remove all attendees", - "removeAllConfirm": "Remove all {{count}} attendees?\n\nThe change applies when you save the meeting." - }, - "duplicate": "Duplicate", - "duplicateToDate": "Duplicate to date", - "duplicated": "Meeting duplicated", - "duplicateFailed": "Could not duplicate the meeting", - "platform": { - "none": "None", - "webex": "Webex", - "teams": "Teams", - "zoom": "Zoom", - "other": "Other" - }, - "attendanceType": { - "internal": "Internal", - "virtual": "Virtual", - "external": "External" - }, - "status": { - "scheduled": "Scheduled", - "cancelled": "Cancelled", - "completed": "Completed", - "postponed": "Postponed" - }, - "errors": { - "titleRequired": "Arabic title is required", - "dateRequired": "Date is required", - "duplicateNumber": "That daily number already exists for this date", - "saveFailed": "Could not save the meeting", - "endTimeBeforeStart": "End time must be after start time" - } - }, - "audit": { - "heading": "Audit Log", - "empty": "No audit entries.", - "filterDate": "Filter by date", - "filterEntity": "Entity", - "filter": { - "from": "From", - "to": "To", - "actorId": "Actor ID" - }, - "pageInfo": "Showing", - "all": "All", - "col": { - "when": "When", - "actor": "User", - "action": "Action", - "entity": "Entity", - "entityId": "ID", - "diff": "Details", - "changes": "Changes" - }, - "created": "Created", - "removed": "Removed", - "moreChanges": "more changes", - "field": { - "titleAr": "Title (Arabic)", - "titleEn": "Title (English)", - "meetingDate": "Date", - "dailyNumber": "Daily number", - "startTime": "Start time", - "endTime": "End time", - "location": "Location", - "meetingUrl": "Meeting URL", - "platform": "Platform", - "status": "Status", - "isHighlighted": "Highlighted", - "notes": "Notes", - "assignedTo": "Assigned to", - "taskType": "Task type", - "dueAt": "Due at", - "reviewDecision": "Review decision", - "reviewNotes": "Review notes" - }, - "entity": { - "meeting": "Meeting", - "attendee": "Attendee", - "request": "Request", - "task": "Task", - "font_settings": "Font settings", - "pdf_archive": "PDF archive" - }, - "action": { - "create": "Create", - "update": "Update", - "delete": "Delete", - "approve": "Approve", - "approved": "Approved", - "reject": "Reject", - "rejected": "Rejected", - "needs_edit": "Needs edit", - "withdraw": "Withdrawn", - "submit": "Submit", - "apply": "Apply", - "duplicate": "Duplicate", - "replace_attendees": "Update attendees", - "done": "Done", - "meeting_cascade_shift": "Auto-shifted by cascade" - } - }, - "pdf": { - "heading": "PDF Export", - "intro": "Generates a real PDF of the day's schedule on the server using your saved font settings. Each download is archived.", - "downloadPdf": "Download PDF", - "downloadHint": "The PDF is generated using your font preferences (family, size, weight, alignment) with proper Arabic shaping.", - "downloaded": "PDF download started", - "selectDate": "Schedule date", - "archivesHeading": "Archived PDFs", - "noArchives": "No PDFs archived for this date yet.", - "downloadArchive": "Download", - "legacyArchive": "Legacy snapshot" - }, - "fontSettingsPage": { - "heading": "Font Settings", - "intro": "These settings apply to the meetings schedule. You can save your personal preference; admins can also save the global default.", - "scope": "Scope", - "scopeUser": "My preference", - "scopeGlobal": "Global (everyone)", - "fontFamily": "Font family", - "fontSize": "Font size", - "fontWeight": "Weight", - "alignment": "Alignment", - "weight": { - "regular": "Regular", - "medium": "Medium", - "semibold": "Semibold", - "bold": "Bold" - }, - "align": { - "start": "Start", - "center": "Center", - "end": "End" - }, - "preview": "Preview", - "previewText": "Sample text 123 نص تجريبي ١٢٣", - "save": "Save settings", - "saved": "Saved", - "reset": "Reset to default" - }, - "notificationsPage": { - "empty": "No notifications yet.", - "col": { - "meeting": "Meeting", - "user": "User", - "type": "Type", - "scheduledAt": "Scheduled", - "status": "Status" - }, - "type": { - "reminder": "Reminder", - "change": "Change notice", - "cancel": "Cancellation notice", - "meeting_created": "Meeting created" - }, - "status": { - "pending": "Pending", - "sent": "Sent", - "skipped": "Skipped", - "failed": "Failed" - } - } - } -} diff --git a/executive-meetings-base.ts b/executive-meetings-base.ts deleted file mode 100644 index 9598a31a..00000000 --- a/executive-meetings-base.ts +++ /dev/null @@ -1,2564 +0,0 @@ -import { Router, type IRouter } from "express"; -import { - eq, - asc, - desc, - inArray, - and, - sql, - gte, - lt, - isNull, - or, - type SQL, -} from "drizzle-orm"; -import { z, type ZodType } from "zod"; -import { db } from "@workspace/db"; -import { - executiveMeetingsTable, - executiveMeetingAttendeesTable, - executiveMeetingRequestsTable, - executiveMeetingTasksTable, - executiveMeetingNotificationsTable, - executiveMeetingAuditLogsTable, - executiveMeetingPdfArchivesTable, - executiveMeetingFontSettingsTable, - rolesTable, - usersTable, - type ExecutiveMeetingAttendee, -} from "@workspace/db"; -import { - requireAuth, - requireExecutiveAccess, - getEffectiveRoleIds, -} from "../middlewares/auth"; -import { sanitizeRichText } from "../lib/sanitize"; -import { emitExecutiveMeetingsDayChanged } from "../lib/realtime"; -import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer"; -import { ObjectStorageService } from "../lib/objectStorage"; -import { - recordExecutiveMeetingNotifications, - broadcastExecutiveMeetingNotifications, - getUserIdsForRoleNames, - sendExecutiveMeetingEmail, - getUserDisplay as getUserDisplayForNotify, -} from "../lib/executive-meeting-notify"; -import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod"; -import type { Request, Response, NextFunction } from "express"; - -const router: IRouter = Router(); - -router.param("id", (req, res, next, value) => { - if (!/^\d+$/.test(String(value))) { - next("route"); - return; - } - next(); -}); - -// ---------- Constants ---------- - -const PLATFORMS = ["none", "webex", "teams", "zoom", "other"] as const; -const STATUSES = ["scheduled", "cancelled", "completed", "postponed"] as const; -const ATTENDANCE_TYPES = ["internal", "virtual", "external"] as const; -// Schedule column ids that the merge-cells overlay can span. Kept in -// sync with the frontend ColumnId enum in -// artifacts/tx-os/src/pages/executive-meetings.tsx. -const MERGE_COLUMNS = ["number", "meeting", "attendees", "time"] as const; -const MERGE_COLUMN_INDEX: Record<(typeof MERGE_COLUMNS)[number], number> = { - number: 0, - meeting: 1, - attendees: 2, - time: 3, -}; -const REQUEST_TYPES = [ - "create", - "edit", - "delete", - "reschedule", - "add_attendee", - "remove_attendee", - "change_location", - "cancel_meeting", - "note", - "highlight", - "unhighlight", - "other", -] as const; -const REQUEST_REVIEW_STATUSES = [ - "approved", - "rejected", - "needs_edit", -] as const; -const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const; -const FONT_FAMILIES = [ - "system", - "Cairo", - "Tajawal", - "Noto Naskh Arabic", - "Amiri", -] as const; -const FONT_WEIGHTS = ["regular", "bold"] as const; -const FONT_ALIGNMENTS = ["start", "center"] as const; -const FONT_SIZE_MIN = 12; -const FONT_SIZE_MAX = 22; - -const MUTATE_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", -] as const; -const APPROVE_ROLES = ["admin", "executive_office_manager"] as const; -const REQUEST_ROLES = [ - "admin", - "executive_ceo", - "executive_office_manager", - "executive_coord_lead", - "executive_coordinator", -] as const; -const ADMIN_AUDIT_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", -] as const; -const TASK_VIEW_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", - "executive_coordinator", -] as const; -const TASK_BROAD_VIEW_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", -] as const; -const READ_ROLES = [ - "admin", - "executive_ceo", - "executive_office_manager", - "executive_coord_lead", - "executive_coordinator", - "executive_viewer", -] as const; - -// ---------- Role helpers (finer-grained, layered on top of requireExecutiveAccess) ---------- - -async function getRoleNamesForUser(userId: number): Promise> { - const ids = await getEffectiveRoleIds(userId); - if (ids.length === 0) return new Set(); - const rows = await db - .select({ name: rolesTable.name }) - .from(rolesTable) - .where(inArray(rolesTable.id, ids)); - return new Set(rows.map((r) => r.name)); -} - -function makeRequireRoles(allowed: ReadonlyArray) { - return async function ( - req: Request, - res: Response, - next: NextFunction, - ): Promise { - if (!req.session.userId) { - res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); - return; - } - const names = await getRoleNamesForUser(req.session.userId); - const ok = allowed.some((r) => names.has(r)); - if (!ok) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); - return; - } - next(); - }; -} - -const requireMutate = makeRequireRoles(MUTATE_ROLES); -const requireApprove = makeRequireRoles(APPROVE_ROLES); -const requireRequest = makeRequireRoles(REQUEST_ROLES); -const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES); -const requireTaskView = makeRequireRoles(TASK_VIEW_ROLES); - -// ---------- Zod schemas ---------- - -const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD"); -const timeSchema = z - .string() - .regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM"); -const positiveIntSchema = z.number().int().positive(); - -// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap -// output). The byte count grows quickly with inline wrappers, -// so we allow up to ~10k bytes for titles and ~5k for attendee names. The -// sanitizer strips disallowed tags/attrs before persistence. -const attendeeSchema = z.object({ - name: z.string().trim().min(1).max(5000), - title: z.string().trim().max(200).nullable().optional(), - attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"), - sortOrder: z.number().int().min(0).optional(), -}); - -const meetingBaseFields = { - titleAr: z.string().trim().min(1).max(10000), - titleEn: z.string().trim().min(1).max(10000), - meetingDate: dateSchema, - dailyNumber: positiveIntSchema.max(1000).nullable().optional(), - startTime: timeSchema.nullable().optional(), - endTime: timeSchema.nullable().optional(), - location: z.string().trim().max(500).nullable().optional(), - meetingUrl: z.string().trim().max(2000).nullable().optional(), - platform: z.enum(PLATFORMS).optional(), - status: z.enum(STATUSES).optional(), - isHighlighted: z - .union([z.boolean(), z.number()]) - .transform((v) => (v ? 1 : 0)) - .optional(), - notes: z.string().trim().max(5000).nullable().optional(), -} as const; - -const meetingCreateSchema = z - .object({ - ...meetingBaseFields, - attendees: z.array(attendeeSchema).optional(), - }) - .refine( - (v) => !v.startTime || !v.endTime || v.startTime <= v.endTime, - { message: "startTime must be <= endTime", path: ["endTime"] }, - ); - -// Cell-merge overlay payload accepted by PATCH. The whole `merge` key is -// optional; when present it must either be `null` (clear) or a fully -// specified object describing the column range and the free-text label. -// `mergeText` accepts sanitized rich-text HTML up to ~10k bytes (same -// budget as titleAr/titleEn). -const meetingMergeSchema = z - .union([ - z.null(), - z.object({ - mergeStartColumn: z.enum(MERGE_COLUMNS), - mergeEndColumn: z.enum(MERGE_COLUMNS), - mergeText: z.string().trim().max(10000), - }), - ]) - .refine( - (v) => - v === null || - MERGE_COLUMN_INDEX[v.mergeStartColumn] <= - MERGE_COLUMN_INDEX[v.mergeEndColumn], - { - message: "mergeStartColumn must come before mergeEndColumn", - path: ["mergeEndColumn"], - }, - ); - -const meetingPatchSchema = z - .object({ - titleAr: meetingBaseFields.titleAr.optional(), - titleEn: meetingBaseFields.titleEn.optional(), - meetingDate: meetingBaseFields.meetingDate.optional(), - dailyNumber: meetingBaseFields.dailyNumber, - startTime: meetingBaseFields.startTime, - endTime: meetingBaseFields.endTime, - location: meetingBaseFields.location, - meetingUrl: meetingBaseFields.meetingUrl, - platform: meetingBaseFields.platform, - status: meetingBaseFields.status, - isHighlighted: meetingBaseFields.isHighlighted, - notes: meetingBaseFields.notes, - attendees: z.array(attendeeSchema).optional(), - merge: meetingMergeSchema.optional(), - }) - .refine( - (v) => !v.startTime || !v.endTime || v.startTime <= v.endTime, - { message: "startTime must be <= endTime", path: ["endTime"] }, - ); - -const attendeesPutSchema = z.object({ - attendees: z.array(attendeeSchema), -}); - -const duplicateSchema = z.object({ - targetDate: dateSchema, -}); - -// Reorder schema lives in @workspace/api-zod (lib/api-zod/src/manual.ts) so -// frontend and server share the same contract for POST /reorder. -const reorderSchema = ExecutiveMeetingsReorderBody; - -const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); -const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/); - -const detailsByType = { - create: z - .object({ - titleAr: z.string().trim().max(300).optional(), - titleEn: z.string().trim().max(300).optional(), - meetingDate: dateOnly.optional(), - startTime: timeHm.optional(), - endTime: timeHm.optional(), - location: z.string().trim().max(300).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - edit: z - .object({ - titleAr: z.string().trim().max(300).optional(), - titleEn: z.string().trim().max(300).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - delete: z - .object({ - reason: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - reschedule: z - .object({ - meetingDate: dateOnly.optional(), - startTime: timeHm.optional(), - endTime: timeHm.optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()) - .refine( - (v) => Boolean(v.meetingDate || v.startTime || v.endTime), - { message: "reschedule requires meetingDate, startTime, or endTime" }, - ), - add_attendee: z - .object({ - name: z.string().trim().min(1).max(200), - title: z.string().trim().max(200).optional(), - attendanceType: z.enum(ATTENDANCE_TYPES).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - remove_attendee: z - .object({ - attendeeId: z.number().int().positive(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - change_location: z - .object({ - location: z.string().trim().max(300).optional(), - meetingUrl: z.string().trim().max(1000).optional(), - platform: z.enum(PLATFORMS).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()) - .refine( - (v) => Boolean(v.location || v.meetingUrl || v.platform), - { message: "change_location requires location, meetingUrl, or platform" }, - ), - cancel_meeting: z - .object({ - reason: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - note: z - .object({ - note: z.string().trim().max(5000).optional(), - text: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()) - .refine( - (v) => Boolean((v.note ?? v.text ?? "").trim()), - { message: "note text required" }, - ), - highlight: z - .object({ - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - unhighlight: z - .object({ - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - other: z.object({}).catchall(z.unknown()), -} as const; - -const requestPayloadSchemas = z.discriminatedUnion("requestType", [ - z.object({ requestType: z.literal("create"), requestDetails: detailsByType.create }), - z.object({ requestType: z.literal("edit"), requestDetails: detailsByType.edit }), - z.object({ requestType: z.literal("delete"), requestDetails: detailsByType.delete }), - z.object({ requestType: z.literal("reschedule"), requestDetails: detailsByType.reschedule }), - z.object({ requestType: z.literal("add_attendee"), requestDetails: detailsByType.add_attendee }), - z.object({ requestType: z.literal("remove_attendee"), requestDetails: detailsByType.remove_attendee }), - z.object({ requestType: z.literal("change_location"), requestDetails: detailsByType.change_location }), - z.object({ requestType: z.literal("cancel_meeting"), requestDetails: detailsByType.cancel_meeting }), - z.object({ requestType: z.literal("note"), requestDetails: detailsByType.note }), - z.object({ requestType: z.literal("highlight"), requestDetails: detailsByType.highlight }), - z.object({ requestType: z.literal("unhighlight"), requestDetails: detailsByType.unhighlight }), - z.object({ requestType: z.literal("other"), requestDetails: detailsByType.other }), -]); - -const requestCreateSchema = z.intersection( - z.object({ meetingId: positiveIntSchema.nullable().optional() }), - requestPayloadSchemas, -); - -const requestNestedCreateSchema = requestPayloadSchemas; - -const requestReviewSchema = z.union([ - z.object({ - action: z.literal("withdraw"), - }), - z.object({ - status: z.enum(REQUEST_REVIEW_STATUSES), - reviewNotes: z.string().trim().max(2000).nullable().optional(), - }), -]); - -const dueAtSchema = z - .union([ - z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - z.string().datetime(), - z.literal(""), - ]) - .nullable() - .optional() - .transform((v) => { - if (v === "" || v === null || v === undefined) return null; - return /^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00.000Z` : v; - }); - -const taskCreateSchema = z.object({ - taskType: z.string().trim().min(1).max(64), - meetingId: positiveIntSchema.nullable().optional(), - requestId: positiveIntSchema.nullable().optional(), - assignedTo: positiveIntSchema.nullable().optional(), - dueAt: dueAtSchema, - notes: z.string().trim().max(5000).nullable().optional(), -}); - -const taskPatchSchema = z.object({ - status: z.enum(TASK_STATUSES).optional(), - taskType: z.string().trim().min(1).max(64).optional(), - assignedTo: positiveIntSchema.nullable().optional(), - dueAt: dueAtSchema, - notes: z.string().trim().max(5000).nullable().optional(), - meetingId: positiveIntSchema.nullable().optional(), -}); - -const pdfArchiveCreateSchema = z.object({ - archiveDate: dateSchema, - filePath: z.string().trim().max(500).optional(), -}); - -const fontSettingsSchema = z.object({ - scope: z.enum(["user", "global"]).default("user"), - fontFamily: z.enum(FONT_FAMILIES).default("system"), - fontSize: z.number().int().min(FONT_SIZE_MIN).max(FONT_SIZE_MAX).default(14), - fontWeight: z.enum(FONT_WEIGHTS).default("regular"), - alignment: z.enum(FONT_ALIGNMENTS).default("start"), -}); - -function parseBody( - res: Response, - schema: ZodType, - body: unknown, -): T | null { - const r = schema.safeParse(body ?? {}); - if (!r.success) { - const issue = r.error.issues[0]; - const msg = issue - ? `${issue.path.join(".") || "body"}: ${issue.message}` - : "validation"; - res.status(400).json({ error: msg, code: "validation" }); - return null; - } - return r.data; -} - - -type DbExecutor = typeof db | Parameters[0]>[0]; -type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert; - -async function logAudit( - executor: DbExecutor, - opts: { - action: string; - entityType: string; - entityId: number | null; - oldValue?: unknown; - newValue?: unknown; - performedBy: number | null; - }, -): Promise { - const row: AuditInsert = { - action: opts.action, - entityType: opts.entityType, - entityId: opts.entityId ?? null, - oldValue: (opts.oldValue ?? null) as AuditInsert["oldValue"], - newValue: (opts.newValue ?? null) as AuditInsert["newValue"], - performedBy: opts.performedBy, - }; - await executor.insert(executiveMeetingAuditLogsTable).values(row); -} - -// ---------- Common helpers ---------- - -async function nextDailyNumber( - executor: DbExecutor, - date: string, -): Promise { - const [row] = await executor - .select({ - max: sql`max(${executiveMeetingsTable.dailyNumber})`, - }) - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, date)); - return (row?.max ?? 0) + 1; -} - -async function fetchMeetingWithAttendees(id: number) { - const [meeting] = await db - .select() - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.id, id)); - if (!meeting) return null; - const attendees = await db - .select() - .from(executiveMeetingAttendeesTable) - .where(eq(executiveMeetingAttendeesTable.meetingId, id)) - .orderBy(asc(executiveMeetingAttendeesTable.sortOrder)); - return { ...meeting, attendees }; -} - -// ===================================================================== -// MEETINGS -// ===================================================================== - -router.get( - "/executive-meetings", - requireExecutiveAccess, - async (req, res): Promise => { - const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - if (dateRaw && !/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) { - res - .status(400) - .json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" }); - return; - } - const targetDate = dateRaw || new Date().toISOString().slice(0, 10); - const meetings = await db - .select() - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, targetDate)) - .orderBy(asc(executiveMeetingsTable.dailyNumber)); - if (meetings.length === 0) { - res.json({ date: targetDate, meetings: [] }); - return; - } - const ids = meetings.map((m) => m.id); - const attendees = await db - .select() - .from(executiveMeetingAttendeesTable) - .where(inArray(executiveMeetingAttendeesTable.meetingId, ids)) - .orderBy( - asc(executiveMeetingAttendeesTable.meetingId), - asc(executiveMeetingAttendeesTable.sortOrder), - ); - const byMeeting = new Map(); - for (const a of attendees) { - const arr = byMeeting.get(a.meetingId) ?? []; - arr.push(a); - byMeeting.set(a.meetingId, arr); - } - res.json({ - date: targetDate, - meetings: meetings.map((m) => ({ - ...m, - attendees: byMeeting.get(m.id) ?? [], - })), - }); - }, -); - -router.get( - "/executive-meetings/dates", - requireExecutiveAccess, - async (_req, res): Promise => { - const rows = await db - .selectDistinct({ d: executiveMeetingsTable.meetingDate }) - .from(executiveMeetingsTable) - .orderBy(asc(executiveMeetingsTable.meetingDate)); - res.json({ dates: rows.map((r) => r.d) }); - }, -); - -router.get( - "/executive-meetings/me", - requireExecutiveAccess, - async (req, res): Promise => { - if (!req.session.userId) { - res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); - return; - } - const names = await getRoleNamesForUser(req.session.userId); - res.json({ - userId: req.session.userId, - roles: Array.from(names), - canRead: READ_ROLES.some((r) => names.has(r)), - canMutate: MUTATE_ROLES.some((r) => names.has(r)), - canApprove: APPROVE_ROLES.some((r) => names.has(r)), - canSubmitRequest: REQUEST_ROLES.some((r) => names.has(r)), - canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)), - canViewTasks: TASK_VIEW_ROLES.some((r) => names.has(r)), - canViewAllTasks: TASK_BROAD_VIEW_ROLES.some((r) => names.has(r)), - }); - }, -); - -router.get( - "/executive-meetings/:id", - requireExecutiveAccess, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const m = await fetchMeetingWithAttendees(id); - if (!m) { - res.status(404).json({ error: "Meeting not found", code: "not_found" }); - return; - } - res.json(m); - }, -); - -router.post( - "/executive-meetings", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const data = parseBody(res, meetingCreateSchema, req.body); - if (!data) return; - const userId = req.session.userId!; - try { - const inserted = await db.transaction(async (tx) => { - const dailyNumber = - data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate)); - const meetingValues: typeof executiveMeetingsTable.$inferInsert = { - titleAr: sanitizeRichText(data.titleAr), - titleEn: sanitizeRichText(data.titleEn ?? ""), - meetingDate: data.meetingDate, - dailyNumber, - startTime: data.startTime ?? null, - endTime: data.endTime ?? null, - location: data.location ?? null, - meetingUrl: data.meetingUrl ?? null, - platform: data.platform ?? "none", - status: data.status ?? "scheduled", - isHighlighted: data.isHighlighted ?? 0, - notes: data.notes ?? null, - createdBy: userId, - updatedBy: userId, - }; - const [meeting] = await tx - .insert(executiveMeetingsTable) - .values(meetingValues) - .returning(); - if (!meeting) throw new Error("insert_failed"); - const attendees = data.attendees ?? []; - if (attendees.length > 0) { - await tx.insert(executiveMeetingAttendeesTable).values( - attendees.map((a, idx) => ({ - meetingId: meeting.id, - name: sanitizeRichText(a.name), - title: a.title ?? null, - attendanceType: a.attendanceType, - sortOrder: a.sortOrder ?? idx, - })), - ); - } - await logAudit(tx, { - action: "create", - entityType: "meeting", - entityId: meeting.id, - newValue: { ...meeting, attendees }, - performedBy: userId, - }); - const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES); - const recipients = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: approverIds, - meetingId: meeting.id, - notificationType: "meeting_created", - titleAr: "اجتماع جديد", - titleEn: "New executive meeting", - bodyAr: meeting.titleAr || meeting.titleEn || "", - bodyEn: meeting.titleEn || meeting.titleAr || "", - relatedType: "executive_meeting", - relatedId: meeting.id, - excludeUserId: userId, - }); - return { meeting, recipients }; - }); - void broadcastExecutiveMeetingNotifications( - inserted.recipients, - "meeting_created", - inserted.meeting.id, - ); - const full = await fetchMeetingWithAttendees(inserted.meeting.id); - res.status(201).json(full); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("executive_meetings_date_number_unique")) { - res - .status(409) - .json({ - error: "Daily number already used for this date", - code: "duplicate_number", - }); - return; - } - res.status(500).json({ error: "Could not create meeting", code: "create_failed" }); - } - }, -); - -router.patch( - "/executive-meetings/:id", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const existing = await fetchMeetingWithAttendees(id); - if (!existing) { - res.status(404).json({ error: "Meeting not found", code: "not_found" }); - return; - } - const data = parseBody(res, meetingPatchSchema, req.body); - if (!data) return; - const userId = req.session.userId!; - try { - await db.transaction(async (tx) => { - const updateValues: Partial = { - updatedBy: userId, - }; - if (data.titleAr !== undefined) - updateValues.titleAr = sanitizeRichText(data.titleAr); - if (data.titleEn !== undefined) - updateValues.titleEn = sanitizeRichText(data.titleEn ?? ""); - if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate; - if (data.dailyNumber !== undefined && data.dailyNumber !== null) - updateValues.dailyNumber = data.dailyNumber; - if (data.startTime !== undefined) updateValues.startTime = data.startTime; - if (data.endTime !== undefined) updateValues.endTime = data.endTime; - if (data.location !== undefined) updateValues.location = data.location; - if (data.meetingUrl !== undefined) updateValues.meetingUrl = data.meetingUrl; - if (data.platform !== undefined) updateValues.platform = data.platform; - if (data.status !== undefined) updateValues.status = data.status; - if (data.isHighlighted !== undefined) - updateValues.isHighlighted = data.isHighlighted; - if (data.notes !== undefined) updateValues.notes = data.notes; - if (data.merge !== undefined) { - if (data.merge === null) { - updateValues.mergeStartColumn = null; - updateValues.mergeEndColumn = null; - updateValues.mergeText = null; - } else { - updateValues.mergeStartColumn = data.merge.mergeStartColumn; - updateValues.mergeEndColumn = data.merge.mergeEndColumn; - updateValues.mergeText = sanitizeRichText(data.merge.mergeText); - } - } - - if (Object.keys(updateValues).length > 1) { - await tx - .update(executiveMeetingsTable) - .set(updateValues) - .where(eq(executiveMeetingsTable.id, id)); - } - let attendees = existing.attendees; - if (data.attendees !== undefined) { - await tx - .delete(executiveMeetingAttendeesTable) - .where(eq(executiveMeetingAttendeesTable.meetingId, id)); - if (data.attendees.length > 0) { - await tx.insert(executiveMeetingAttendeesTable).values( - data.attendees.map((a, idx) => ({ - meetingId: id, - name: sanitizeRichText(a.name), - title: a.title ?? null, - attendanceType: a.attendanceType, - sortOrder: a.sortOrder ?? idx, - })), - ); - } - attendees = (await db - .select() - .from(executiveMeetingAttendeesTable) - .where(eq(executiveMeetingAttendeesTable.meetingId, id)) - .orderBy(asc(executiveMeetingAttendeesTable.sortOrder))); - } - await logAudit(tx, { - action: "update", - entityType: "meeting", - entityId: id, - oldValue: existing, - newValue: { ...existing, ...updateValues, attendees }, - performedBy: userId, - }); - }); - const updated = await fetchMeetingWithAttendees(id); - void emitExecutiveMeetingsDayChanged( - updated?.meetingDate ?? existing.meetingDate, - ); - res.json(updated); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("executive_meetings_date_number_unique")) { - res - .status(409) - .json({ - error: "Daily number already used for this date", - code: "duplicate_number", - }); - return; - } - res.status(500).json({ error: "Could not update meeting", code: "update_failed" }); - } - }, -); - -router.delete( - "/executive-meetings/:id", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const existing = await fetchMeetingWithAttendees(id); - if (!existing) { - res.status(404).json({ error: "Meeting not found", code: "not_found" }); - return; - } - const userId = req.session.userId!; - await db.transaction(async (tx) => { - await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id)); - await logAudit(tx, { - action: "delete", - entityType: "meeting", - entityId: id, - oldValue: existing, - performedBy: userId, - }); - }); - res.status(204).end(); - }, -); - -router.put( - "/executive-meetings/:id/attendees", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const existing = await fetchMeetingWithAttendees(id); - if (!existing) { - res.status(404).json({ error: "Meeting not found", code: "not_found" }); - return; - } - const data = parseBody(res, attendeesPutSchema, req.body); - if (!data) return; - const userId = req.session.userId!; - await db.transaction(async (tx) => { - await tx - .delete(executiveMeetingAttendeesTable) - .where(eq(executiveMeetingAttendeesTable.meetingId, id)); - if (data.attendees.length > 0) { - await tx.insert(executiveMeetingAttendeesTable).values( - data.attendees.map((a, idx) => ({ - meetingId: id, - name: sanitizeRichText(a.name), - title: a.title ?? null, - attendanceType: a.attendanceType, - sortOrder: a.sortOrder ?? idx, - })), - ); - } - await logAudit(tx, { - action: "replace_attendees", - entityType: "meeting", - entityId: id, - oldValue: { attendees: existing.attendees }, - newValue: { attendees: data.attendees }, - performedBy: userId, - }); - }); - const updated = await fetchMeetingWithAttendees(id); - res.json(updated); - }, -); - -router.post( - "/executive-meetings/:id/duplicate", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const data = parseBody(res, duplicateSchema, req.body); - if (!data) return; - const source = await fetchMeetingWithAttendees(id); - if (!source) { - res.status(404).json({ error: "Meeting not found", code: "not_found" }); - return; - } - const userId = req.session.userId!; - try { - const inserted = await db.transaction(async (tx) => { - const nextNumber = await nextDailyNumber(tx, data.targetDate); - const meetingValues: typeof executiveMeetingsTable.$inferInsert = { - dailyNumber: nextNumber, - // Re-sanitize on duplicate so a row that was somehow stored with - // disallowed markup (e.g. raw SQL import, schema regression) does - // not get cloned into a fresh meeting unchecked. - titleAr: sanitizeRichText(source.titleAr), - titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn, - meetingDate: data.targetDate, - startTime: source.startTime, - endTime: source.endTime, - location: source.location, - meetingUrl: source.meetingUrl, - platform: source.platform, - status: "scheduled", - isHighlighted: 0, - notes: source.notes, - attachments: source.attachments, - createdBy: userId, - updatedBy: userId, - }; - const [meeting] = await tx - .insert(executiveMeetingsTable) - .values(meetingValues) - .returning(); - if (!meeting) throw new Error("duplicate_failed"); - if (source.attendees && source.attendees.length > 0) { - await tx.insert(executiveMeetingAttendeesTable).values( - source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({ - meetingId: meeting.id, - // source already-stored values are sanitized at write time, but - // re-running keeps a defense-in-depth boundary if anything was - // imported via raw SQL. - name: sanitizeRichText(a.name), - title: a.title, - attendanceType: a.attendanceType, - sortOrder: a.sortOrder ?? idx, - })), - ); - } - await logAudit(tx, { - action: "duplicate", - entityType: "meeting", - entityId: meeting.id, - oldValue: { sourceId: source.id, sourceDate: source.meetingDate }, - newValue: meeting, - performedBy: userId, - }); - return meeting; - }); - const full = await fetchMeetingWithAttendees(inserted.id); - res.status(201).json(full); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("executive_meetings_date_number_unique")) { - res.status(409).json({ error: "Daily number conflict", code: "conflict" }); - return; - } - res.status(500).json({ error: msg, code: "server_error" }); - } - }, -); - -// ===================================================================== -// REORDER (within a single day) -// ===================================================================== - -router.post( - "/executive-meetings/reorder", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const data = parseBody(res, reorderSchema, req.body); - if (!data) return; - const userId = req.session.userId!; - const dedup = new Set(data.orderedIds); - if (dedup.size !== data.orderedIds.length) { - res - .status(400) - .json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" }); - return; - } - try { - await db.transaction(async (tx) => { - const allRows = await tx - .select() - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, data.meetingDate)); - if (allRows.length !== data.orderedIds.length) { - throw Object.assign(new Error("incomplete_day_reorder"), { - httpStatus: 400, - code: "incomplete_day", - }); - } - const dayIds = new Set(allRows.map((r) => r.id)); - for (const id of data.orderedIds) { - if (!dayIds.has(id)) { - throw Object.assign(new Error("invalid_or_cross_day_id"), { - httpStatus: 400, - code: "invalid_ids", - }); - } - } - const slots = allRows - .slice() - .sort((a, b) => { - const aHasTime = a.startTime != null; - const bHasTime = b.startTime != null; - if (aHasTime && bHasTime) { - if (a.startTime! < b.startTime!) return -1; - if (a.startTime! > b.startTime!) return 1; - return a.dailyNumber - b.dailyNumber; - } - if (aHasTime) return -1; - if (bHasTime) return 1; - return a.dailyNumber - b.dailyNumber; - }) - .map((r) => ({ startTime: r.startTime, endTime: r.endTime })); - for (let i = 0; i < data.orderedIds.length; i++) { - const id = data.orderedIds[i]!; - await tx - .update(executiveMeetingsTable) - .set({ dailyNumber: -(i + 1), updatedBy: userId }) - .where(eq(executiveMeetingsTable.id, id)); - } - for (let i = 0; i < data.orderedIds.length; i++) { - const id = data.orderedIds[i]!; - const slot = slots[i]!; - await tx - .update(executiveMeetingsTable) - .set({ - startTime: slot.startTime, - endTime: slot.endTime, - dailyNumber: i + 1, - updatedBy: userId, - }) - .where(eq(executiveMeetingsTable.id, id)); - } - await logAudit(tx, { - action: "executive_meeting.reorder", - entityType: "meeting", - entityId: data.orderedIds[0]!, - oldValue: { - meetingDate: data.meetingDate, - order: allRows - .slice() - .sort((a, b) => a.dailyNumber - b.dailyNumber) - .map((r) => r.id), - }, - newValue: { - meetingDate: data.meetingDate, - order: data.orderedIds, - }, - performedBy: userId, - }); - const updated = await tx - .select() - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, data.meetingDate)); - const seen = new Set(); - for (const u of updated) { - if (seen.has(u.dailyNumber)) { - throw new Error("duplicate_daily_number_after_reorder"); - } - seen.add(u.dailyNumber); - } - }); - // Return the updated meetings in the new order, with attendees. - const idToFetch = data.orderedIds; - const fullList = await Promise.all( - idToFetch.map((id) => fetchMeetingWithAttendees(id)), - ); - res.json({ meetings: fullList.filter(Boolean) }); - } catch (err) { - const status = (err as { httpStatus?: number }).httpStatus ?? 500; - const code = (err as { code?: string }).code ?? "server_error"; - const msg = err instanceof Error ? err.message : String(err); - res.status(status).json({ error: msg, code }); - } - }, -); - -// ===================================================================== -// REQUESTS -// ===================================================================== - -const TIME_RE_LOCAL = /^\d{2}:\d{2}(:\d{2})?$/; -const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/; - -function validateApplyPayload( - reqRow: typeof executiveMeetingRequestsTable.$inferSelect, -): string | null { - const details = (reqRow.requestDetails ?? {}) as Record; - if (reqRow.requestType === "reschedule") { - const st = typeof details.startTime === "string" ? details.startTime : null; - const et = typeof details.endTime === "string" ? details.endTime : null; - const md = typeof details.meetingDate === "string" ? details.meetingDate : null; - if (st && !TIME_RE_LOCAL.test(st)) return "startTime invalid"; - if (et && !TIME_RE_LOCAL.test(et)) return "endTime invalid"; - if (md && !DATE_RE_LOCAL.test(md)) return "meetingDate invalid"; - if (st && et && st > et) return "startTime must be <= endTime"; - } - return null; -} - -async function applyApprovedRequest( - tx: DbExecutor, - reqRow: typeof executiveMeetingRequestsTable.$inferSelect, - performedBy: number, -): Promise { - const details = (reqRow.requestDetails ?? {}) as Record; - if (!reqRow.meetingId) return; - const meetingId = reqRow.meetingId; - const [existing] = await tx - .select() - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.id, meetingId)); - if (!existing) return; - - const update: Partial = { - updatedBy: performedBy, - }; - switch (reqRow.requestType) { - case "reschedule": { - if (typeof details.startTime === "string" && TIME_RE_LOCAL.test(details.startTime)) - update.startTime = details.startTime; - if (typeof details.endTime === "string" && TIME_RE_LOCAL.test(details.endTime)) - update.endTime = details.endTime; - if (typeof details.meetingDate === "string" && DATE_RE_LOCAL.test(details.meetingDate)) - update.meetingDate = details.meetingDate; - const effStart = update.startTime ?? existing.startTime; - const effEnd = update.endTime ?? existing.endTime; - if (effStart && effEnd && effStart > effEnd) { - throw new Error("APPLY_VALIDATION:startTime must be <= endTime"); - } - break; - } - case "change_location": { - if (typeof details.location === "string") - update.location = details.location.slice(0, 300); - if (typeof details.meetingUrl === "string") - update.meetingUrl = details.meetingUrl.slice(0, 1000); - if ( - typeof details.platform === "string" && - (PLATFORMS as readonly string[]).includes(details.platform) - ) - update.platform = details.platform; - break; - } - case "cancel_meeting": - update.status = "cancelled"; - break; - case "highlight": - update.isHighlighted = 1; - break; - case "unhighlight": - update.isHighlighted = 0; - break; - case "note": { - const newNote = typeof details.note === "string" ? details.note.slice(0, 5000) : ""; - const prev = existing.notes ?? ""; - update.notes = prev ? `${prev}\n${newNote}` : newNote; - break; - } - case "add_attendee": { - const rawName = typeof details.name === "string" ? details.name.trim() : ""; - if (!rawName) break; - // Same sanitization boundary as the direct attendee POST/PUT paths so - // an approved request cannot smuggle script/event-handler markup into - // the rich-text attendee.name column. - const name = sanitizeRichText(rawName.slice(0, 5000)); - if (!name) break; - const title = typeof details.title === "string" ? details.title.slice(0, 200) : null; - const attendanceType = - typeof details.attendanceType === "string" && - (ATTENDANCE_TYPES as readonly string[]).includes(details.attendanceType) - ? (details.attendanceType as (typeof ATTENDANCE_TYPES)[number]) - : "internal"; - const [{ value: maxOrder }] = await tx - .select({ - value: sql`coalesce(max(${executiveMeetingAttendeesTable.sortOrder}), -1)`, - }) - .from(executiveMeetingAttendeesTable) - .where(eq(executiveMeetingAttendeesTable.meetingId, meetingId)); - await tx.insert(executiveMeetingAttendeesTable).values({ - meetingId, - name, - title, - attendanceType, - sortOrder: (maxOrder ?? -1) + 1, - }); - break; - } - case "remove_attendee": { - const attendeeId = Number(details.attendeeId); - if (Number.isInteger(attendeeId) && attendeeId > 0) { - await tx - .delete(executiveMeetingAttendeesTable) - .where( - and( - eq(executiveMeetingAttendeesTable.id, attendeeId), - eq(executiveMeetingAttendeesTable.meetingId, meetingId), - ), - ); - } - break; - } - default: - break; - } - if (Object.keys(update).length > 1) { - await tx - .update(executiveMeetingsTable) - .set(update) - .where(eq(executiveMeetingsTable.id, meetingId)); - } -} - -router.get( - "/executive-meetings/requests", - requireExecutiveAccess, - async (req, res): Promise => { - const status = typeof req.query.status === "string" ? req.query.status : ""; - const mine = req.query.mine === "true" || req.query.mine === "1"; - const dateFromRaw = - typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : ""; - const dateToRaw = - typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : ""; - const requesterRaw = Number(req.query.requester); - const userId = req.session.userId!; - - const names = await getRoleNamesForUser(userId); - const canSeeAllRequests = - APPROVE_ROLES.some((r) => names.has(r)) || - ADMIN_AUDIT_ROLES.some((r) => names.has(r)); - - const conds: SQL[] = []; - if (status) conds.push(eq(executiveMeetingRequestsTable.status, status)); - if (mine || !canSeeAllRequests) { - conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId)); - } else if (Number.isInteger(requesterRaw) && requesterRaw > 0) { - // Approvers may filter by a specific requester; non-approvers ignored. - conds.push(eq(executiveMeetingRequestsTable.requestedBy, requesterRaw)); - } - if (dateFromRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateFromRaw)) { - conds.push( - gte( - executiveMeetingRequestsTable.createdAt, - new Date(`${dateFromRaw}T00:00:00.000Z`), - ), - ); - } - if (dateToRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateToRaw)) { - const end = new Date(`${dateToRaw}T00:00:00.000Z`); - end.setUTCDate(end.getUTCDate() + 1); - conds.push(lt(executiveMeetingRequestsTable.createdAt, end)); - } - const where = conds.length > 0 ? and(...conds) : undefined; - - const rows = await db - .select({ - r: executiveMeetingRequestsTable, - requesterUsername: usersTable.username, - requesterDisplayNameAr: usersTable.displayNameAr, - requesterDisplayNameEn: usersTable.displayNameEn, - }) - .from(executiveMeetingRequestsTable) - .leftJoin(usersTable, eq(usersTable.id, executiveMeetingRequestsTable.requestedBy)) - .where(where) - .orderBy(desc(executiveMeetingRequestsTable.createdAt)) - .limit(200); - - res.json({ - requests: rows.map((row) => ({ - ...row.r, - requester: row.requesterUsername - ? { - username: row.requesterUsername, - displayNameAr: row.requesterDisplayNameAr, - displayNameEn: row.requesterDisplayNameEn, - } - : null, - })), - }); - }, -); - -router.post( - "/executive-meetings/requests", - requireExecutiveAccess, - requireRequest, - async (req, res): Promise => { - const data = parseBody(res, requestCreateSchema, req.body); - if (!data) return; - let meetingId = data.meetingId ?? null; - if (meetingId !== null) { - const [m] = await db - .select({ id: executiveMeetingsTable.id }) - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.id, meetingId)); - if (!m) { - res.status(404).json({ error: "Meeting not found", code: "not_found" }); - return; - } - } - const userId = req.session.userId!; - const result = await db.transaction(async (tx) => { - const insertValues: typeof executiveMeetingRequestsTable.$inferInsert = { - meetingId, - requestedBy: userId, - requestType: data.requestType, - requestDetails: data.requestDetails, - status: "new", - }; - const [row] = await tx - .insert(executiveMeetingRequestsTable) - .values(insertValues) - .returning(); - await logAudit(tx, { - action: "submit", - entityType: "request", - entityId: row!.id, - newValue: row!, - performedBy: userId, - }); - const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES); - const requesterDisplay = await getUserDisplayForNotify(userId); - const recipients = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: approverIds, - meetingId: row!.meetingId, - notificationType: "request_submitted", - titleAr: "طلب جديد بانتظار المراجعة", - titleEn: "New executive-meeting request awaiting review", - bodyAr: requesterDisplay - ? `${requesterDisplay.ar} — ${row!.requestType}` - : row!.requestType, - bodyEn: requesterDisplay - ? `${requesterDisplay.en} — ${row!.requestType}` - : row!.requestType, - relatedType: "executive_meeting_request", - relatedId: row!.id, - excludeUserId: userId, - }); - return { row: row!, recipients }; - }); - void broadcastExecutiveMeetingNotifications( - result.recipients, - "request_submitted", - result.row.meetingId, - ); - void sendExecutiveMeetingEmail( - result.recipients, - { - ar: "طلب جديد بانتظار المراجعة", - en: "New executive-meeting request awaiting review", - }, - { - ar: `طلب من النوع ${result.row.requestType} بانتظار اعتمادك.`, - en: `A ${result.row.requestType} request is awaiting your review.`, - }, - "request_submitted", - ); - res.status(201).json(result.row); - }, -); - -router.post( - "/executive-meetings/:id/requests", - requireExecutiveAccess, - requireRequest, - async (req, res): Promise => { - const meetingId = Number(req.params.id); - if (!Number.isInteger(meetingId) || meetingId <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const [m] = await db - .select({ id: executiveMeetingsTable.id }) - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.id, meetingId)); - if (!m) { - res.status(404).json({ error: "Meeting not found", code: "not_found" }); - return; - } - const data = parseBody(res, requestNestedCreateSchema, req.body); - if (!data) return; - const userId = req.session.userId!; - const result = await db.transaction(async (tx) => { - const insertValues: typeof executiveMeetingRequestsTable.$inferInsert = { - meetingId, - requestedBy: userId, - requestType: data.requestType, - requestDetails: data.requestDetails, - status: "new", - }; - const [row] = await tx - .insert(executiveMeetingRequestsTable) - .values(insertValues) - .returning(); - await logAudit(tx, { - action: "submit", - entityType: "request", - entityId: row!.id, - newValue: row!, - performedBy: userId, - }); - const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES); - const requesterDisplay = await getUserDisplayForNotify(userId); - const recipients = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: approverIds, - meetingId: row!.meetingId, - notificationType: "request_submitted", - titleAr: "طلب جديد بانتظار المراجعة", - titleEn: "New executive-meeting request awaiting review", - bodyAr: requesterDisplay - ? `${requesterDisplay.ar} — ${row!.requestType}` - : row!.requestType, - bodyEn: requesterDisplay - ? `${requesterDisplay.en} — ${row!.requestType}` - : row!.requestType, - relatedType: "executive_meeting_request", - relatedId: row!.id, - excludeUserId: userId, - }); - return { row: row!, recipients }; - }); - void broadcastExecutiveMeetingNotifications( - result.recipients, - "request_submitted", - result.row.meetingId, - ); - void sendExecutiveMeetingEmail( - result.recipients, - { - ar: "طلب جديد بانتظار المراجعة", - en: "New executive-meeting request awaiting review", - }, - { - ar: `طلب من النوع ${result.row.requestType} بانتظار اعتمادك.`, - en: `A ${result.row.requestType} request is awaiting your review.`, - }, - "request_submitted", - ); - res.status(201).json(result.row); - }, -); - -router.patch( - "/executive-meetings/requests/:id", - requireExecutiveAccess, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const userId = req.session.userId!; - const [existing] = await db - .select() - .from(executiveMeetingRequestsTable) - .where(eq(executiveMeetingRequestsTable.id, id)); - if (!existing) { - res.status(404).json({ error: "Request not found", code: "not_found" }); - return; - } - const data = parseBody(res, requestReviewSchema, req.body); - if (!data) return; - const names = await getRoleNamesForUser(userId); - - if ("action" in data && data.action === "withdraw") { - if (existing.requestedBy !== userId) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); - return; - } - if (existing.status !== "new") { - res.status(409).json({ error: "Cannot withdraw", code: "bad_state" }); - return; - } - const updated = await db.transaction(async (tx) => { - const [row] = await tx - .update(executiveMeetingRequestsTable) - .set({ status: "withdrawn" }) - .where(eq(executiveMeetingRequestsTable.id, id)) - .returning(); - await logAudit(tx, { - action: "withdraw", - entityType: "request", - entityId: id, - oldValue: existing, - newValue: row, - performedBy: userId, - }); - return row; - }); - res.json(updated); - return; - } - - // Status-driven review path (approved | rejected | needs_edit) - const canApprove = APPROVE_ROLES.some((r) => names.has(r)); - if (!canApprove) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); - return; - } - if (existing.status !== "new") { - res.status(409).json({ error: "Already reviewed", code: "bad_state" }); - return; - } - const review = data as { - status: (typeof REQUEST_REVIEW_STATUSES)[number]; - reviewNotes?: string | null; - }; - - if (review.status === "approved") { - const validationError = validateApplyPayload(existing); - if (validationError) { - res.status(400).json({ error: validationError, code: "validation" }); - return; - } - } - - try { - const result = await db.transaction(async (tx) => { - const [row] = await tx - .update(executiveMeetingRequestsTable) - .set({ - status: review.status, - reviewedBy: userId, - reviewDecision: review.status, - reviewNotes: review.reviewNotes ?? null, - }) - .where( - and( - eq(executiveMeetingRequestsTable.id, id), - eq(executiveMeetingRequestsTable.status, "new"), - ), - ) - .returning(); - if (!row) throw new Error("RACE_ALREADY_REVIEWED"); - await logAudit(tx, { - action: review.status, - entityType: "request", - entityId: id, - oldValue: existing, - newValue: row, - performedBy: userId, - }); - const taskRecipients: number[] = []; - if (review.status === "approved") { - await applyApprovedRequest(tx, row, userId); - if (row.meetingId) { - await logAudit(tx, { - action: "apply", - entityType: "meeting", - entityId: row.meetingId, - oldValue: { fromRequestId: row.id }, - newValue: { requestType: row.requestType, details: row.requestDetails }, - performedBy: userId, - }); - } - const [task] = await tx - .insert(executiveMeetingTasksTable) - .values({ - taskType: `follow_up_${row.requestType}`, - meetingId: row.meetingId, - requestId: row.id, - assignedTo: row.assignedTo ?? null, - status: "pending", - notes: review.reviewNotes ?? null, - }) - .returning(); - await logAudit(tx, { - action: "create", - entityType: "task", - entityId: task!.id, - newValue: task!, - performedBy: userId, - }); - if (task && task.assignedTo) { - const r = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: [task.assignedTo], - meetingId: task.meetingId ?? row.meetingId, - notificationType: "task_assigned", - titleAr: "أُسندت إليك مهمة", - titleEn: "You were assigned a task", - bodyAr: task.taskType, - bodyEn: task.taskType, - relatedType: "executive_meeting_task", - relatedId: task.id, - excludeUserId: userId, - }); - taskRecipients.push(...r); - } - } - const requesterId = existing.requestedBy; - const reviewerDisplay = await getUserDisplayForNotify(userId); - let reviewRecipients: number[] = []; - if (requesterId && requesterId !== userId) { - reviewRecipients = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: [requesterId], - meetingId: row.meetingId, - notificationType: `request_${review.status}`, - titleAr: - review.status === "approved" - ? "تم اعتماد طلبك" - : review.status === "rejected" - ? "رُفض طلبك" - : "طلبك يحتاج تعديلاً", - titleEn: - review.status === "approved" - ? "Your request was approved" - : review.status === "rejected" - ? "Your request was rejected" - : "Your request needs edits", - bodyAr: reviewerDisplay - ? `${reviewerDisplay.ar} — ${row.requestType}` - : row.requestType, - bodyEn: reviewerDisplay - ? `${reviewerDisplay.en} — ${row.requestType}` - : row.requestType, - relatedType: "executive_meeting_request", - relatedId: row.id, - excludeUserId: userId, - }); - } - return { row, reviewRecipients, taskRecipients }; - }); - void broadcastExecutiveMeetingNotifications( - result.reviewRecipients, - `request_${review.status}`, - result.row.meetingId, - ); - if (result.taskRecipients.length > 0) { - void broadcastExecutiveMeetingNotifications( - result.taskRecipients, - "task_assigned", - result.row.meetingId, - ); - } - res.json(result.row); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg === "RACE_ALREADY_REVIEWED") { - res.status(409).json({ error: "Already reviewed", code: "bad_state" }); - return; - } - if (msg.startsWith("APPLY_VALIDATION:")) { - res - .status(400) - .json({ error: msg.slice("APPLY_VALIDATION:".length), code: "validation" }); - return; - } - res.status(500).json({ error: msg, code: "server_error" }); - } - }, -); - -// ===================================================================== -// TASKS -// ===================================================================== - -router.get( - "/executive-meetings/tasks", - requireExecutiveAccess, - requireTaskView, - async (req, res): Promise => { - const status = typeof req.query.status === "string" ? req.query.status : ""; - const mine = req.query.mine === "true" || req.query.mine === "1"; - const meetingIdRaw = typeof req.query.meetingId === "string" ? req.query.meetingId : ""; - const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - const dateFromRaw = typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : ""; - const dateToRaw = typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : ""; - const assigneeRaw = Number(req.query.assigneeId); - const userId = req.session.userId!; - const names = await getRoleNamesForUser(userId); - const hasBroadView = TASK_BROAD_VIEW_ROLES.some((r) => names.has(r)); - const conds: SQL[] = []; - if (status && (TASK_STATUSES as readonly string[]).includes(status)) { - conds.push(eq(executiveMeetingTasksTable.status, status)); - } - // Plain coordinators (no broad-view role) are hard-scoped to their own - // assignments — any `mine`/`assigneeId` query parameters they submit are - // ignored and replaced with `assignedTo = currentUserId`. - if (!hasBroadView) { - conds.push(eq(executiveMeetingTasksTable.assignedTo, userId)); - } else { - if (mine) { - conds.push(eq(executiveMeetingTasksTable.assignedTo, userId)); - } - if (Number.isInteger(assigneeRaw) && assigneeRaw > 0) { - conds.push(eq(executiveMeetingTasksTable.assignedTo, assigneeRaw)); - } - } - if (meetingIdRaw) { - const n = Number(meetingIdRaw); - if (Number.isInteger(n) && n > 0) { - conds.push(eq(executiveMeetingTasksTable.meetingId, n)); - } - } - if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { - const start = new Date(`${dateRaw}T00:00:00.000Z`); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); - conds.push(gte(executiveMeetingTasksTable.createdAt, start)); - conds.push(lt(executiveMeetingTasksTable.createdAt, end)); - } else { - if (dateFromRaw && DATE_RE_LOCAL.test(dateFromRaw)) { - conds.push( - gte( - executiveMeetingTasksTable.createdAt, - new Date(`${dateFromRaw}T00:00:00.000Z`), - ), - ); - } - if (dateToRaw && DATE_RE_LOCAL.test(dateToRaw)) { - const end = new Date(`${dateToRaw}T00:00:00.000Z`); - end.setUTCDate(end.getUTCDate() + 1); - conds.push(lt(executiveMeetingTasksTable.createdAt, end)); - } - } - const where = conds.length > 0 ? and(...conds) : undefined; - const rows = await db - .select({ - t: executiveMeetingTasksTable, - assigneeUsername: usersTable.username, - assigneeDisplayNameAr: usersTable.displayNameAr, - assigneeDisplayNameEn: usersTable.displayNameEn, - }) - .from(executiveMeetingTasksTable) - .leftJoin(usersTable, eq(usersTable.id, executiveMeetingTasksTable.assignedTo)) - .where(where) - .orderBy( - asc(executiveMeetingTasksTable.status), - desc(executiveMeetingTasksTable.createdAt), - ) - .limit(200); - res.json({ - tasks: rows.map((row) => ({ - ...row.t, - assignee: row.assigneeUsername - ? { - username: row.assigneeUsername, - displayNameAr: row.assigneeDisplayNameAr, - displayNameEn: row.assigneeDisplayNameEn, - } - : null, - })), - }); - }, -); - -router.post( - "/executive-meetings/tasks", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const data = parseBody(res, taskCreateSchema, req.body); - if (!data) return; - const userId = req.session.userId!; - const result = await db.transaction(async (tx) => { - const insertValues: typeof executiveMeetingTasksTable.$inferInsert = { - taskType: data.taskType, - meetingId: data.meetingId ?? null, - requestId: data.requestId ?? null, - assignedTo: data.assignedTo ?? null, - dueAt: data.dueAt ? new Date(data.dueAt) : null, - notes: data.notes ?? null, - status: "pending", - }; - const [row] = await tx - .insert(executiveMeetingTasksTable) - .values(insertValues) - .returning(); - await logAudit(tx, { - action: "create", - entityType: "task", - entityId: row!.id, - newValue: row!, - performedBy: userId, - }); - let recipients: number[] = []; - if (row?.assignedTo) { - recipients = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: [row.assignedTo], - meetingId: row.meetingId, - notificationType: "task_assigned", - titleAr: "أُسندت إليك مهمة", - titleEn: "You were assigned a task", - bodyAr: row.taskType, - bodyEn: row.taskType, - relatedType: "executive_meeting_task", - relatedId: row.id, - excludeUserId: userId, - }); - } - return { row: row!, recipients }; - }); - if (result.recipients.length > 0) { - void broadcastExecutiveMeetingNotifications( - result.recipients, - "task_assigned", - result.row.meetingId, - ); - } - res.status(201).json(result.row); - }, -); - -router.patch( - "/executive-meetings/tasks/:id", - requireExecutiveAccess, - requireTaskView, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const userId = req.session.userId!; - const [existing] = await db - .select() - .from(executiveMeetingTasksTable) - .where(eq(executiveMeetingTasksTable.id, id)); - if (!existing) { - res.status(404).json({ error: "Task not found", code: "not_found" }); - return; - } - const names = await getRoleNamesForUser(userId); - const isMutator = MUTATE_ROLES.some((r) => names.has(r)); - const isAssignee = existing.assignedTo === userId; - if (!isMutator && !isAssignee) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); - return; - } - const data = parseBody(res, taskPatchSchema, req.body); - if (!data) return; - const update: Partial = {}; - if (data.status !== undefined) { - update.status = data.status; - if (data.status === "completed") update.completedAt = new Date(); - else if (existing.status === "completed") update.completedAt = null; - } - if (isMutator) { - if (data.taskType !== undefined) update.taskType = data.taskType; - if (data.assignedTo !== undefined) update.assignedTo = data.assignedTo; - if (data.dueAt !== undefined) - update.dueAt = data.dueAt && data.dueAt !== "" ? new Date(data.dueAt) : null; - if (data.notes !== undefined) update.notes = data.notes; - if (data.meetingId !== undefined) update.meetingId = data.meetingId; - } - if (Object.keys(update).length === 0) { - res.json(existing); - return; - } - const result = await db.transaction(async (tx) => { - const [row] = await tx - .update(executiveMeetingTasksTable) - .set(update) - .where(eq(executiveMeetingTasksTable.id, id)) - .returning(); - await logAudit(tx, { - action: "update", - entityType: "task", - entityId: id, - oldValue: existing, - newValue: row, - performedBy: userId, - }); - const reassignRecipients: number[] = []; - const completionRecipients: number[] = []; - // Reassignment → notify the new assignee. - if ( - row && - row.assignedTo && - row.assignedTo !== existing.assignedTo - ) { - const r = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: [row.assignedTo], - meetingId: row.meetingId, - notificationType: "task_assigned", - titleAr: "أُسندت إليك مهمة", - titleEn: "You were assigned a task", - bodyAr: row.taskType, - bodyEn: row.taskType, - relatedType: "executive_meeting_task", - relatedId: row.id, - excludeUserId: userId, - }); - reassignRecipients.push(...r); - } - if ( - row && - row.status === "completed" && - existing.status !== "completed" - ) { - // Notify the original requester (if the task came from a request) - let requesterId: number | null = null; - if (row.requestId) { - const [reqOld] = await tx - .select() - .from(executiveMeetingRequestsTable) - .where(eq(executiveMeetingRequestsTable.id, row.requestId)); - requesterId = reqOld?.requestedBy ?? null; - if (reqOld && reqOld.status !== "done") { - const [reqNew] = await tx - .update(executiveMeetingRequestsTable) - .set({ status: "done" }) - .where(eq(executiveMeetingRequestsTable.id, row.requestId)) - .returning(); - await logAudit(tx, { - action: "done", - entityType: "request", - entityId: row.requestId, - oldValue: reqOld, - newValue: reqNew, - performedBy: userId, - }); - } - } - const completionTargets = Array.from( - new Set( - [requesterId, existing.assignedTo].filter( - (id): id is number => typeof id === "number" && id > 0, - ), - ), - ); - if (completionTargets.length > 0) { - const r = await recordExecutiveMeetingNotifications(tx, { - recipientUserIds: completionTargets, - meetingId: row.meetingId, - notificationType: "task_completed", - titleAr: "اكتملت مهمة", - titleEn: "A task was completed", - bodyAr: row.taskType, - bodyEn: row.taskType, - relatedType: "executive_meeting_task", - relatedId: row.id, - excludeUserId: userId, - }); - completionRecipients.push(...r); - } - } - return { row, reassignRecipients, completionRecipients }; - }); - if (result.reassignRecipients.length > 0) { - void broadcastExecutiveMeetingNotifications( - result.reassignRecipients, - "task_assigned", - result.row?.meetingId ?? null, - ); - } - if (result.completionRecipients.length > 0) { - void broadcastExecutiveMeetingNotifications( - result.completionRecipients, - "task_completed", - result.row?.meetingId ?? null, - ); - } - res.json(result.row); - }, -); - -router.delete( - "/executive-meetings/tasks/:id", - requireExecutiveAccess, - requireMutate, - async (req, res): Promise => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) { - res.status(404).json({ error: "Not found", code: "not_found" }); - return; - } - const userId = req.session.userId!; - const [existing] = await db - .select() - .from(executiveMeetingTasksTable) - .where(eq(executiveMeetingTasksTable.id, id)); - if (!existing) { - res.status(404).json({ error: "Task not found", code: "not_found" }); - return; - } - await db.transaction(async (tx) => { - await tx - .delete(executiveMeetingTasksTable) - .where(eq(executiveMeetingTasksTable.id, id)); - await logAudit(tx, { - action: "delete", - entityType: "task", - entityId: id, - oldValue: existing, - performedBy: userId, - }); - }); - res.status(204).end(); - }, -); - -// ===================================================================== -// AUDIT LOGS -// ===================================================================== - -router.get( - "/executive-meetings/audit-logs", - requireExecutiveAccess, - requireAdminAudit, - async (req, res): Promise => { - const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - const dateFromRaw = - typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : ""; - const dateToRaw = - typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : ""; - const entityType = - typeof req.query.entityType === "string" ? req.query.entityType.trim() : ""; - const action = - typeof req.query.action === "string" ? req.query.action.trim() : ""; - const actorRaw = Number(req.query.actorId); - const actorId = Number.isInteger(actorRaw) && actorRaw > 0 ? actorRaw : null; - const limitRaw = Number(req.query.limit); - const limit = - Number.isFinite(limitRaw) && limitRaw > 0 - ? Math.min(Math.floor(limitRaw), 200) - : 50; - const offsetRaw = Number(req.query.offset); - const offset = - Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0; - const conds: SQL[] = []; - if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { - const start = new Date(`${dateRaw}T00:00:00.000Z`); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); - conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start)); - conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end)); - } - if (dateFromRaw && DATE_RE_LOCAL.test(dateFromRaw)) { - conds.push( - gte( - executiveMeetingAuditLogsTable.performedAt, - new Date(`${dateFromRaw}T00:00:00.000Z`), - ), - ); - } - if (dateToRaw && DATE_RE_LOCAL.test(dateToRaw)) { - const end = new Date(`${dateToRaw}T00:00:00.000Z`); - end.setUTCDate(end.getUTCDate() + 1); - conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end)); - } - if (entityType) conds.push(eq(executiveMeetingAuditLogsTable.entityType, entityType)); - if (action) conds.push(eq(executiveMeetingAuditLogsTable.action, action)); - if (actorId) conds.push(eq(executiveMeetingAuditLogsTable.performedBy, actorId)); - const where = conds.length > 0 ? and(...conds) : undefined; - const [{ count: totalRaw } = { count: 0 }] = await db - .select({ count: sql`count(*)::int` }) - .from(executiveMeetingAuditLogsTable) - .where(where); - const total = Number(totalRaw) || 0; - const rows = await db - .select({ - l: executiveMeetingAuditLogsTable, - actorUsername: usersTable.username, - actorDisplayNameAr: usersTable.displayNameAr, - actorDisplayNameEn: usersTable.displayNameEn, - }) - .from(executiveMeetingAuditLogsTable) - .leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy)) - .where(where) - .orderBy(desc(executiveMeetingAuditLogsTable.performedAt)) - .limit(limit) - .offset(offset); - res.json({ - entries: rows.map((row) => ({ - ...row.l, - actor: row.actorUsername - ? { - username: row.actorUsername, - displayNameAr: row.actorDisplayNameAr, - displayNameEn: row.actorDisplayNameEn, - } - : null, - })), - total, - limit, - offset, - hasMore: offset + rows.length < total, - }); - }, -); - -// ===================================================================== -// NOTIFICATIONS (placeholder list — visible to any executive read role) -// ===================================================================== - -router.get( - "/executive-meetings/notifications", - requireExecutiveAccess, - async (req, res): Promise => { - // Optional ?date=YYYY-MM-DD filter joins through the meetings table so - // the schedule's "selected date" can scope the notifications view, as - // required by Phase 2. - const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - const conds: SQL[] = []; - let meetingIdsForDate: number[] | null = null; - if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { - const meetingsOnDate = await db - .select({ id: executiveMeetingsTable.id }) - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, dateRaw)); - meetingIdsForDate = meetingsOnDate.map((m) => m.id); - if (meetingIdsForDate.length === 0) { - res.json({ notifications: [] }); - return; - } - conds.push( - inArray(executiveMeetingNotificationsTable.meetingId, meetingIdsForDate), - ); - } - const where = conds.length > 0 ? and(...conds) : undefined; - const rows = await db - .select({ - n: executiveMeetingNotificationsTable, - userUsername: usersTable.username, - userDisplayNameAr: usersTable.displayNameAr, - userDisplayNameEn: usersTable.displayNameEn, - }) - .from(executiveMeetingNotificationsTable) - .leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId)) - .where(where) - .orderBy(desc(executiveMeetingNotificationsTable.createdAt)) - .limit(200); - res.json({ - notifications: rows.map((row) => ({ - ...row.n, - user: row.userUsername - ? { - username: row.userUsername, - displayNameAr: row.userDisplayNameAr, - displayNameEn: row.userDisplayNameEn, - } - : null, - })), - }); - }, -); - -// ===================================================================== -// PDF GENERATION (server-side) -// ===================================================================== - -// Resolve the user's effective font preferences exactly the way the -// frontend does: user-scope row wins, otherwise global, otherwise the -// schema defaults. -async function resolveFontPrefsForUser(userId: number): Promise { - const rows = await db - .select() - .from(executiveMeetingFontSettingsTable) - .where( - or( - eq(executiveMeetingFontSettingsTable.scope, "global"), - and( - eq(executiveMeetingFontSettingsTable.scope, "user"), - eq(executiveMeetingFontSettingsTable.userId, userId), - ), - ), - ); - const userRow = rows.find((r) => r.scope === "user" && r.userId === userId); - const globalRow = rows.find((r) => r.scope === "global"); - const eff = userRow ?? globalRow; - return { - fontFamily: eff?.fontFamily ?? "system", - fontSize: eff?.fontSize ?? 14, - fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular", - alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start", - }; -} - -// Upload bytes to object storage by signing a PUT URL and streaming the -// buffer through `fetch`. Returns the canonical /objects/ path that -// GET /api/storage/objects/* knows how to serve. -async function uploadPdfToStorage(buf: Buffer): Promise { - const objectStorage = new ObjectStorageService(); - const uploadUrl = await objectStorage.getObjectEntityUploadURL(); - const putRes = await fetch(uploadUrl, { - method: "PUT", - body: buf, - headers: { - "Content-Type": "application/pdf", - "Content-Length": String(buf.byteLength), - }, - }); - if (!putRes.ok) { - const body = await putRes.text().catch(() => ""); - throw new Error( - `Failed to upload PDF to object storage (${putRes.status}): ${body.slice(0, 200)}`, - ); - } - // Storage returns the signed URL we PUT to — normalize back to - // /objects/ so we can serve it via GET /api/storage/objects/*. - return objectStorage.normalizeObjectEntityPath(uploadUrl.split("?")[0]!); -} - -router.get( - "/executive-meetings/pdf", - requireExecutiveAccess, - async (req, res): Promise => { - const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - if (!dateRaw || !DATE_RE_LOCAL.test(dateRaw)) { - res - .status(400) - .json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" }); - return; - } - const lang: "ar" | "en" = - typeof req.query.lang === "string" && req.query.lang === "en" ? "en" : "ar"; - const userId = req.session.userId!; - - const meetings = await db - .select() - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, dateRaw)) - .orderBy(asc(executiveMeetingsTable.dailyNumber)); - let attendeesByMeeting = new Map(); - if (meetings.length > 0) { - const ids = meetings.map((m) => m.id); - const attendees = await db - .select() - .from(executiveMeetingAttendeesTable) - .where(inArray(executiveMeetingAttendeesTable.meetingId, ids)) - .orderBy( - asc(executiveMeetingAttendeesTable.meetingId), - asc(executiveMeetingAttendeesTable.sortOrder), - ); - for (const a of attendees) { - const arr = attendeesByMeeting.get(a.meetingId) ?? []; - arr.push(a); - attendeesByMeeting.set(a.meetingId, arr); - } - } - - const font = await resolveFontPrefsForUser(userId); - const labels = - lang === "ar" - ? { - title: "جدول الاجتماعات التنفيذية", - no: "م", - meeting: "الاجتماع", - attendees: "الحضور", - time: "الوقت", - none: "لا توجد اجتماعات في هذا اليوم.", - } - : { - title: "Executive Meetings Schedule", - no: "#", - meeting: "Meeting", - attendees: "Attendees", - time: "Time", - none: "No meetings on this day.", - }; - - let pdf: Buffer; - try { - pdf = await renderSchedulePdf({ - date: dateRaw, - lang, - font, - labels, - meetings: meetings.map((m) => ({ - dailyNumber: m.dailyNumber, - titleAr: m.titleAr, - titleEn: m.titleEn, - startTime: m.startTime, - endTime: m.endTime, - location: m.location, - isHighlighted: m.isHighlighted ?? 0, - attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({ - name: a.name, - title: a.title, - attendanceType: a.attendanceType, - })), - })), - }); - } catch (err) { - // Fail loudly so callers see a real error instead of a corrupt PDF. - console.error("[executive-meetings] PDF render failed", err); - res.status(500).json({ - error: "Failed to generate PDF", - code: "pdf_render_failed", - detail: err instanceof Error ? err.message : String(err), - }); - return; - } - - // Upload + archive. If storage upload fails (e.g. sidecar offline) - // we still serve the PDF inline so the user is never blocked, but - // we record the archive with a synthetic path so the failure is - // visible in the audit trail. - let storagePath = `inline:${dateRaw}`; - try { - storagePath = await uploadPdfToStorage(pdf); - } catch (err) { - console.error("[executive-meetings] PDF upload failed; serving inline", err); - } - - try { - await db.transaction(async (tx) => { - const [{ value: maxV }] = await tx - .select({ - value: sql`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`, - }) - .from(executiveMeetingPdfArchivesTable) - .where(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw)); - const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = { - archiveDate: dateRaw, - filePath: storagePath, - version: (maxV ?? 0) + 1, - byteSize: pdf.byteLength, - generatedBy: userId, - }; - const [row] = await tx - .insert(executiveMeetingPdfArchivesTable) - .values(insertValues) - .returning(); - await logAudit(tx, { - action: "create", - entityType: "pdf_archive", - entityId: row!.id, - newValue: row!, - performedBy: userId, - }); - }); - } catch (err) { - console.error("[executive-meetings] PDF archive insert failed", err); - } - - res.setHeader("Content-Type", "application/pdf"); - res.setHeader( - "Content-Disposition", - `attachment; filename="executive-meetings-${dateRaw}.pdf"`, - ); - res.setHeader("Content-Length", String(pdf.byteLength)); - res.setHeader("Cache-Control", "no-store"); - res.end(pdf); - }, -); - -// ===================================================================== -// PDF ARCHIVES -// ===================================================================== - -router.get( - "/executive-meetings/pdf-archives", - requireExecutiveAccess, - async (req, res): Promise => { - const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - const conds: SQL[] = []; - if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { - conds.push(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw)); - } - const where = conds.length > 0 ? and(...conds) : undefined; - const rows = await db - .select({ - a: executiveMeetingPdfArchivesTable, - username: usersTable.username, - displayNameAr: usersTable.displayNameAr, - displayNameEn: usersTable.displayNameEn, - }) - .from(executiveMeetingPdfArchivesTable) - .leftJoin( - usersTable, - eq(usersTable.id, executiveMeetingPdfArchivesTable.generatedBy), - ) - .where(where) - .orderBy(desc(executiveMeetingPdfArchivesTable.generatedAt)) - .limit(200); - res.json({ - archives: rows.map((row) => ({ - ...row.a, - generator: row.username - ? { - username: row.username, - displayNameAr: row.displayNameAr, - displayNameEn: row.displayNameEn, - } - : null, - })), - }); - }, -); - -router.post( - "/executive-meetings/pdf-archives", - requireExecutiveAccess, - async (req, res): Promise => { - const data = parseBody(res, pdfArchiveCreateSchema, req.body); - if (!data) return; - const filePath = data.filePath?.trim() || `print:${data.archiveDate}`; - const userId = req.session.userId!; - const created = await db.transaction(async (tx) => { - const [{ value: maxV }] = await tx - .select({ - value: sql`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`, - }) - .from(executiveMeetingPdfArchivesTable) - .where(eq(executiveMeetingPdfArchivesTable.archiveDate, data.archiveDate)); - const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = { - archiveDate: data.archiveDate, - filePath, - version: (maxV ?? 0) + 1, - generatedBy: userId, - }; - const [row] = await tx - .insert(executiveMeetingPdfArchivesTable) - .values(insertValues) - .returning(); - await logAudit(tx, { - action: "create", - entityType: "pdf_archive", - entityId: row!.id, - newValue: row!, - performedBy: userId, - }); - return row; - }); - res.status(201).json(created); - }, -); - -// ===================================================================== -// FONT SETTINGS -// ===================================================================== - -router.get( - "/executive-meetings/font-settings", - requireExecutiveAccess, - async (req, res): Promise => { - const userId = req.session.userId!; - const rows = await db - .select() - .from(executiveMeetingFontSettingsTable) - .where( - or( - eq(executiveMeetingFontSettingsTable.scope, "global"), - and( - eq(executiveMeetingFontSettingsTable.scope, "user"), - eq(executiveMeetingFontSettingsTable.userId, userId), - ), - ), - ); - const global = rows.find((r) => r.scope === "global") ?? null; - const user = rows.find((r) => r.scope === "user" && r.userId === userId) ?? null; - res.json({ global, user }); - }, -); - -// Shared handler used by both PUT (canonical) and PATCH (alias) so we don't -// rely on Express re-dispatch tricks — both methods run the exact same logic. -const upsertFontSettingsHandler = async ( - req: import("express").Request, - res: import("express").Response, -): Promise => { - const userId = req.session.userId!; - const data = parseBody(res, fontSettingsSchema, req.body); - if (!data) return; - if (data.scope === "global") { - const names = await getRoleNamesForUser(userId); - const ok = APPROVE_ROLES.some((r) => names.has(r)); - if (!ok) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); - return; - } - } - const targetUserId = data.scope === "global" ? null : userId; - const result = await db.transaction(async (tx) => { - const [existing] = await tx - .select() - .from(executiveMeetingFontSettingsTable) - .where( - and( - eq(executiveMeetingFontSettingsTable.scope, data.scope), - targetUserId === null - ? isNull(executiveMeetingFontSettingsTable.userId) - : eq(executiveMeetingFontSettingsTable.userId, targetUserId), - ), - ); - let row; - if (existing) { - const [updated] = await tx - .update(executiveMeetingFontSettingsTable) - .set({ - fontFamily: data.fontFamily, - fontSize: data.fontSize, - fontWeight: data.fontWeight, - alignment: data.alignment, - }) - .where(eq(executiveMeetingFontSettingsTable.id, existing.id)) - .returning(); - row = updated; - } else { - const insertValues: typeof executiveMeetingFontSettingsTable.$inferInsert = { - scope: data.scope, - userId: targetUserId, - fontFamily: data.fontFamily, - fontSize: data.fontSize, - fontWeight: data.fontWeight, - alignment: data.alignment, - }; - const [inserted] = await tx - .insert(executiveMeetingFontSettingsTable) - .values(insertValues) - .returning(); - row = inserted; - } - await logAudit(tx, { - action: existing ? "update" : "create", - entityType: "font_settings", - entityId: row?.id ?? null, - oldValue: existing ?? null, - newValue: row ?? null, - performedBy: userId, - }); - return row; - }); - res.json(result); -}; - -router.put( - "/executive-meetings/font-settings", - requireExecutiveAccess, - upsertFontSettingsHandler, -); - -// Backwards-compatible PATCH alias — uses the same handler directly so it -// works whether the client sends PATCH or PUT. -router.patch( - "/executive-meetings/font-settings", - requireExecutiveAccess, - upsertFontSettingsHandler, -); - -export { requireApprove }; -export default router; diff --git a/executive-meetings-page-base.tsx b/executive-meetings-page-base.tsx deleted file mode 100644 index c11a7aad..00000000 --- a/executive-meetings-page-base.tsx +++ /dev/null @@ -1,5265 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type CSSProperties, - type ReactNode, -} from "react"; -import { useTranslation } from "react-i18next"; -import { useLocation } from "wouter"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - ArrowLeft, - ArrowRight, - CalendarClock, - FileDown, - Type, - Languages, - ListChecks, - ClipboardList, - CheckSquare, - ListTodo, - Bell, - ScrollText, - FileText, - Settings as SettingsIcon, - Plus, - Pencil, - Trash2, - Check, - X, - ChevronUp, - ChevronDown, - Copy, - Eye, - EyeOff, - RotateCcw, - Palette, - Sliders, - Combine, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Checkbox } from "@/components/ui/checkbox"; -import { useToast } from "@/hooks/use-toast"; -import { - DndContext, - PointerSensor, - KeyboardSensor, - TouchSensor, - useSensor, - useSensors, - closestCenter, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - horizontalListSortingStrategy, - verticalListSortingStrategy, - useSortable, - sortableKeyboardCoordinates, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { GripVertical } from "lucide-react"; -import { EditableCell } from "@/components/editable-cell"; -import { safeHtml } from "@/lib/safe-html"; -import { formatTime as i18nFormatTime } from "@/lib/i18n-format"; - -type Attendee = { - id?: number; - meetingId?: number; - name: string; - title: string | null; - attendanceType: "internal" | "virtual" | "external"; - sortOrder: number; -}; - -type Meeting = { - id: number; - dailyNumber: number; - titleAr: string; - titleEn: string; - meetingDate: string; - startTime: string | null; - endTime: string | null; - location: string | null; - meetingUrl: string | null; - platform: "none" | "webex" | "teams" | "zoom" | "other"; - status: string; - isHighlighted: number; - notes: string | null; - attendees: Attendee[]; - // Optional cell-merge overlay. When all three are non-null the - // schedule renders one merged cell spanning - // [mergeStartColumn..mergeEndColumn] showing mergeText (sanitized - // HTML) instead of the original column values. Originals are - // preserved on the row so clearing the merge restores them. - mergeStartColumn?: ColumnId | null; - mergeEndColumn?: ColumnId | null; - mergeText?: string | null; -}; - -type DayResponse = { date: string; meetings: Meeting[] }; - -type MeRoles = { - userId: number; - roles: string[]; - canRead: boolean; - canMutate: boolean; - canApprove: boolean; - canSubmitRequest: boolean; - canViewAudit: boolean; - canViewTasks: boolean; - canViewAllTasks: boolean; -}; - -type RequestRow = { - id: number; - meetingId: number | null; - requestedBy: number | null; - requestType: string; - requestDetails: unknown; - status: "new" | "approved" | "rejected" | "withdrawn"; - reviewedBy: number | null; - reviewDecision: string | null; - reviewNotes: string | null; - createdAt: string; - updatedAt: string; - requester: { - username: string; - displayNameAr: string | null; - displayNameEn: string | null; - } | null; -}; - -type TaskRow = { - id: number; - requestId: number | null; - meetingId: number | null; - assignedTo: number | null; - taskType: string; - status: "pending" | "in_progress" | "completed" | "cancelled"; - dueAt: string | null; - completedAt: string | null; - notes: string | null; - createdAt: string; - updatedAt: string; - assignee: { - username: string; - displayNameAr: string | null; - displayNameEn: string | null; - } | null; -}; - -type AuditEntry = { - id: number; - action: string; - entityType: string; - entityId: number | null; - oldValue: unknown; - newValue: unknown; - performedBy: number | null; - performedAt: string; - actor: { - username: string; - displayNameAr: string | null; - displayNameEn: string | null; - } | null; -}; - -type NotificationRow = { - id: number; - meetingId: number | null; - userId: number | null; - notificationType: string; - scheduledAt: string | null; - sentAt: string | null; - status: string; - createdAt: string; - user: { - username: string; - displayNameAr: string | null; - displayNameEn: string | null; - } | null; -}; - -type FontPrefs = { - fontFamily: string; - fontSize: number; - fontWeight: "regular" | "bold"; - alignment: "start" | "center"; -}; - -type FontSettingsResponse = { - global: ({ scope: "global"; userId: null } & FontPrefs) | null; - user: ({ scope: "user"; userId: number } & FontPrefs) | null; -}; - -type PdfArchive = { - id: number; - archiveDate: string; - filePath: string; - version: number; - // Bytes recorded by the server-side generator. Null on legacy rows - // that were created before we wrote the PDF to object storage. - byteSize: number | null; - generatedBy: number | null; - generatedAt: string; - generator: { - username: string; - displayNameAr: string | null; - displayNameEn: string | null; - } | null; -}; - -function formatBytesForDisplay(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; - const units = ["B", "KB", "MB"]; - let value = bytes; - let unitIdx = 0; - while (value >= 1024 && unitIdx < units.length - 1) { - value /= 1024; - unitIdx += 1; - } - return `${value < 10 && unitIdx > 0 ? value.toFixed(1) : Math.round(value)} ${units[unitIdx]}`; -} - -const SECTIONS = [ - { key: "schedule", icon: CalendarClock }, - { key: "manage", icon: ListChecks }, - { key: "requests", icon: ClipboardList }, - { key: "approvals", icon: CheckSquare }, - { key: "tasks", icon: ListTodo }, - { key: "notifications", icon: Bell }, - { key: "audit", icon: ScrollText }, - { key: "pdf", icon: FileText }, - { key: "fontSettings", icon: SettingsIcon }, -] as const; - -type SectionKey = (typeof SECTIONS)[number]["key"]; - -type MeCapabilities = { - userId: number; - roles: string[]; - canRead: boolean; - canMutate: boolean; - canApprove: boolean; - canSubmitRequest: boolean; - canViewAudit: boolean; - canViewTasks: boolean; - canViewAllTasks: boolean; -}; - -function isSectionVisible( - key: SectionKey, - me: MeCapabilities | null | undefined, -): boolean { - if (!me) return key === "schedule"; // Pre-load: only show the read-only schedule. - switch (key) { - case "schedule": - case "pdf": - case "fontSettings": - return me.canRead; - case "manage": - return me.canMutate; - case "requests": - return me.canSubmitRequest || me.canApprove; - case "approvals": - return me.canApprove; - case "tasks": - // CEO and viewer roles never see it. - return me.canViewTasks; - case "notifications": - return me.canRead; - case "audit": - return me.canViewAudit; - default: - return false; - } -} - -const DEFAULT_FONT: FontPrefs = { - fontFamily: "system", - fontSize: 14, - fontWeight: "regular", - alignment: "start", -}; - -type ColumnId = "number" | "meeting" | "attendees" | "time"; - -type ColumnSetting = { - id: ColumnId; - visible: boolean; - width: number; -}; - -const DEFAULT_COLUMNS: ColumnSetting[] = [ - { id: "number", visible: true, width: 56 }, - { id: "meeting", visible: true, width: 320 }, - { id: "attendees", visible: true, width: 600 }, - // Wide enough to fit "HH:MM – HH:MM" on one line, plus the two - // controls when the cell enters edit mode. - { id: "time", visible: true, width: 200 }, -]; - -const MIN_COL_WIDTH = 56; -const MAX_COL_WIDTH = 1200; - -const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [ - { key: "default", bg: "", label: "default" }, - { key: "red", bg: "#fee2e2", label: "red" }, - { key: "amber", bg: "#fef3c7", label: "amber" }, - { key: "green", bg: "#dcfce7", label: "green" }, - { key: "blue", bg: "#dbeafe", label: "blue" }, - { key: "violet", bg: "#ede9fe", label: "violet" }, - { key: "gray", bg: "#f3f4f6", label: "gray" }, -]; - -const COLS_STORAGE_KEY = "em-schedule-cols-v1"; -const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1"; -const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1"; - -type HighlightPrefs = { enabled: boolean; color: string }; -const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = { - enabled: true, - color: "#16a34a", -}; - -const HIGHLIGHT_COLOR_SWATCHES = [ - "#16a34a", - "#2563eb", - "#dc2626", - "#ea580c", - "#7c3aed", - "#0B1E3F", -]; - -function parseTimeToMinutes(t: string | null): number | null { - if (!t) return null; - const m = /^(\d{2}):(\d{2})/.exec(t); - if (!m) return null; - const h = Number(m[1]); - const mm = Number(m[2]); - if (!Number.isFinite(h) || !Number.isFinite(mm)) return null; - return h * 60 + mm; -} - -/** - * Returns the id of the meeting that is currently in progress on `dateIso`, - * or null. A meeting is "current" when the wall clock falls within - * [startTime, endTime) and the displayed date matches today. - */ -function computeCurrentMeetingId( - meetings: Meeting[], - dateIso: string, - nowMs: number, -): number | null { - const now = new Date(nowMs); - const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; - if (todayIso !== dateIso) return null; - const minutesNow = now.getHours() * 60 + now.getMinutes(); - for (const m of meetings) { - const s = parseTimeToMinutes(m.startTime); - const e = parseTimeToMinutes(m.endTime); - if (s === null || e === null) continue; - if (s <= minutesNow && minutesNow < e) return m.id; - } - return null; -} - -function formatTime(t: string | null, lang: "ar" | "en"): string { - if (!t) return ""; - // DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so - // Intl can format the time-of-day in the user's language with AM/PM - // (English) or ص/م (Arabic). Latin digits are enforced via the shared - // formatTime helper. Falls back to the raw HH:mm slice if parsing - // somehow fails (defensive — shouldn't happen for our DB shape). - const hhmm = t.slice(0, 5); - const parsed = new Date(`1970-01-01T${hhmm}:00`); - if (Number.isNaN(parsed.getTime())) return hhmm; - return i18nFormatTime(parsed, lang, { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); -} - -function todayIso(): string { - return new Date().toISOString().slice(0, 10); -} - -function fontWeightToCss(w: FontPrefs["fontWeight"]): number { - return { regular: 400, bold: 700 }[w]; -} - -function fontFamilyToCss(name: string): string { - if (name === "system") return ""; - if (/[\s-]/.test(name)) return `"${name}", system-ui, sans-serif`; - return `${name}, system-ui, sans-serif`; -} - -function buildFontStyle(prefs: FontPrefs): CSSProperties { - return { - fontFamily: fontFamilyToCss(prefs.fontFamily) || undefined, - fontSize: prefs.fontSize, - fontWeight: fontWeightToCss(prefs.fontWeight), - textAlign: prefs.alignment, - }; -} - -function tWithFallback( - t: (key: string) => string, - key: string, - fallback: string, -): string { - const value = t(key); - return value === key ? fallback : value; -} - -async function apiJson( - url: string, - init?: Omit & { body?: unknown }, -): Promise { - const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {}; - const opts: RequestInit = { - credentials: "include", - ...rest, - headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) }, - }; - if (rawBody !== undefined) { - opts.body = - typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody); - } - const res = await fetch(url, opts); - if (res.status === 204) return undefined as T; - const data = (await res.json().catch(() => ({}))) as T & { error?: string }; - if (!res.ok) { - throw new Error((data as { error?: string }).error || `HTTP ${res.status}`); - } - return data; -} - -function readJsonFromStorage(key: string, fallback: T): T { - if (typeof window === "undefined") return fallback; - try { - const raw = window.localStorage.getItem(key); - if (!raw) return fallback; - return JSON.parse(raw) as T; - } catch { - return fallback; - } -} - -function writeJsonToStorage(key: string, value: unknown): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(key, JSON.stringify(value)); - } catch { - /* ignore */ - } -} - -function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(() => { - if (typeof window === "undefined") return false; - return window.matchMedia(query).matches; - }); - useEffect(() => { - if (typeof window === "undefined") return; - const mql = window.matchMedia(query); - const handler = (e: MediaQueryListEvent) => setMatches(e.matches); - setMatches(mql.matches); - mql.addEventListener("change", handler); - return () => mql.removeEventListener("change", handler); - }, [query]); - return matches; -} - -function normalizeColumns(value: unknown): ColumnSetting[] { - if (!Array.isArray(value)) return DEFAULT_COLUMNS; - const seen = new Set(); - const out: ColumnSetting[] = []; - for (const entry of value) { - if (!entry || typeof entry !== "object") continue; - const e = entry as Partial; - if (typeof e.id !== "string") continue; - if (!DEFAULT_COLUMNS.some((d) => d.id === e.id)) continue; - if (seen.has(e.id as ColumnId)) continue; - seen.add(e.id as ColumnId); - const fallback = DEFAULT_COLUMNS.find((d) => d.id === e.id)!; - const width = - typeof e.width === "number" && Number.isFinite(e.width) - ? Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, e.width)) - : fallback.width; - out.push({ - id: e.id as ColumnId, - visible: typeof e.visible === "boolean" ? e.visible : true, - width, - }); - } - // Append any defaults that were missing (e.g. new columns added later). - for (const def of DEFAULT_COLUMNS) { - if (!seen.has(def.id)) out.push({ ...def }); - } - return out; -} - -export default function ExecutiveMeetingsPage() { - const { t, i18n } = useTranslation(); - const [, setLocation] = useLocation(); - const isRtl = i18n.language === "ar"; - const lang: "ar" | "en" = isRtl ? "ar" : "en"; - const BackIcon = isRtl ? ArrowRight : ArrowLeft; - - const [section, setSection] = useState("schedule"); - const [date, setDate] = useState(todayIso()); - - const { data, isLoading } = useQuery({ - queryKey: ["/api/executive-meetings", date], - queryFn: async () => { - const res = await fetch(`/api/executive-meetings?date=${date}`, { - credentials: "include", - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); - }, - }); - - const { data: me } = useQuery({ - queryKey: ["/api/executive-meetings/me"], - queryFn: () => apiJson("/api/executive-meetings/me"), - }); - - const { data: fontResp } = useQuery({ - queryKey: ["/api/executive-meetings/font-settings"], - queryFn: () => apiJson("/api/executive-meetings/font-settings"), - }); - - const effectiveFont: FontPrefs = useMemo(() => { - const u = fontResp?.user; - const g = fontResp?.global; - const src = u ?? g; - if (!src) return DEFAULT_FONT; - return { - fontFamily: src.fontFamily, - fontSize: src.fontSize, - fontWeight: src.fontWeight, - alignment: src.alignment, - }; - }, [fontResp]); - - const meetings = data?.meetings ?? []; - - return ( -
-
-
- - -
-
- -
-
-
- {t("executiveMeetings.titleAr")} -
-
- {t("executiveMeetings.titleEn")} -
-
-
- -
- -
- - - -
-
- - -
- -
- {section === "schedule" && ( - - )} - {section === "manage" && me && ( - - )} - {section === "requests" && me && ( - - )} - {section === "approvals" && me && ( - - )} - {section === "tasks" && me && ( - - )} - {section === "notifications" && me && ( - - )} - {section === "audit" && me && ( - - )} - {section === "pdf" && ( - - )} - {section === "fontSettings" && me && ( - - )} -
-
- ); -} - -// ============ SCHEDULE ============ - -function ScheduleSection({ - meetings, - date, - onDateChange, - isLoading, - isRtl, - t, - font, - canMutate, -}: { - meetings: Meeting[]; - date: string; - onDateChange: (d: string) => void; - isLoading: boolean; - isRtl: boolean; - t: (k: string) => string; - font: FontPrefs; - canMutate: boolean; -}) { - const qc = useQueryClient(); - const { toast } = useToast(); - const tableStyle = buildFontStyle(font); - const [columns, setColumns] = useState(() => - normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)), - ); - const [rowColors, setRowColors] = useState>(() => - readJsonFromStorage>(ROW_COLORS_STORAGE_KEY, {}), - ); - const [highlightPrefs, setHighlightPrefs] = useState(() => - readJsonFromStorage( - HIGHLIGHT_STORAGE_KEY, - DEFAULT_HIGHLIGHT_PREFS, - ), - ); - - useEffect(() => { - writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs); - }, [highlightPrefs]); - - const [nowTick, setNowTick] = useState(() => Date.now()); - useEffect(() => { - let intervalId: ReturnType | null = null; - const msToNextMinute = 60_000 - (Date.now() % 60_000); - const timeoutId = setTimeout(() => { - setNowTick(Date.now()); - intervalId = setInterval(() => setNowTick(Date.now()), 60_000); - }, msToNextMinute); - return () => { - clearTimeout(timeoutId); - if (intervalId) clearInterval(intervalId); - }; - }, []); - - const currentMeetingId = useMemo( - () => computeCurrentMeetingId(meetings, date, nowTick), - [meetings, date, nowTick], - ); - - // Optimistic local override so we can render the new row order before the - // server round-trip finishes. Cleared on each new meetings prop. - const [optimisticOrder, setOptimisticOrder] = useState(null); - const [reordering, setReordering] = useState(false); - const reorderingRef = useRef(false); - useEffect(() => { - setOptimisticOrder(null); - }, [meetings, date]); - - const orderedMeetings = useMemo(() => { - if (!optimisticOrder) return meetings; - const byId = new Map(meetings.map((m) => [m.id, m])); - const out: Meeting[] = []; - for (const id of optimisticOrder) { - const m = byId.get(id); - if (m) out.push(m); - } - // Append any meetings not in the optimistic order (defensive). - for (const m of meetings) { - if (!optimisticOrder.includes(m.id)) out.push(m); - } - return out; - }, [meetings, optimisticOrder]); - - const refreshDay = useCallback(() => { - void qc.invalidateQueries({ - queryKey: ["/api/executive-meetings", date], - }); - }, [qc, date]); - - const saveTitle = useCallback( - async (meeting: Meeting, html: string) => { - const body = isRtl ? { titleAr: html } : { titleEn: html }; - try { - await apiJson(`/api/executive-meetings/${meeting.id}`, { - method: "PATCH", - body, - }); - refreshDay(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - throw err; - } - }, - [isRtl, refreshDay, toast, t], - ); - - const saveAttendeeName = useCallback( - async (meeting: Meeting, attendeeIdx: number, html: string) => { - // Detect "truly empty" content: Tiptap empty paragraphs,  , or - // pure whitespace. When the attendee's name is cleared we treat the - // edit as a delete-row gesture and remove the attendee instead of - // saving an empty record. - const isEmpty = - html - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .replace(/\u00a0/g, " ") - .trim() === ""; - const next = isEmpty - ? meeting.attendees - .filter((_, i) => i !== attendeeIdx) - // Re-pack sortOrder so the server stores a contiguous run; the - // PUT endpoint takes the array as the new source of truth and - // reassigns ids on insert. - .map((a, i) => ({ ...a, sortOrder: i })) - : meeting.attendees.map((a, i) => - i === attendeeIdx ? { ...a, name: html } : a, - ); - try { - await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, { - method: "PUT", - body: { - attendees: next.map((a) => ({ - name: a.name, - title: a.title, - attendanceType: a.attendanceType, - sortOrder: a.sortOrder, - })), - }, - }); - refreshDay(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - throw err; - } - }, - [refreshDay, toast, t], - ); - - // ------------------------------------------------------------------- - // "+ Add attendee" — optimistic ghost row - // - // Clicking the inline "+" button in the attendees cell does NOT hit - // the server immediately (the API requires a non-empty name). Instead - // we render a synthetic in-edit EditableCell next to the existing - // attendees. When the user saves a non-empty name we PUT the full - // array with the new attendee appended; if they leave the cell empty - // (blur or Escape) we just discard the pending state. - // - // We allow at most one pending attendee at a time across the whole - // page — the AttendeesCell hides "+" buttons elsewhere while a ghost - // row is open so the user can't accidentally orphan their typing. - type PendingAttendee = { - meetingId: number; - attendanceType: Attendee["attendanceType"]; - // Bumped each time the user clicks "+" so React remounts the ghost - // EditableCell (giving it a fresh editor and focus). - key: number; - }; - const [pendingAttendee, setPendingAttendee] = - useState(null); - - const startAddAttendee = useCallback( - (meetingId: number, attendanceType: Attendee["attendanceType"]) => { - // Only one ghost row may be open at a time across the whole page. - // We refuse a new start while another is pending — the user must - // first commit (blur with text) or cancel (Escape / blur empty). - // The "+" buttons are already hidden in this state, but a stale - // click that beats the re-render is still possible. - setPendingAttendee((prev) => - prev ? prev : { meetingId, attendanceType, key: Date.now() }, - ); - }, - [], - ); - - const cancelAddAttendee = useCallback(() => { - setPendingAttendee(null); - }, []); - - const commitAddAttendee = useCallback( - async ( - meeting: Meeting, - attendanceType: Attendee["attendanceType"], - html: string, - ) => { - // Always clear pending — whether we end up persisting or not. - setPendingAttendee(null); - const isEmpty = - html - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .replace(/\u00a0/g, " ") - .trim() === ""; - if (isEmpty) return; - const maxSort = meeting.attendees.reduce( - (m, a) => Math.max(m, a.sortOrder ?? 0), - -1, - ); - const next = [ - ...meeting.attendees.map((a) => ({ - name: a.name, - title: a.title, - attendanceType: a.attendanceType, - sortOrder: a.sortOrder, - })), - { - name: html, - title: null as string | null, - attendanceType, - sortOrder: maxSort + 1, - }, - ]; - try { - await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, { - method: "PUT", - body: { attendees: next }, - }); - refreshDay(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - } - }, - [refreshDay, toast, t], - ); - - const saveTimes = useCallback( - async ( - meeting: Meeting, - startTime: string | null, - endTime: string | null, - ) => { - try { - await apiJson(`/api/executive-meetings/${meeting.id}`, { - method: "PATCH", - body: { startTime, endTime }, - }); - refreshDay(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - throw err; - } - }, - [refreshDay, toast, t], - ); - - // PATCH the cell-merge overlay on a meeting. `merge === null` clears - // the overlay and restores the original cells; otherwise it sets a - // new range + free text that the row will display in place of the - // spanned cells. - const saveMerge = useCallback( - async ( - meeting: Meeting, - merge: - | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } - | null, - ) => { - try { - await apiJson(`/api/executive-meetings/${meeting.id}`, { - method: "PATCH", - body: { merge }, - }); - refreshDay(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - throw err; - } - }, - [refreshDay, toast, t], - ); - - // Inline "delete entire meeting" action exposed on each schedule row. - // Uses the same DELETE endpoint as the Manage section, but lives on - // the schedule itself so editors can remove a row without context- - // switching. Confirmation surfaces the meeting title so users know - // exactly what will be removed (titles can contain rich-text HTML so - // we strip tags before showing the prompt). - const [deletingMeetingId, setDeletingMeetingId] = useState( - null, - ); - const deleteMeeting = useCallback( - async (meeting: Meeting) => { - if (deletingMeetingId != null) return; - // Pick the localized display title with the same fallback the row - // uses, then strip HTML so the native confirm dialog shows plain - // text (titles are Tiptap rich text in the DB). - const rawTitle = isRtl - ? meeting.titleAr - : meeting.titleEn || meeting.titleAr; - const plainTitle = rawTitle - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .replace(/\u00a0/g, " ") - .trim(); - const message = t("executiveMeetings.schedule.deleteRowConfirm").replace( - "{{title}}", - plainTitle, - ); - if (!window.confirm(message)) return; - setDeletingMeetingId(meeting.id); - try { - await apiJson(`/api/executive-meetings/${meeting.id}`, { - method: "DELETE", - }); - toast({ title: t("executiveMeetings.schedule.deleted") }); - refreshDay(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - } finally { - setDeletingMeetingId(null); - } - }, - [deletingMeetingId, isRtl, refreshDay, t, toast], - ); - - const [creatingRow, setCreatingRow] = useState(false); - // When the user clicks "Add row" we capture the new meeting id here so - // that, once the refreshed list contains it, we can scroll the row into - // view and auto-open the title cell in inline-edit mode. - const [autoEditTitleId, setAutoEditTitleId] = useState(null); - const createRow = useCallback(async () => { - if (creatingRow) return; - setCreatingRow(true); - try { - const created = (await apiJson(`/api/executive-meetings`, { - method: "POST", - body: { - titleAr: t("executiveMeetings.schedule.newMeetingDefaultAr"), - titleEn: t("executiveMeetings.schedule.newMeetingDefaultEn"), - meetingDate: date, - }, - })) as Meeting | undefined; - if (created?.id) setAutoEditTitleId(created.id); - refreshDay(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - } finally { - setCreatingRow(false); - } - }, [creatingRow, date, refreshDay, toast, t]); - - // Once the auto-edit target meeting is in the list, scroll it into view. - // The MeetingRow itself receives `startInEditMode` so its EditableCell - // opens immediately and clears `autoEditTitleId` via `onStartedEditing`. - // We also schedule a fallback clear so the state never goes stale if the - // title cell never mounts (e.g. column hidden, row filtered out). - useEffect(() => { - if (autoEditTitleId == null) return; - if (!meetings.some((m) => m.id === autoEditTitleId)) return; - const rafId = window.requestAnimationFrame(() => { - const row = document.querySelector( - `[data-testid="em-row-${autoEditTitleId}"]`, - ); - if (row && "scrollIntoView" in row) { - (row as HTMLElement).scrollIntoView({ - block: "center", - behavior: "smooth", - }); - } - }); - const fallback = window.setTimeout(() => { - setAutoEditTitleId(null); - }, 1500); - return () => { - window.cancelAnimationFrame(rafId); - window.clearTimeout(fallback); - }; - }, [autoEditTitleId, meetings]); - const clearAutoEditTitleId = useCallback(() => { - setAutoEditTitleId(null); - }, []); - - const reorderRows = useCallback( - async (fromId: number, toId: number) => { - if (fromId === toId) return; - if (reorderingRef.current) return; - const ids = meetings.map((m) => m.id); - const fromIdx = ids.indexOf(fromId); - const toIdx = ids.indexOf(toId); - if (fromIdx < 0 || toIdx < 0) return; - const newOrder = ids.slice(); - newOrder.splice(fromIdx, 1); - newOrder.splice(toIdx, 0, fromId); - setOptimisticOrder(newOrder); - reorderingRef.current = true; - setReordering(true); - try { - await apiJson(`/api/executive-meetings/reorder`, { - method: "POST", - body: { meetingDate: date, orderedIds: newOrder }, - }); - refreshDay(); - } catch (err) { - setOptimisticOrder(null); - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - } finally { - reorderingRef.current = false; - setReordering(false); - } - }, - [meetings, date, refreshDay, toast, t], - ); - - useEffect(() => { - writeJsonToStorage(COLS_STORAGE_KEY, columns); - }, [columns]); - - useEffect(() => { - writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors); - }, [rowColors]); - - const visibleColumns = columns.filter((c) => c.visible); - - const setColumnWidth = useCallback((id: ColumnId, width: number) => { - setColumns((prev) => - prev.map((c) => - c.id === id - ? { - ...c, - width: Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, width)), - } - : c, - ), - ); - }, []); - - const setRowColor = useCallback((meetingId: number, colorKey: string) => { - setRowColors((prev) => { - const next = { ...prev }; - if (colorKey === "default") { - delete next[meetingId]; - } else { - next[meetingId] = colorKey; - } - return next; - }); - }, []); - - // Responsive breakpoints: - // < md (<768px) → stacked card layout (phones) - // md..=1280 CSS px — get no handles - // since drag-resizing with a finger fights touch scrolling. - const isXl = useMediaQuery("(min-width: 1280px)"); - const isFinePointer = useMediaQuery("(pointer: fine)"); - const showResizeHandles = isXl && isFinePointer; - - // dnd-kit: sortable column headers + sortable rows. - // - // - PointerSensor for desktop mouse: a 6px activation distance keeps a - // normal click on cell content from being mistaken for a drag (so the - // inline editors on title/attendee/time cells still open on click). - // - TouchSensor for iPad and other touch devices: PointerSensor alone - // does not work on touch because the browser claims the gesture as a - // vertical scroll before the finger has moved 6px, so the drag never - // activates. A long-press activation (delay+tolerance) lets the user - // press-and-hold the row's grip handle to start dragging while still - // leaving normal scrolling intact everywhere else on the row. - // - KeyboardSensor for accessibility / keyboard reordering. - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - useSensor(TouchSensor, { - activationConstraint: { delay: 200, tolerance: 8 }, - }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ); - - const onColumnDragEnd = useCallback( - (e: DragEndEvent) => { - const { active, over } = e; - if (!over || active.id === over.id) return; - setColumns((prev) => { - const ids = prev.map((c) => c.id); - const fromIdx = ids.indexOf(active.id as ColumnId); - const toIdx = ids.indexOf(over.id as ColumnId); - if (fromIdx < 0 || toIdx < 0) return prev; - const next = prev.slice(); - const [moved] = next.splice(fromIdx, 1); - if (!moved) return prev; - next.splice(toIdx, 0, moved); - return next; - }); - }, - [], - ); - - const onRowDragEnd = useCallback( - (e: DragEndEvent) => { - const { active, over } = e; - if (!over || active.id === over.id) return; - void reorderRows(Number(active.id), Number(over.id)); - }, - [reorderRows], - ); - - return ( -
-
-

- {t("executiveMeetings.scheduleHeading")} -

-
- - -
-
- -
-
{t("executiveMeetings.titleAr")}
-
{date}
-
- -
- - {/* Always emit colgroup + th widths so they're available for print - and for ≥xl screens. CSS classes on the
decide whether - they're enforced (table-fixed) or treated as hints (table-auto). */} - - {visibleColumns.map((c) => ( - - ))} - - - - - c.id)} - strategy={horizontalListSortingStrategy} - > - {visibleColumns.map((c, idx) => ( - setColumnWidth(c.id, c.width + delta)} - /> - ))} - - - - - - - {isLoading && ( - - - - )} - {!isLoading && orderedMeetings.length === 0 && ( - - - - )} - m.id)} - strategy={verticalListSortingStrategy} - > - {orderedMeetings.map((m) => ( - setRowColor(m.id, key)} - canMutate={canMutate} - onSaveTitle={(html) => saveTitle(m, html)} - onSaveAttendeeName={(idx, html) => - saveAttendeeName(m, idx, html) - } - onSaveTimes={(start, end) => saveTimes(m, start, end)} - onSaveMerge={(merge) => saveMerge(m, merge)} - onDeleteMeeting={() => deleteMeeting(m)} - // Disable every row's delete button while any delete - // is in flight, since deleteMeeting short-circuits - // concurrent calls — keeps the UI honest about which - // clicks will do anything. - isDeleting={deletingMeetingId !== null} - isCurrent={ - highlightPrefs.enabled && currentMeetingId === m.id - } - highlightColor={highlightPrefs.color} - reordering={reordering} - autoEditTitle={autoEditTitleId === m.id} - onAutoEditTitleConsumed={clearAutoEditTitleId} - pendingAttendee={pendingAttendee} - onStartAddAttendee={startAddAttendee} - onCommitAddAttendee={(type, html) => - commitAddAttendee(m, type, html) - } - onCancelAddAttendee={cancelAddAttendee} - /> - ))} - - {canMutate && !isLoading && ( - - - - )} - - -
- {t("common.loading")} -
- {t("executiveMeetings.noMeetings")} -
- -
-
-
- ); -} - -function ResizeHandle({ - isRtl, - onResize, -}: { - isRtl: boolean; - onResize: (delta: number) => void; -}) { - const startXRef = useRef(null); - - const onPointerDown = (e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - (e.target as HTMLDivElement).setPointerCapture(e.pointerId); - startXRef.current = e.clientX; - }; - - const onPointerMove = (e: React.PointerEvent) => { - if (startXRef.current === null) return; - const raw = e.clientX - startXRef.current; - // In RTL the trailing edge is on the LEFT, so dragging left makes the - // column wider. Negate the delta in that case. - const delta = isRtl ? -raw : raw; - if (Math.abs(delta) >= 1) { - startXRef.current = e.clientX; - onResize(delta); - } - }; - - const onPointerUp = (e: React.PointerEvent) => { - startXRef.current = null; - try { - (e.target as HTMLDivElement).releasePointerCapture(e.pointerId); - } catch { - /* ignore */ - } - }; - - // Place handle on the trailing (logical end) edge so it works the same in - // both LTR and RTL. - const sideClass = isRtl ? "left-0" : "right-0"; - - return ( -
- ); -} - -function ColumnsCustomizer({ - columns, - onChange, - t, - highlightPrefs, - onHighlightPrefsChange, -}: { - columns: ColumnSetting[]; - onChange: (next: ColumnSetting[]) => void; - t: (k: string) => string; - highlightPrefs: HighlightPrefs; - onHighlightPrefsChange: (next: HighlightPrefs) => void; -}) { - const toggle = (id: ColumnId, value: boolean) => { - // Always keep at least one column visible. - const visibleCount = columns.filter((c) => c.visible).length; - if (!value && visibleCount <= 1) return; - onChange(columns.map((c) => (c.id === id ? { ...c, visible: value } : c))); - }; - - const reset = () => onChange(DEFAULT_COLUMNS.map((c) => ({ ...c }))); - - return ( - - - - - -
- {t("executiveMeetings.customize.title")} -
-

- {t("executiveMeetings.customize.hint")} -

-
    - {columns.map((c) => ( -
  • - toggle(c.id, !!v)} - data-testid={`em-customize-toggle-${c.id}`} - /> - - {!c.visible ? ( - - ) : ( - - )} -
  • - ))} -
-
- - - {t("executiveMeetings.customize.dragHint")} - -
-
-
- - - onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v }) - } - data-testid="em-highlight-toggle" - /> -
-

- {t("executiveMeetings.highlight.hint")} -

-
- {HIGHLIGHT_COLOR_SWATCHES.map((c) => { - const selected = highlightPrefs.color === c; - return ( - -
-
-
-
- ); -} - -/** - * Sortable column header for the schedule table. The whole is the - * drag handle so it stays grabbable in any RTL/LTR mode and on touch. The - * resize handle on the trailing edge stops drag propagation so resizing - * still works. - */ -function SortableHeader({ - col, - isLast, - isRtl, - t, - showResizeHandle, - onResize, -}: { - col: ColumnSetting; - isLast: boolean; - isRtl: boolean; - t: (k: string) => string; - showResizeHandle: boolean; - onResize: (delta: number) => void; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: col.id }); - const align = - col.id === "number" || col.id === "time" ? "text-center" : "text-start"; - const style: CSSProperties = { - width: col.width, - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - cursor: "grab", - }; - return ( - - {t(`executiveMeetings.col.${col.id}`)} - {/* Resize handles only render on large screens AND fine (mouse) - pointers — see comment on showResizeHandles. */} - {!isLast && showResizeHandle && ( - - )} - - ); -} - -function RowColorPickerSwatches({ - current, - onPick, - t, -}: { - current: string; - onPick: (key: string) => void; - t: (k: string) => string; -}) { - return ( - <> -
- {t("executiveMeetings.rowColor.label")} -
-
- {ROW_COLOR_OPTIONS.map((opt) => { - const selected = current === opt.key; - const isDefault = opt.key === "default"; - return ( -
- - ); -} - -function RowColorPicker({ - current, - onPick, - t, -}: { - current: string; - onPick: (key: string) => void; - t: (k: string) => string; -}) { - return ( - - - - - - - - - ); -} - -// Reusable row-level "delete entire meeting" trigger. Rendered as an -// absolutely-positioned overlay inside whichever cell is the row's -// current anchor (first visible cell on unmerged rows, the merged -// cell on merged rows) so it stays available regardless of which -// columns the user has hidden via the column customizer. The host -// must carry `relative group` for the positioning + hover -// reveal to work, which every renderCell branch already does. -function DeleteRowButton({ - meetingId, - isDeleting, - onDelete, - label, -}: { - meetingId: number; - isDeleting: boolean; - onDelete: () => void; - label: string; -}) { - return ( - - ); -} - -function MeetingRow({ - meeting, - isRtl, - t, - visibleColumns, - rowColorKey, - onPickRowColor, - canMutate, - onSaveTitle, - onSaveAttendeeName, - onSaveTimes, - onSaveMerge, - onDeleteMeeting, - isDeleting, - isCurrent, - highlightColor, - reordering, - autoEditTitle = false, - onAutoEditTitleConsumed, - pendingAttendee, - onStartAddAttendee, - onCommitAddAttendee, - onCancelAddAttendee, -}: { - meeting: Meeting; - isRtl: boolean; - t: (k: string) => string; - visibleColumns: ColumnSetting[]; - rowColorKey: string; - onPickRowColor: (key: string) => void; - canMutate: boolean; - onSaveTitle: (html: string) => Promise; - onSaveAttendeeName: (idx: number, html: string) => Promise; - onSaveTimes: ( - startTime: string | null, - endTime: string | null, - ) => Promise; - onSaveMerge: ( - merge: - | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } - | null, - ) => Promise; - onDeleteMeeting: () => void; - isDeleting: boolean; - isCurrent: boolean; - highlightColor: string; - reordering: boolean; - autoEditTitle?: boolean; - onAutoEditTitleConsumed?: () => void; - pendingAttendee?: { - meetingId: number; - attendanceType: Attendee["attendanceType"]; - key: number; - } | null; - onStartAddAttendee?: ( - meetingId: number, - attendanceType: Attendee["attendanceType"], - ) => void; - onCommitAddAttendee?: ( - attendanceType: Attendee["attendanceType"], - html: string, - ) => Promise; - onCancelAddAttendee?: () => void; -}) { - const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr; - const isCancelled = meeting.status === "cancelled"; - const highlight = meeting.isHighlighted === 1; - - const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey); - const rowBg = rowColor?.bg || ""; - - // Make the row a sortable item. `attributes` get spread onto the for - // a11y; `listeners` go on the dedicated grip handle in the # cell so a - // normal click on the cell content (to enter inline-edit mode) is still - // possible. - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: meeting.id, disabled: !canMutate }); - - const numberCellCls = - "border border-gray-300 px-2 py-2 text-center align-middle font-semibold " + - (highlight || isCancelled - ? "bg-red-600 text-white" - : "bg-white text-[#0B1E3F]"); - - // Always provide width + overflow:hidden inline. In `table-auto` mode the - // browser treats width as a hint and shrinks cells to fit; in `table-fixed` - // mode (xl screens AND any print) it enforces them. overflow:hidden + the - // existing break-words on inner divs keeps wrapping behavior correct. - const cellStyle = (col: ColumnSetting): CSSProperties => ({ - width: col.width, - overflow: "hidden", - }); - - // Compute the cell-merge overlay for this row. We resolve the stored - // start/end column ids against the *canonical* schedule column order - // (not against `visibleColumns`) so that a merge whose start or end - // column is currently hidden still renders across whichever columns - // in the range remain visible. This guarantees a row that has a - // merge in the DB never silently falls back to "unmerged" just - // because the user toggled off one of the boundary columns. - const canonStart = canonicalMergeIndex(meeting.mergeStartColumn); - const canonEnd = canonicalMergeIndex(meeting.mergeEndColumn); - const hasStoredMerge = - canonStart >= 0 && canonEnd >= canonStart && typeof meeting.mergeText === "string"; - const visibleMergeIndices: number[] = []; - if (hasStoredMerge) { - visibleColumns.forEach((c, idx) => { - const ci = canonicalMergeIndex(c.id); - if (ci >= canonStart && ci <= canonEnd) visibleMergeIndices.push(idx); - }); - } - // Headers are themselves drag-sortable, so users can reorder the - // schedule columns. If a previously-set merge ends up spanning - // *non-contiguous* visible indices after a reorder, rendering one - // colSpan cell at the first index would silently swallow the wrong - // columns. In that case we degrade to "unmerged" rendering — the DB - // merge state stays intact, and unmerging or restoring canonical - // order brings the merged cell back. - const visibleMergeContiguous = - visibleMergeIndices.length === 0 || - visibleMergeIndices[visibleMergeIndices.length - 1] - - visibleMergeIndices[0] + - 1 === - visibleMergeIndices.length; - const isMerged = - hasStoredMerge && visibleMergeIndices.length > 0 && visibleMergeContiguous; - const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1; - const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null; - // First visible cell that's NOT part of a merge. The merge trigger - // for an unmerged row is anchored here so editors can still open the - // merge menu even when the # column is hidden. We look for the - // first index in visibleColumns whose canonical id exists (which is - // every schedule column), so this collapses to `0` in practice but - // would skip non-mergeable columns if they're added later. - const firstUnmergedIdx = isMerged ? -1 : visibleColumns.length > 0 ? 0 : -1; - - // Light tint for the current meeting on the editable cells (meeting, - // attendees, time). The number cell keeps its solid white background - // and original text colour so it stays legible — only the rest of the - // row gets a soft wash of highlightColor. We keep the original text - // colour everywhere so the row remains readable. - const tintBg = - isCurrent && !(highlight || isCancelled) - ? hexToRgba(highlightColor, 0.13) - : null; - const tintedCellStyle = (col: ColumnSetting): CSSProperties => - tintBg ? { ...cellStyle(col), backgroundColor: tintBg } : cellStyle(col); - - const renderCell = (col: ColumnSetting, idx: number): ReactNode => { - // Anchor the merge-menu trigger to whichever cell is the first - // visible one on an unmerged row. Using "first visible cell" - // (instead of always the # cell) keeps the trigger reachable when - // editors hide the number column. The trigger is rendered as an - // absolutely-positioned overlay inside the cell, so every cell type - // needs `relative group` on its for the overlay to find it. - const showMergeTrigger = canMutate && !isMerged && idx === firstUnmergedIdx; - const mergeOverlay = showMergeTrigger ? ( - - ) : null; - // Delete trigger uses the same "first visible cell" anchoring as the - // merge trigger so the action stays available even when editors hide - // the # column via the column customizer. Only one button per row. - // The merged-cell branch (in renderCells below) renders its own copy. - const showDeleteTrigger = canMutate && !isMerged && idx === firstUnmergedIdx; - const deleteOverlay = showDeleteTrigger ? ( - - ) : null; - switch (col.id) { - case "number": - return ( - -
- {canMutate && ( - - )} - {meeting.dailyNumber} -
- - {deleteOverlay} - {mergeOverlay} - - ); - case "meeting": - return ( - - - {meeting.location && ( -
- {meeting.location} -
- )} - {deleteOverlay} - {mergeOverlay} - - ); - case "attendees": - return ( - - - {deleteOverlay} - {mergeOverlay} - - ); - case "time": - return ( - - - {deleteOverlay} - {mergeOverlay} - - ); - } - }; - - // When a merge is active we render a single with an - // EditableCell for `mergeText`, and skip the cells the merge covers - // after the first. The merged cell hosts its own MergeMenu so users - // can always unmerge or re-merge — even when the # column is hidden. - // Originals stay in the DB so unmerge restores them. - const renderCells = (): ReactNode[] => { - if (!isMerged) { - return visibleColumns.map((c, idx) => renderCell(c, idx)); - } - const cells: ReactNode[] = []; - const span = visibleMergeIndices.length; - visibleColumns.forEach((col, idx) => { - if (mergeSkipSet?.has(idx)) return; - if (idx === mergeFirstIdx) { - // Sum widths of the spanned visible columns so layout in - // table-fixed mode reserves the right horizontal space. - const totalWidth = visibleMergeIndices.reduce( - (sum, i) => sum + (visibleColumns[i].width || 0), - 0, - ); - cells.push( - - { - await onSaveMerge({ - mergeStartColumn: meeting.mergeStartColumn as ColumnId, - mergeEndColumn: meeting.mergeEndColumn as ColumnId, - mergeText: html, - }); - }} - ariaLabel={t("executiveMeetings.merge.editLabel")} - dir={isRtl ? "rtl" : "ltr"} - className="font-medium leading-snug break-words" - disabled={!canMutate} - placeholder={t("executiveMeetings.merge.placeholder")} - testId={`em-merge-edit-${meeting.id}`} - /> - {canMutate && ( - - )} - {canMutate && ( - - )} - , - ); - } else { - cells.push(renderCell(col, idx)); - } - }); - return cells; - }; - - // Combine row background with highlight tint. When the row is the current - // meeting we apply a soft tint (color at low opacity) plus a coloured ring - // via box-shadow (rings on don't render across cells; box-shadow does). - const baseBg = rowBg || "white"; - const trStyle: CSSProperties = { - backgroundColor: baseBg, - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : reordering ? 0.7 : 1, - boxShadow: isCurrent ? `inset 0 0 0 2px ${highlightColor}` : undefined, - }; - - return ( - - {renderCells()} - - ); -} - -// Canonical schedule column order for cell-merge ranges. MUST stay in -// sync with the API's MERGE_COLUMNS array in -// artifacts/api-server/src/routes/executive-meetings.ts. We use this -// (instead of `visibleColumns`) when computing whether a stored merge -// overlaps the currently visible columns, so that hiding one boundary -// of the range still lets the merged cell render across whichever -// columns inside the range remain visible. -const CANONICAL_MERGE_ORDER: ColumnId[] = [ - "number", - "meeting", - "attendees", - "time", -]; -function canonicalMergeIndex(id: ColumnId | string | null | undefined): number { - if (id == null) return -1; - return CANONICAL_MERGE_ORDER.indexOf(id as ColumnId); -} - -// Convert a #RRGGBB or #RGB hex color into an rgba() string with the -// given alpha. Used to derive the soft tint applied to the current -// meeting row from the user's highlight color preference. Falls back -// to the input string unchanged if it isn't a recognised hex. -function hexToRgba(hex: string, alpha: number): string { - const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex); - const m6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); - let r = 0; - let g = 0; - let b = 0; - if (m6) { - r = parseInt(m6[1], 16); - g = parseInt(m6[2], 16); - b = parseInt(m6[3], 16); - } else if (m3) { - r = parseInt(m3[1] + m3[1], 16); - g = parseInt(m3[2] + m3[2], 16); - b = parseInt(m3[3] + m3[3], 16); - } else { - return hex; - } - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -} - -// Small popover-driven control attached to a row (in the # cell when -// not merged, or to the merged cell itself) that lets editors pick a -// merge range or clear an existing one. Each option corresponds to a -// fixed [start..end] column range. -function MergeMenu({ - meetingId, - // True iff the row is rendered as a merged cell *right now*. Used - // for testid wiring only. - isMerged = false, - // True iff the row has a stored merge in the DB (regardless of - // whether it's currently renderable as merged). Drives the - // "Unmerge" option so users can always clear stored merges, even - // when the visible columns aren't contiguous. - hasStoredMerge, - // Existing merge text from the DB, preserved when the user picks a - // new merge range so we never silently overwrite their content. - existingMergeText, - t, - onChange, -}: { - meetingId: number; - isMerged?: boolean; - hasStoredMerge: boolean; - existingMergeText: string | null; - t: (k: string) => string; - onChange: ( - merge: - | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } - | null, - ) => Promise; -}) { - // Re-merging keeps the existing text so users don't accidentally lose - // what they had typed. New merges (no stored text) start empty. - const apply = async (start: ColumnId, end: ColumnId) => { - await onChange({ - mergeStartColumn: start, - mergeEndColumn: end, - mergeText: existingMergeText ?? "", - }); - }; - return ( - - - - - - - - - {hasStoredMerge && ( - - )} - - - ); -} - -function TimeRangeCell({ - meeting, - t, - lang, - canMutate, - onSaveTimes, -}: { - meeting: Meeting; - t: (k: string) => string; - lang: "ar" | "en"; - canMutate: boolean; - onSaveTimes: ( - startTime: string | null, - endTime: string | null, - ) => Promise; -}) { - const { toast } = useToast(); - const startSaved = meeting.startTime ? meeting.startTime.slice(0, 5) : ""; - const endSaved = meeting.endTime ? meeting.endTime.slice(0, 5) : ""; - - const [editing, setEditing] = useState(false); - const [start, setStart] = useState(startSaved); - const [end, setEnd] = useState(endSaved); - const wrapperRef = useRef(null); - const blurTimerRef = useRef | null>(null); - const startInputRef = useRef(null); - - // If the saved times change while we're not editing (e.g. another tab - // updated them, or the user picked a different date), sync local state. - useEffect(() => { - if (!editing) { - setStart(startSaved); - setEnd(endSaved); - } - }, [editing, startSaved, endSaved]); - - useEffect(() => { - if (editing) { - startInputRef.current?.focus(); - } - }, [editing]); - - const enterEdit = useCallback(() => { - if (!canMutate) return; - setEditing(true); - }, [canMutate]); - - const cancel = useCallback(() => { - setStart(startSaved); - setEnd(endSaved); - setEditing(false); - }, [startSaved, endSaved]); - - const save = useCallback(async () => { - const newStart = start || null; - const newEnd = end || null; - if (newStart === (startSaved || null) && newEnd === (endSaved || null)) { - setEditing(false); - return; - } - if (newStart && newEnd && newStart > newEnd) { - // Surface the validation error and keep the user in edit mode so they - // can correct it without losing the values they just typed. - toast({ - title: t("executiveMeetings.schedule.timeOrderError"), - variant: "destructive", - }); - startInputRef.current?.focus(); - return; - } - setEditing(false); - try { - await onSaveTimes(newStart, newEnd); - } catch { - // The parent `saveTimes` handler is responsible for surfacing the - // error toast; we only need to roll back the local draft state so - // the cell shows the previously-saved values. - setStart(startSaved); - setEnd(endSaved); - } - }, [start, end, startSaved, endSaved, onSaveTimes]); - - const onFocusOut = (e: React.FocusEvent) => { - if (!editing) return; - const next = e.relatedTarget as Node | null; - if (next && wrapperRef.current?.contains(next)) return; - if (blurTimerRef.current) clearTimeout(blurTimerRef.current); - blurTimerRef.current = setTimeout(() => { - void save(); - }, 150); - }; - - const onFocusIn = () => { - if (blurTimerRef.current) { - clearTimeout(blurTimerRef.current); - blurTimerRef.current = null; - } - }; - - if (!editing) { - const hasAny = !!(meeting.startTime || meeting.endTime); - const display = hasAny - ? `${formatTime(meeting.startTime, lang)} – ${formatTime(meeting.endTime, lang)}` - : "—"; - return ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - enterEdit(); - } - }} - data-testid={`em-time-${meeting.id}`} - > - {display} -
- ); - } - - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - cancel(); - } else if (e.key === "Enter") { - e.preventDefault(); - void save(); - } - }; - - return ( -
- setStart(e.target.value)} - onKeyDown={onKeyDown} - aria-label={t("executiveMeetings.schedule.timeStart")} - className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white" - data-testid={`em-time-start-${meeting.id}`} - /> - - setEnd(e.target.value)} - onKeyDown={onKeyDown} - aria-label={t("executiveMeetings.schedule.timeEnd")} - className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white" - data-testid={`em-time-end-${meeting.id}`} - /> - - -
- ); -} - -function AttendeesCell({ - meeting, - t, - canMutate = false, - onSaveAttendeeName, - pendingAttendee = null, - onStartAddAttendee, - onCommitAddAttendee, - onCancelAddAttendee, -}: { - meeting: Meeting; - t: (k: string) => string; - canMutate?: boolean; - onSaveAttendeeName?: (idx: number, html: string) => Promise; - pendingAttendee?: { - meetingId: number; - attendanceType: Attendee["attendanceType"]; - key: number; - } | null; - onStartAddAttendee?: ( - meetingId: number, - attendanceType: Attendee["attendanceType"], - ) => void; - onCommitAddAttendee?: ( - attendanceType: Attendee["attendanceType"], - html: string, - ) => Promise; - onCancelAddAttendee?: () => void; -}) { - // Build (attendee, originalIndex) tuples so attendees can be sliced into - // virtual/internal/external groups while still referring back to their - // index in `meeting.attendees` (needed by the PUT save endpoint). - const tagged = meeting.attendees.map((a, i) => ({ a, i })); - const virtual = tagged.filter(({ a }) => a.attendanceType === "virtual"); - const internal = tagged.filter(({ a }) => a.attendanceType === "internal"); - const external = tagged.filter(({ a }) => a.attendanceType === "external"); - const hasSplit = - virtual.length > 0 && (internal.length > 0 || external.length > 0); - - const platformLabel: Record = { - none: "", - webex: "Webex", - teams: "Teams", - zoom: "Zoom", - other: t("executiveMeetings.virtual"), - }; - - // pendingAttendee is GLOBAL — it's the single ghost row open anywhere on - // the page. So we must distinguish: - // * "is the ghost in THIS row, in THIS group?" — controls whether to - // render the ghost EditableCell here. - // * "is a ghost open ANYWHERE on the page?" — controls whether to hide - // the "+" buttons in this row, so the user can't orphan their typing - // by clicking "+" in a different row while one is already open. - const isPendingForThisMeeting = - !!pendingAttendee && pendingAttendee.meetingId === meeting.id; - const isPendingFor = (type: Attendee["attendanceType"]) => - isPendingForThisMeeting && pendingAttendee!.attendanceType === type; - const hasAnyPending = !!pendingAttendee; - - const startAdd = onStartAddAttendee - ? (type: Attendee["attendanceType"]) => - onStartAddAttendee(meeting.id, type) - : undefined; - - const commitAdd = onCommitAddAttendee; - const cancelAdd = onCancelAddAttendee; - - const flowProps = { - canMutate, - onSaveAttendeeName, - isPending: false, - pendingKey: pendingAttendee?.key ?? 0, - onStartAdd: hasAnyPending ? undefined : startAdd, - onCommitAdd: commitAdd, - onCancelAdd: cancelAdd, - addLabel: t("executiveMeetings.schedule.addAttendee"), - editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"), - newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"), - meetingId: meeting.id, - }; - - if (hasSplit) { - return ( -
- {(virtual.length > 0 || isPendingFor("virtual")) && ( - - )} - {(internal.length > 0 || isPendingFor("internal")) && ( - - )} - {(external.length > 0 || isPendingFor("external")) && ( - - )} - {/* Add chips for groups that don't yet exist for this meeting. */} - {canMutate && !hasAnyPending && startAdd && ( -
- {virtual.length === 0 && ( - startAdd("virtual")} - label={t("executiveMeetings.schedule.addVirtualAttendee")} - testId={`em-add-attendee-virtual-${meeting.id}`} - /> - )} - {internal.length === 0 && ( - startAdd("internal")} - label={t("executiveMeetings.schedule.addInternalAttendee")} - testId={`em-add-attendee-internal-${meeting.id}`} - /> - )} - {external.length === 0 && ( - startAdd("external")} - label={t("executiveMeetings.schedule.addExternalAttendee")} - testId={`em-add-attendee-external-${meeting.id}`} - /> - )} -
- )} -
- ); - } - - // Single-group cases: meeting.platform-only virtual list, all-internal, - // all-external, or empty. Pick a sensible default add type so the user - // can add a first attendee without reaching for the request modal. - const singleGroupType: Attendee["attendanceType"] = - meeting.platform !== "none" && virtual.length > 0 - ? "virtual" - : tagged[0]?.a.attendanceType ?? "internal"; - - if (meeting.platform !== "none" && virtual.length > 0) { - return ( - - ); - } - - // Empty cell or single-type cell — render flow without a header. - return ( - - ); -} - -type AttendeeWithIndex = { a: Attendee; i: number }; - -function AddAttendeeChip({ - onClick, - label, - testId, -}: { - onClick: () => void; - label: string; - testId: string; -}) { - return ( - - ); -} - -type AttendeeFlowSharedProps = { - canMutate?: boolean; - onSaveAttendeeName?: (idx: number, html: string) => Promise; - addType: Attendee["attendanceType"]; - isPending: boolean; - pendingKey: number; - onStartAdd?: (type: Attendee["attendanceType"]) => void; - onCommitAdd?: ( - type: Attendee["attendanceType"], - html: string, - ) => Promise; - onCancelAdd?: () => void; - addLabel: string; - editAriaLabel: string; - newAriaLabel: string; - meetingId: number; -}; - -function AttendeeGroup({ - label, - items, - ...flowProps -}: { label: string; items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) { - return ( -
- {/* The group label is purely informational — make it pointer-events:none - so a near-miss tap on the label still falls through to whatever is - underneath (e.g. an attendee name or the cell background). */} -
- — {label} — -
- -
- ); -} - -function AttendeeFlow({ - items, - canMutate = false, - onSaveAttendeeName, - addType, - isPending, - pendingKey, - onStartAdd, - onCommitAdd, - onCancelAdd, - addLabel, - editAriaLabel, - newAriaLabel, -}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) { - const editable = canMutate && !!onSaveAttendeeName; - const showAdd = canMutate && !!onStartAdd && !isPending; - - if (items.length === 0 && !showAdd && !isPending) { - return ; - } - - return ( -
    - {items.map(({ a, i }, displayIdx) => ( -
  • - {displayIdx + 1}- - {editable ? ( - onSaveAttendeeName!(i, html)} - ariaLabel={editAriaLabel} - dir="auto" - placeholder="…" - // Always-visible dashed underline gives each attendee name a - // discoverable click target even when names wrap onto multiple - // lines. The vertical padding + min-height widen the tap - // target so a near-miss tap on an iPad/touch device still - // lands on the editor. Hidden in edit mode (the editor draws - // its own border) and on print. - className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0" - testId={`em-edit-attendee-${i}`} - /> - ) : ( - - )} -
  • - ))} - {isPending && onCommitAdd && ( -
  • - {items.length + 1}- - onCommitAdd(addType, html)} - ariaLabel={newAriaLabel} - dir="auto" - placeholder="…" - className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0" - testId={`em-add-attendee-pending-input-${addType}`} - startInEditMode - // Force a save call on blur even when the user typed nothing, - // so the parent can clear the pending ghost row. - forceSaveOnBlur - // Escape should also clear the ghost. - onCancel={onCancelAdd} - /> -
  • - )} - {showAdd && ( -
  • - -
  • - )} -
- ); -} - -// ============ MANAGE ============ - -type MeetingFormState = { - id: number | null; - titleAr: string; - titleEn: string; - meetingDate: string; - dailyNumber: string; - startTime: string; - endTime: string; - location: string; - meetingUrl: string; - platform: Meeting["platform"]; - status: string; - isHighlighted: boolean; - notes: string; - attendees: Attendee[]; -}; - -function emptyMeetingForm(date: string): MeetingFormState { - return { - id: null, - titleAr: "", - titleEn: "", - meetingDate: date, - dailyNumber: "", - startTime: "", - endTime: "", - location: "", - meetingUrl: "", - platform: "none", - status: "scheduled", - isHighlighted: false, - notes: "", - attendees: [], - }; -} - -function ManageSection({ - date, - onDateChange, - canMutate, - isRtl, - t, -}: { - date: string; - onDateChange: (d: string) => void; - canMutate: boolean; - isRtl: boolean; - t: (k: string) => string; -}) { - const qc = useQueryClient(); - const { toast } = useToast(); - const [editing, setEditing] = useState(null); - const [saving, setSaving] = useState(false); - const [deletingId, setDeletingId] = useState(null); - const [duplicating, setDuplicating] = useState<{ - id: number; - targetDate: string; - } | null>(null); - const [duplicatingBusy, setDuplicatingBusy] = useState(false); - - const { data, isLoading } = useQuery({ - queryKey: ["/api/executive-meetings", date], - queryFn: () => apiJson(`/api/executive-meetings?date=${date}`), - }); - const meetings = data?.meetings ?? []; - - function openCreate() { - setEditing(emptyMeetingForm(date)); - } - function openDuplicate(m: Meeting) { - setDuplicating({ id: m.id, targetDate: date }); - } - async function performDuplicate() { - if (!duplicating) return; - setDuplicatingBusy(true); - try { - await apiJson(`/api/executive-meetings/${duplicating.id}/duplicate`, { - method: "POST", - body: { targetDate: duplicating.targetDate }, - }); - toast({ title: t("executiveMeetings.manage.duplicated") }); - const newDate = duplicating.targetDate; - setDuplicating(null); - if (newDate === date) { - await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] }); - } else { - onDateChange(newDate); - } - } catch (err) { - toast({ - title: t("executiveMeetings.manage.duplicateFailed"), - description: err instanceof Error ? err.message : String(err), - variant: "destructive", - }); - } finally { - setDuplicatingBusy(false); - } - } - function openEdit(m: Meeting) { - setEditing({ - id: m.id, - titleAr: m.titleAr, - titleEn: m.titleEn ?? "", - meetingDate: m.meetingDate, - dailyNumber: String(m.dailyNumber), - startTime: m.startTime ? m.startTime.slice(0, 5) : "", - endTime: m.endTime ? m.endTime.slice(0, 5) : "", - location: m.location ?? "", - meetingUrl: m.meetingUrl ?? "", - platform: m.platform, - status: m.status, - isHighlighted: m.isHighlighted === 1, - notes: m.notes ?? "", - attendees: m.attendees.map((a) => ({ ...a })), - }); - } - - async function save() { - if (!editing) return; - if (!editing.titleAr.trim() || !editing.titleEn.trim()) { - toast({ - title: t("executiveMeetings.manage.errors.titleRequired"), - variant: "destructive", - }); - return; - } - setSaving(true); - const body: Record = { - titleAr: editing.titleAr.trim(), - titleEn: editing.titleEn.trim(), - meetingDate: editing.meetingDate, - startTime: editing.startTime || null, - endTime: editing.endTime || null, - location: editing.location.trim() || null, - meetingUrl: editing.meetingUrl.trim() || null, - platform: editing.platform, - status: editing.status, - isHighlighted: editing.isHighlighted ? 1 : 0, - notes: editing.notes.trim() || null, - attendees: editing.attendees.map((a, idx) => ({ - name: a.name, - title: a.title, - attendanceType: a.attendanceType, - sortOrder: idx, - })), - }; - if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber); - try { - if (editing.id == null) { - await apiJson(`/api/executive-meetings`, { method: "POST", body }); - } else { - await apiJson(`/api/executive-meetings/${editing.id}`, { - method: "PATCH", - body, - }); - } - toast({ title: t("executiveMeetings.common.saved") }); - setEditing(null); - await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("executiveMeetings.manage.errors.saveFailed"), - description: msg, - variant: "destructive", - }); - } finally { - setSaving(false); - } - } - - async function confirmDelete(id: number) { - if (!window.confirm(t("executiveMeetings.manage.deleteConfirm"))) return; - setDeletingId(id); - try { - await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }); - await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast({ title: msg, variant: "destructive" }); - } finally { - setDeletingId(null); - } - } - - if (!canMutate) { - return ; - } - - return ( -
-
-

- {t("executiveMeetings.manage.heading")} -

-
- - -
-
- -
- - - - - - - - - - - - {isLoading && ( - - - - )} - {!isLoading && meetings.length === 0 && ( - - - - )} - {meetings.map((m) => ( - - - - - - - - ))} - -
#{t("executiveMeetings.col.meeting")}{t("executiveMeetings.col.time")}{t("executiveMeetings.manage.field.status")}{t("executiveMeetings.common.actions")}
- {t("common.loading")} -
- {t("executiveMeetings.manage.noMeetings")} -
{m.dailyNumber} -
- {isRtl ? m.titleAr : m.titleEn || m.titleAr} -
-
- {m.attendees.length} · {m.platform} -
-
- {formatTime(m.startTime, isRtl ? "ar" : "en")} — {formatTime(m.endTime, isRtl ? "ar" : "en")} - - {t(`executiveMeetings.manage.status.${m.status}`)} - -
- - - -
-
-
- - {editing && ( - setEditing(null)} - saving={saving} - t={t} - /> - )} - - !o && setDuplicating(null)} - > - - - - {t("executiveMeetings.manage.duplicateToDate")} - - - - - setDuplicating( - duplicating - ? { ...duplicating, targetDate: e.target.value } - : null, - ) - } - data-testid="em-duplicate-date" - /> - - - - - - - -
- ); -} - -function MeetingFormDialog({ - state, - onChange, - onSave, - onClose, - saving, - t, -}: { - state: MeetingFormState; - onChange: (s: MeetingFormState) => void; - onSave: () => void; - onClose: () => void; - saving: boolean; - t: (k: string) => string; -}) { - function update(k: K, v: MeetingFormState[K]) { - onChange({ ...state, [k]: v }); - } - function addAttendee() { - onChange({ - ...state, - attendees: [ - ...state.attendees, - { name: "", title: null, attendanceType: "internal", sortOrder: state.attendees.length }, - ], - }); - } - function removeAttendee(i: number) { - const arr = state.attendees.slice(); - arr.splice(i, 1); - onChange({ ...state, attendees: arr }); - } - function setAttendee(i: number, patch: Partial) { - const arr = state.attendees.slice(); - arr[i] = { ...arr[i], ...patch } as Attendee; - onChange({ ...state, attendees: arr }); - } - function moveAttendee(i: number, dir: -1 | 1) { - const j = i + dir; - if (j < 0 || j >= state.attendees.length) return; - const arr = state.attendees.slice(); - [arr[i], arr[j]] = [arr[j], arr[i]]; - onChange({ ...state, attendees: arr }); - } - - return ( - !o && onClose()}> - - - - {state.id == null - ? t("executiveMeetings.manage.addMeeting") - : t("executiveMeetings.manage.editMeeting")} - - -
- - update("titleAr", e.target.value)} - data-testid="em-form-titleAr" - /> - - - update("titleEn", e.target.value)} /> - - - update("meetingDate", e.target.value)} - /> - - - update("dailyNumber", e.target.value)} - /> - - - update("startTime", e.target.value)} - /> - - - update("endTime", e.target.value)} - /> - - - update("location", e.target.value)} /> - - - update("meetingUrl", e.target.value)} - /> - - - - - - - - - update("isHighlighted", v)} - /> - - -