From 57e82974640b0e936ae13b9b5f1ad225767969c4 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 3 May 2026 14:34:29 +0000 Subject: [PATCH] Task #349: Executive Meetings PDF improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema: add `font_color` (hex, default #000000) and `logo_object_path` to `executive_meeting_font_settings`. Pushed via drizzle-kit. - PDF renderer: - Add `fontColor` to PdfFontPrefs (body cells only; header chrome and logo intentionally ignore it to preserve branding). - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in lockstep with the on-screen swatches; deliberately stop painting the legacy `isHighlighted` overlay so the archived PDF reflects editorial state instead of the viewer's transient cursor. - Add optional `logo: Buffer`. Header now reserves a left-anchored logo box and centers the title in the remaining width; bad image bytes log + fall back to the no-logo layout instead of crashing. - API route: - Extend fontSettingsSchema with strict #RRGGBB regex and /^/objects/$/ regex for logoObjectPath. - resolveFontPrefsForUser now returns { font, logoObjectPath }. - loadLogoBytes downloads the brand asset via ObjectStorageService. - Logo only writable on the global-scope row. - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" / "Meeting Attendance List" per the user's printed sample. - Frontend (executive-meetings.tsx): - FontPrefs gains fontColor; FontSettingsResponse.global gains logoObjectPath; DEFAULT_FONT and effectiveFont updated. - buildFontStyle applies fontColor to on-screen rows. - FontSettingsSection: native color picker + hex text input; logo upload (PNG/JPEG) via @workspace/object-storage-web's useUpload, visible only at the global scope for admins. - Locales: AR/EN keys for fontColor + logo.{label,upload,replace, remove,uploading,uploadFailed,globalOnly}. - Tests: existing font-settings roundtrip extended with fontColor; new test rejects malformed fontColor and non-/objects logo paths. Type-check clean for api-server and tx-os; new font-settings tests pass. Other test failures in the suite pre-date this change. --- ar.json.old | 1463 ++++ artifacts/api-server/src/lib/pdf-renderer.ts | 114 +- .../src/routes/executive-meetings.ts | 100 +- .../tests/executive-meetings.test.mjs | 35 + artifacts/tx-os/src/locales/ar.json | 10 + artifacts/tx-os/src/locales/en.json | 10 + .../tx-os/src/pages/executive-meetings.tsx | 131 +- attached_assets/image_1777818423550.png | Bin 0 -> 27931 bytes 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 +++++++++++++++++ lib/db/src/schema/executive-meetings.ts | 10 + pdf-renderer-base.ts | 657 ++ pdf-renderer.ts.old | 710 ++ schema-base.ts | 215 + tests-base.mjs | 916 ++ 20 files changed, 26597 insertions(+), 28 deletions(-) create mode 100644 ar.json.old create mode 100644 attached_assets/image_1777818423550.png create mode 100644 en.json.old create mode 100644 executive-meetings-base.ts create mode 100644 executive-meetings-page-base.tsx create mode 100644 executive-meetings-schema.ts.old create mode 100644 executive-meetings.test.mjs.old create mode 100644 executive-meetings.ts.old create mode 100644 executive-meetings.tsx.old create mode 100644 pdf-renderer-base.ts create mode 100644 pdf-renderer.ts.old create mode 100644 schema-base.ts create mode 100644 tests-base.mjs diff --git a/ar.json.old b/ar.json.old new file mode 100644 index 00000000..176c03f6 --- /dev/null +++ b/ar.json.old @@ -0,0 +1,1463 @@ +{ + "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/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts index f07707db..b7c150a8 100644 --- a/artifacts/api-server/src/lib/pdf-renderer.ts +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -13,6 +13,23 @@ export type PdfFontPrefs = { fontSize: number; fontWeight: "regular" | "bold"; alignment: "start" | "center"; + // Hex color applied to body cell text. Header cells (white-on-navy) + // and the brand logo intentionally ignore this so the brand styling + // survives even with extreme user color picks. + fontColor: string; +}; + +// Mapping from a row-color palette key (`red`/`amber`/...) to the exact +// fill the on-screen schedule uses for that tint. Keep in lockstep with +// `ROW_COLOR_OPTIONS` in artifacts/tx-os/src/pages/executive-meetings.tsx +// so a meeting tinted "amber" in the UI prints with the same swatch. +const ROW_COLOR_FILL: Record = { + red: "#fee2e2", + amber: "#fef3c7", + green: "#dcfce7", + blue: "#dbeafe", + violet: "#ede9fe", + gray: "#f3f4f6", }; export type PdfMeetingAttendee = { @@ -34,7 +51,18 @@ export type PdfMeeting = { startTime: string | null; endTime: string | null; location: string | null; - isHighlighted: number; + // Legacy "current meeting" highlight overlay (#288). Kept on the + // type for source-compat with older callers, but the renderer now + // intentionally ignores it: per-row tint comes from `rowColor` + // instead, so transient on-screen highlights never get baked into + // the printed archive. + isHighlighted?: number; + // Optional per-meeting palette key from the daily schedule (red / + // amber / green / blue / violet / gray). NULL/undefined = no tint. + // Looked up against ROW_COLOR_FILL above; unknown keys are treated + // as "no tint" rather than throwing so a future palette addition on + // the frontend can roll out without breaking server PDFs. + rowColor?: string | null; attendees: PdfMeetingAttendee[]; }; @@ -51,6 +79,11 @@ export type RenderPdfInput = { time: string; none: string; }; + // Optional brand logo bytes (PNG/JPEG) drawn in the top-left of the + // PDF header. The renderer scales it to fit a small box at the page + // margin and lays the title/date out next to it. Pass `undefined` + // to render the legacy header-without-logo layout. + logo?: Buffer | null; }; // Resolve fonts relative to the bundled dist file (esbuild rewrites @@ -337,6 +370,11 @@ type DrawOpts = { align: "left" | "center" | "right"; baseDirection: "ltr" | "rtl"; mapping: FamilyMapping; + // Hex fill for the text run. Optional so existing internal callers + // (e.g. the title/date header passes that need to stay black or + // navy regardless of the user's fontColor) can keep working without + // a code change. + color?: string; }; // Draw a single line of text using mixed Arabic/Latin runs (in visual @@ -375,7 +413,7 @@ function drawMixedLine( return drawWrappingLine(doc, text, opts); } for (const r of measured) { - doc.font(r.fontKey).fontSize(opts.fontSize).fillColor("black"); + doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black"); doc.text(r.text, cursorX, opts.y, { lineBreak: false, width: r.width + 1, @@ -397,7 +435,7 @@ function drawWrappingLine( const arabicChars = [...text].filter(isArabicChar).length; const dominantArabic = arabicChars * 2 > text.length; const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping); - doc.font(fk).fontSize(opts.fontSize).fillColor("black"); + doc.font(fk).fontSize(opts.fontSize).fillColor(opts.color ?? "black"); // For Arabic text, run bidi on the whole string so the font sees runs // already in visual order — this matches what drawMixedLine does for // single-line cells. @@ -448,18 +486,61 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise doc.on("error", reject); }); - // Header — show the schedule title and the date on its own line. + // Header — show the schedule title (and the brand logo when present) + // on a single horizontal band, then the date on its own line below. + // The logo is anchored at the page's left margin regardless of the + // RTL flag, matching the printed sample the user supplied (logo on + // the left even for the Arabic deck). Title text stays centered in + // the remaining horizontal space so an RTL/LTR mix still feels + // balanced. const titleSize = Math.min(28, input.font.fontSize + 10); + const headerLeftEdge = doc.page.margins.left; + const headerWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const headerTopY = doc.y; + const logoBoxSize = titleSize * 1.8; + let titleX = headerLeftEdge; + let titleWidth = headerWidth; + if (input.logo && input.logo.length > 0) { + try { + // PDFKit's image() places the top-left at (x, y) by default — no + // valign needed for top-anchored placement. + doc.image(input.logo, headerLeftEdge, headerTopY, { + fit: [logoBoxSize, logoBoxSize], + }); + // Reserve the logo footprint plus a small gap so the centered + // title doesn't overlap the artwork. We also reserve the same + // footprint on the trailing edge so the title's geometric center + // matches the header's center, not the post-logo center. + const gap = 12; + titleX = headerLeftEdge + logoBoxSize + gap; + titleWidth = headerWidth - 2 * (logoBoxSize + gap); + if (titleWidth < headerWidth * 0.4) { + // Logo + symmetric reservation ate too much space — fall back + // to anchoring the title to the right of the logo only. + titleX = headerLeftEdge + logoBoxSize + gap; + titleWidth = headerWidth - logoBoxSize - gap; + } + // PDFKit moves doc.y after image() in some paths; pin it back so + // the title sits at the same vertical baseline. + doc.y = headerTopY; + } catch (err) { + // Bad image bytes shouldn't kill the whole PDF — log and proceed + // without the logo so the user still gets their schedule. + console.warn("[pdf-renderer] failed to embed logo, continuing without", err); + } + } drawMixedLine(doc, input.labels.title, { - x: doc.page.margins.left, - y: doc.y, - width: doc.page.width - doc.page.margins.left - doc.page.margins.right, + x: titleX, + y: headerTopY, + width: titleWidth, fontSize: titleSize, weight: "bold", align: "center", baseDirection: baseDir, mapping, }); + // Advance past the taller of (title line, logo box) before the date. + doc.y = headerTopY + Math.max(titleSize * 1.25, input.logo ? logoBoxSize : 0); doc.moveDown(0.4); drawMixedLine(doc, input.date, { x: doc.page.margins.left, @@ -637,10 +718,20 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise } const rowTop = doc.y; - if (meeting.isHighlighted) { - doc.save(); - doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca"); - doc.restore(); + // Per-meeting saved tint (#288). The transient on-screen + // "isHighlighted" overlay is intentionally NOT painted here so the + // archived PDF reflects the meeting's editorial state, not the + // viewer's current-meeting cursor. Unknown palette keys silently + // map to "no tint" so a future palette addition rolls out without + // breaking server PDFs that were generated before the palette + // grew. + if (meeting.rowColor) { + const fill = ROW_COLOR_FILL[meeting.rowColor]; + if (fill) { + doc.save(); + doc.rect(tableX, rowTop, tableWidth, rowHeight).fill(fill); + doc.restore(); + } } // Cell borders. @@ -678,6 +769,7 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise align: c.align, baseDirection: baseDir, mapping, + color: input.font.fontColor, }); y += used; } diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index e7f79097..2a52d1a1 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -304,12 +304,32 @@ const pdfArchiveCreateSchema = z.object({ filePath: z.string().trim().max(500).optional(), }); +// Hex color in #RRGGBB form. Stored verbatim and forwarded to the PDF +// renderer / inline-style attribute, so reject anything that wouldn't +// round-trip safely through CSS or PDFKit's `fillColor`. +const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; +// `/objects/` paths are produced by `ObjectStorageService.normalizeObjectEntityPath`. +// We allow null/empty to mean "no logo" (clear the slot). +const OBJECT_PATH_RE = /^\/objects\/[A-Za-z0-9_\-./]+$/; 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"), + fontColor: z + .string() + .regex(HEX_COLOR_RE, "expected #RRGGBB hex color") + .default("#000000"), + // null = explicit clear (UI's "remove logo" gesture). Undefined = + // not touching the field on this PATCH. Only honored when scope is + // "global"; ignored for per-user rows so users can't override the + // org logo with their own. + logoObjectPath: z + .string() + .regex(OBJECT_PATH_RE, "expected /objects/ path") + .nullable() + .optional(), }); function parseBody( @@ -2544,7 +2564,9 @@ router.delete( // 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 { +async function resolveFontPrefsForUser( + userId: number, +): Promise<{ font: PdfFontPrefs; logoObjectPath: string | null }> { const rows = await db .select() .from(executiveMeetingFontSettingsTable) @@ -2561,13 +2583,37 @@ async function resolveFontPrefsForUser(userId: number): Promise { 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", + font: { + fontFamily: eff?.fontFamily ?? "system", + fontSize: eff?.fontSize ?? 14, + fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular", + alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start", + fontColor: eff?.fontColor ?? "#000000", + }, + // Logo lives at the global scope only — per-user rows ignore the + // field. This keeps the brand asset shared across all viewers and + // avoids each user having to re-upload it. + logoObjectPath: globalRow?.logoObjectPath ?? null, }; } +// Download a `/objects/` path back to a Buffer so PDFKit can embed +// it as a header image. Returns null on any failure (missing object, +// network blip, sidecar offline) — the renderer falls back to the +// header-without-logo layout in that case so we never block the PDF. +async function loadLogoBytes(objectPath: string | null): Promise { + if (!objectPath) return null; + try { + const objectStorage = new ObjectStorageService(); + const file = await objectStorage.getObjectEntityFile(objectPath); + const [buf] = await file.download(); + return buf; + } catch (err) { + console.warn("[executive-meetings] failed to load brand logo", err); + return null; + } +} + // 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. @@ -2631,11 +2677,15 @@ router.get( } } - const font = await resolveFontPrefsForUser(userId); + const { font, logoObjectPath } = await resolveFontPrefsForUser(userId); + const logo = await loadLogoBytes(logoObjectPath); + // The Arabic title comes verbatim from the printed sample the user + // provided ("قائمة بأسماء حضور الاجتماعات"). The English label + // mirrors its meaning for bilingual exports. const labels = lang === "ar" ? { - title: "جدول الاجتماعات التنفيذية", + title: "قائمة بأسماء حضور الاجتماعات", no: "م", meeting: "الاجتماع", attendees: "الحضور", @@ -2643,7 +2693,7 @@ router.get( none: "لا توجد اجتماعات في هذا اليوم.", } : { - title: "Executive Meetings Schedule", + title: "Meeting Attendance List", no: "#", meeting: "Meeting", attendees: "Attendees", @@ -2658,6 +2708,7 @@ router.get( lang, font, labels, + logo, meetings: meetings.map((m) => ({ dailyNumber: m.dailyNumber, titleAr: m.titleAr, @@ -2665,7 +2716,12 @@ router.get( startTime: m.startTime, endTime: m.endTime, location: m.location, - isHighlighted: m.isHighlighted ?? 0, + // Forward the saved per-meeting tint (red/amber/green/...) + // so the printed PDF matches the on-screen schedule. The + // legacy `isHighlighted` overlay is intentionally NOT + // forwarded — it represents the viewer's transient + // "current meeting" cursor, not editorial state. + rowColor: m.rowColor, attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({ name: a.name, title: a.title, @@ -2867,6 +2923,10 @@ const upsertFontSettingsHandler = async ( } } const targetUserId = data.scope === "global" ? null : userId; + // Logo is a brand asset and only lives on the global row. Per-user + // PATCHes silently drop the field rather than 400-ing so a generic + // "save my prefs" UI doesn't have to special-case the toggle. + const logoForWrite = data.scope === "global" ? data.logoObjectPath : undefined; const result = await db.transaction(async (tx) => { const [existing] = await tx .select() @@ -2881,14 +2941,22 @@ const upsertFontSettingsHandler = async ( ); let row; if (existing) { + const updateSet: Partial = { + fontFamily: data.fontFamily, + fontSize: data.fontSize, + fontWeight: data.fontWeight, + alignment: data.alignment, + fontColor: data.fontColor, + }; + // Only touch logo when the field was provided AND we're on the + // global row. `null` is a real value (user clicked "remove + // logo") so we have to distinguish from `undefined`. + if (logoForWrite !== undefined) { + updateSet.logoObjectPath = logoForWrite; + } const [updated] = await tx .update(executiveMeetingFontSettingsTable) - .set({ - fontFamily: data.fontFamily, - fontSize: data.fontSize, - fontWeight: data.fontWeight, - alignment: data.alignment, - }) + .set(updateSet) .where(eq(executiveMeetingFontSettingsTable.id, existing.id)) .returning(); row = updated; @@ -2900,6 +2968,8 @@ const upsertFontSettingsHandler = async ( fontSize: data.fontSize, fontWeight: data.fontWeight, alignment: data.alignment, + fontColor: data.fontColor, + logoObjectPath: logoForWrite ?? null, }; const [inserted] = await tx .insert(executiveMeetingFontSettingsTable) diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 06b5b84b..7bd01a85 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -1812,6 +1812,7 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async fontSize: 18, fontWeight: "bold", alignment: "center", + fontColor: "#1f2937", }); assert.equal(put.status, 200); @@ -1825,6 +1826,8 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async assert.equal(body.user.fontSize, 18); assert.equal(body.user.fontWeight, "bold"); assert.equal(body.user.alignment, "center"); + assert.equal(body.user.fontColor, "#1f2937", + "fontColor must persist via the user-scope row"); // PATCH alias must hit the same upsert handler. const patch = await api(adminCookie, "PATCH", @@ -1834,6 +1837,7 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async fontSize: 14, fontWeight: "regular", alignment: "start", + fontColor: "#000000", }); assert.equal(patch.status, 200); @@ -1844,6 +1848,37 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async assert.equal(body2.user.fontSize, 14); assert.equal(body2.user.fontWeight, "regular"); assert.equal(body2.user.alignment, "start"); + assert.equal(body2.user.fontColor, "#000000"); +}); + +test("Font settings: rejects malformed fontColor and logoObjectPath", async () => { + // The hex regex on the API must reject non-#RRGGBB inputs so the + // value can be safely round-tripped through CSS and PDFKit. + const badColor = await api(adminCookie, "PATCH", + "/api/executive-meetings/font-settings", { + scope: "user", + fontFamily: "system", + fontSize: 14, + fontWeight: "regular", + alignment: "start", + fontColor: "red", + }); + assert.equal(badColor.status, 400, "non-hex fontColor must 400"); + + // Anything that isn't a /objects/ path should also be rejected + // — we don't want callers smuggling http URLs or relative paths + // into the brand logo slot. + const badPath = await api(adminCookie, "PATCH", + "/api/executive-meetings/font-settings", { + scope: "global", + fontFamily: "system", + fontSize: 14, + fontWeight: "regular", + alignment: "start", + fontColor: "#000000", + logoObjectPath: "https://evil.example.com/logo.png", + }); + assert.equal(badPath.status, 400, "non-/objects path must 400"); }); test("router.param: non-numeric :id returns 404 across endpoints (no crash)", async () => { diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 176c03f6..d4f33ce9 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1420,6 +1420,16 @@ "fontSize": "حجم الخط", "fontWeight": "السماكة", "alignment": "المحاذاة", + "fontColor": "لون الخط", + "logo": { + "label": "شعار المؤسسة (يظهر أعلى ملف PDF)", + "upload": "رفع الشعار", + "replace": "استبدال الشعار", + "remove": "إزالة الشعار", + "uploading": "جاري الرفع...", + "uploadFailed": "فشل رفع الشعار", + "globalOnly": "يحفظ الشعار للمؤسسة بأكملها." + }, "weight": { "regular": "عادي", "medium": "متوسط", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 77748c85..614eae72 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1286,6 +1286,16 @@ "fontSize": "Font size", "fontWeight": "Weight", "alignment": "Alignment", + "fontColor": "Font color", + "logo": { + "label": "Organization logo (appears at the top of the PDF)", + "upload": "Upload logo", + "replace": "Replace logo", + "remove": "Remove logo", + "uploading": "Uploading...", + "uploadFailed": "Logo upload failed", + "globalOnly": "The logo is saved for the whole organization." + }, "weight": { "regular": "Regular", "medium": "Medium", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 1654fbb3..b94a2190 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -80,6 +80,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; +import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { useToast } from "@/hooks/use-toast"; import { ToastAction } from "@/components/ui/toast"; import { @@ -205,10 +206,20 @@ type FontPrefs = { fontSize: number; fontWeight: "regular" | "bold"; alignment: "start" | "center"; + // Hex color (#RRGGBB) the API now persists alongside the other font + // prefs. Default "#000000" matches the historical hard-coded black. + fontColor: string; }; +// Brand asset stored only on the global-scope row. Per-user rows return +// null here; the UI hides the upload control unless the viewer has +// global edit rights. +type GlobalFontExtras = { logoObjectPath: string | null }; + type FontSettingsResponse = { - global: ({ scope: "global"; userId: null } & FontPrefs) | null; + global: + | ({ scope: "global"; userId: null } & FontPrefs & GlobalFontExtras) + | null; user: ({ scope: "user"; userId: number } & FontPrefs) | null; }; @@ -289,6 +300,7 @@ const DEFAULT_FONT: FontPrefs = { fontSize: 14, fontWeight: "regular", alignment: "start", + fontColor: "#000000", }; type ColumnId = "number" | "meeting" | "attendees" | "time"; @@ -428,6 +440,7 @@ function buildFontStyle(prefs: FontPrefs): CSSProperties { fontSize: prefs.fontSize, fontWeight: fontWeightToCss(prefs.fontWeight), textAlign: prefs.alignment, + color: prefs.fontColor, }; } @@ -785,6 +798,7 @@ function ExecutiveMeetingsPageInner() { fontSize: src.fontSize, fontWeight: src.fontWeight, alignment: src.alignment, + fontColor: src.fontColor ?? "#000000", }; }, [fontResp]); @@ -931,6 +945,7 @@ function ExecutiveMeetingsPageInner() { {section === "settings" && me && ( void; @@ -7211,6 +7228,7 @@ function SettingsSection({
@@ -7328,10 +7346,12 @@ function AlertPrefsCard({ t }: { t: (k: string) => string }) { function FontSettingsSection({ font, + globalLogoObjectPath, canEditGlobal, t, }: { font: FontPrefs; + globalLogoObjectPath: string | null; canEditGlobal: boolean; t: (k: string) => string; }) { @@ -7339,18 +7359,47 @@ function FontSettingsSection({ const { toast } = useToast(); const [scope, setScope] = useState<"user" | "global">("user"); const [prefs, setPrefs] = useState(font); + // Local mirror of the org-wide logo path. Tracks server state but + // also reflects in-flight upload/clear gestures so the preview + // updates immediately without waiting for the next refetch. + const [logoPath, setLogoPath] = useState(globalLogoObjectPath); const [saving, setSaving] = useState(false); useEffect(() => { setPrefs(font); }, [font]); + useEffect(() => { + setLogoPath(globalLogoObjectPath); + }, [globalLogoObjectPath]); + + // Logo uploads always target the global brand asset, regardless of + // the scope toggle, because per-user logos aren't a thing. The + // upload completes against object storage; we only persist the + // resulting `/objects/` path when the user clicks "Save" below. + const { uploadFile: uploadLogo, isUploading: isUploadingLogo } = useUpload({ + onSuccess: (resp: UploadResponse) => setLogoPath(resp.objectPath), + onError: (err: Error) => + toast({ + title: t("executiveMeetings.fontSettingsPage.logo.uploadFailed"), + description: err.message, + variant: "destructive", + }), + }); async function save() { setSaving(true); try { + // Body shape: at the user scope we omit logoObjectPath entirely + // (the API ignores it for user rows anyway, but omitting keeps + // the payload tight). At the global scope we always send the + // current path so "remove logo" persists as `null`. + const body: Record = { scope, ...prefs }; + if (scope === "global") { + body.logoObjectPath = logoPath; + } await apiJson(`/api/executive-meetings/font-settings`, { method: "PATCH", - body: { scope, ...prefs }, + body, }); toast({ title: t("executiveMeetings.fontSettingsPage.saved") }); await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/font-settings"] }); @@ -7366,6 +7415,16 @@ function FontSettingsSection({ setPrefs(DEFAULT_FONT); } + function onLogoFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + // Reset the input so picking the same file twice in a row still + // re-fires onChange — otherwise users hit a confusing dead state + // after a failed upload. + e.target.value = ""; + if (!file) return; + void uploadLogo(file); + } + return (

@@ -7471,6 +7530,74 @@ function FontSettingsSection({ + +
+ {/* + Native color picker keeps the UI dependency-free; we mirror + its value into a text input so users (and screen readers, + and Playwright tests) can read/type the hex directly. The + text input is loosely validated — the API re-validates with + a strict /^#[0-9a-fA-F]{6}$/ regex. + */} + setPrefs({ ...prefs, fontColor: e.target.value })} + className="h-9 w-12 rounded border border-gray-300 cursor-pointer" + data-testid="em-font-color" + aria-label={t("executiveMeetings.fontSettingsPage.fontColor")} + /> + setPrefs({ ...prefs, fontColor: e.target.value })} + className="font-mono text-sm w-32" + maxLength={7} + /> +
+
+ {canEditGlobal && scope === "global" && ( + +
+ {logoPath && ( +
+ logo + +
+ )} + +

+ {t("executiveMeetings.fontSettingsPage.logo.globalOnly")} +

+
+
+ )}