feat: comprehensive UI overhaul + budget allocation redesign

Audit & Quality:
- RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties
- A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons
- Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens
- Performance: useMemo on filters, loading="lazy" on 24 images
- CSS: prefers-reduced-motion, removed dead animations

Component Splits:
- PostDetailPanel: 1332→623 lines + 4 sub-components
- ArtefactDetailPanel: 972→590 lines + 1 sub-component

Brand Identity — Rawaj (رواج):
- New name, DM Sans font, deep teal palette (#0d9488)
- Custom SVG logo, forest-tinted dark mode
- All emails branded with app name in subject line

Design Refinement:
- Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats
- Quieter: removed card lift, brand glow, gradient text, mesh backgrounds
- CampaignDetail: prominent budget card, compact team avatars, Lucide icons
- Consistent page titles via Header.jsx, standardized section headers
- Finance page fully i18n'd (20+ hardcoded strings replaced)

Budget Allocation Redesign:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Validation at all levels: main→campaign→track, expenses blocked if insufficient
- Budget request workflow with CEO approval via public link
- BudgetRequests table, CRUD routes, public approval page
- Budget mutex for race condition prevention
- Idempotent migration for existing campaign budgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-15 15:36:19 +03:00
parent 3c857856c5
commit e1d1c392eb
77 changed files with 4351 additions and 2108 deletions
+70 -5
View File
@@ -4,8 +4,8 @@ const nocodb = require('./nocodb');
const { parseApproverIds } = require('./helpers');
const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
const APP_NAME_EN = "Samaya's Digital Hub";
const APP_NAME_AR = 'المركز الرقمي لسمايا';
const APP_NAME_EN = 'Rawaj';
const APP_NAME_AR = 'رواج';
// ─── TRANSLATIONS ───────────────────────────────────────────────
@@ -94,6 +94,21 @@ const t = {
view: { en: 'View', ar: 'عرض' },
viewTask: { en: 'View Task', ar: 'عرض المهمة' },
viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' },
// Budget
budgetRequest: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestHeading: { en: 'Budget Request', ar: 'طلب ميزانية' },
budgetRequestBody: { en: (name, amount) => `<strong>${name}</strong> is requesting <strong>${amount}</strong>.`,
ar: (name, amount) => `يطلب <strong>${name}</strong> مبلغ <strong>${amount}</strong>.` },
budgetJustification: { en: 'Justification', ar: 'المبرر' },
budgetEarmarkedFor: { en: 'Earmarked for', ar: 'مخصص لـ' },
reviewRequest: { en: 'Review Request', ar: 'مراجعة الطلب' },
budgetApproved: { en: 'Budget Request Approved', ar: 'تمت الموافقة على طلب الميزانية' },
budgetApprovedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been approved. Funds are now available.`,
ar: (amount) => `تمت الموافقة على طلب الميزانية بمبلغ <strong>${amount}</strong>. الأموال متاحة الآن.` },
budgetRejected: { en: 'Budget Request Rejected', ar: 'تم رفض طلب الميزانية' },
budgetRejectedBody: { en: (amount) => `Your budget request for <strong>${amount}</strong> has been rejected.`,
ar: (amount) => `تم رفض طلب الميزانية بمبلغ <strong>${amount}</strong>.` },
};
function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; }
@@ -111,7 +126,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
<html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}">
<div style="background:#1e293b;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
<div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
${appName}
</div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
@@ -121,7 +136,7 @@ function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) {
</div>
${ctaText && ctaUrl ? `
<div style="margin:24px 0 8px">
<a href="${ctaUrl}" style="display:inline-block;background:#3b82f6;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
<a href="${ctaUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
</div>` : ''}
</div>
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
@@ -151,8 +166,10 @@ async function getMultipleUsers(userIds) {
}
function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) {
const appName = lang === 'ar' ? APP_NAME_AR : APP_NAME_EN;
const fullSubject = `${appName} ${subject}`;
const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang });
sendMail({ to, subject, html, text })
sendMail({ to, subject: fullSubject, html, text })
.then(() => console.log(`[notifications] Sent "${subject}" to ${to}`))
.catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message));
}
@@ -387,7 +404,52 @@ function notifyUserInvited({ email, name, password, inviterName, lang = 'en' })
});
}
// 11. Budget request → email CEO
function notifyBudgetRequest({ ceoEmail, amount, requesterName, justification, earmarkedFor, approvalUrl }) {
const earmarkHtml = earmarkedFor ? `<p><strong>${tr('budgetEarmarkedFor', 'en')}:</strong> ${earmarkedFor}</p>` : '';
send({
to: ceoEmail, lang: 'en',
subject: `${tr('budgetRequest', 'en')}: ${amount}`,
heading: tr('budgetRequestHeading', 'en'),
bodyHtml: `
<p>${tr('budgetRequestBody', 'en')(requesterName, amount)}</p>
<p><strong>${tr('budgetJustification', 'en')}:</strong> ${justification}</p>
${earmarkHtml}`,
ctaText: tr('reviewRequest', 'en'),
ctaUrl: approvalUrl,
});
}
// 12. Budget approved → notify requester
function notifyBudgetApproved({ request, requesterEmail, requesterLang }) {
const l = requesterLang || 'en';
send({
to: requesterEmail, lang: l,
subject: `${tr('budgetApproved', l)}: ${request.amount}`,
heading: tr('budgetApproved', l),
bodyHtml: `
<p>${tr('budgetApprovedBody', l)(String(request.amount))}</p>
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
ctaText: null, ctaUrl: null,
});
}
// 13. Budget rejected → notify requester
function notifyBudgetRejected({ request, requesterEmail, requesterLang }) {
const l = requesterLang || 'en';
send({
to: requesterEmail, lang: l,
subject: `${tr('budgetRejected', l)}: ${request.amount}`,
heading: tr('budgetRejected', l),
bodyHtml: `
<p>${tr('budgetRejectedBody', l)(String(request.amount))}</p>
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
ctaText: null, ctaUrl: null,
});
}
module.exports = {
renderEmail,
notifyReviewSubmitted,
notifyApproved,
notifyRejected,
@@ -398,4 +460,7 @@ module.exports = {
notifyIssueStatusUpdate,
notifyCampaignCreated,
notifyUserInvited,
notifyBudgetRequest,
notifyBudgetApproved,
notifyBudgetRejected,
};