14 Commits

Author SHA1 Message Date
Jonathan Miller 88cb3a2fe5 Update MokoSuite → MokoSuiteClient submodule reference
Updated .gitmodules URL and path for the MokoSuite → MokoSuiteClient rename.
2026-06-15 16:49:10 -05:00
Jonathan Miller 29e87cb572 Add 7 admin models — Donors, Donations, Campaigns, Grants, Volunteers, Events, Memberships
All use Joomla 6 BaseDatabaseModel with $this->getDatabase()
2026-06-15 00:49:53 -05:00
Jonathan Miller 33f4da29f9 Add Volunteers/Grants API controllers, EventCalendar and GrantPortal site views
- NpoVolunteersController: list, register, log hours, stats
- NpoGrantsController: grants CRUD, campaigns with progress tracking
- EventCalendar: public monthly view with registration counts
- GrantPortal: public grant listing with summary stats
2026-06-15 00:35:03 -05:00
Jonathan Miller d257afa23e feat: events/volunteers/memberships/grants API controller
NpoEventsController: event listing + registration + check-in, volunteer
hours logging, membership summary, grant pipeline with reports due.
Permission checks on admin-only endpoints (npo.grants, core.manage).
2026-06-13 08:57:23 -05:00
Jonathan Miller 38dabdce76 chore: CI workflow, language files, CSS, JS infrastructure 2026-06-13 08:37:25 -05:00
Jonathan Miller c9c5ff7254 feat: events, volunteers, memberships views + grant/membership/event helpers
GrantHelper: pipeline summary, deadline tracking, report due dates.
MembershipHelper: active members, expiring, revenue summary, renewal.
EventHelper: upcoming events, registration, check-in, capacity tracking.
Admin views: Events list, Volunteers with hours stats, Memberships
with annual dues summary. All with templates.
2026-06-13 08:14:30 -05:00
Jonathan Miller 9f304902f9 feat: public donate page, campaign page with thermometer, volunteer signup
Donate: online donation form with quick-amount buttons, fund/campaign
selection, tribute (in honor/memory), auto tax receipt generation,
contact auto-creation. Campaign page: public thermometer + recent
donors + donate button. Volunteer signup: skills checkboxes, day
availability, auto contact creation + volunteer registration.
2026-06-13 08:01:55 -05:00
Jonathan Miller 7a033da7ed feat: wire ACL permission checks into API controller
NpoDonationsController: requireAuth() checks on listDonations and
createDonation (npo.donations permission). Matches access.xml actions.
2026-06-13 07:33:08 -05:00
Jonathan Miller f97c86bc71 chore: comprehensive config.xml settings + access.xml permissions 2026-06-13 07:20:07 -05:00
Jonathan Miller 2b6d52eeb3 feat: webservices plugin, task scheduler, router, config, access
NpoAutomation task plugin: year-end tax receipts, lapsed donor detection,
grant deadline reminders, pledge follow-up emails.
MokoSuiteNpoApi webservices: 6 CRUD routes (donors, donations, campaigns,
grants, volunteers, events). Router, config.xml, access.xml, updates.xml.
2026-06-13 07:10:16 -05:00
Jonathan Miller 675fff8ca6 feat: donors list, campaigns with thermometers, grants pipeline, donations API
NpoDonationsController API: donations CRUD, campaigns list, thermometer
data, donor list, fundraising summary.
Admin views: Donors list with lifetime giving + level badges, Campaigns
with progress bars + donor counts, Grants pipeline with deadline tracking.
2026-06-13 06:53:08 -05:00
Jonathan Miller aee7bd071d chore: add MokoSuite base as direct submodule for packaging 2026-06-12 02:26:17 -05:00
Jonathan Miller 63e7e8a922 chore: add MokoSuiteCRM as git submodule for layer packaging 2026-06-12 02:20:31 -05:00
Jonathan Miller 72c6d9a0e6 feat: initial scaffold — MokoSuiteNPO nonprofit management
Layer 2 add-on for MokoSuite CRM. Full nonprofit operations:

Schema (12 tables):
- Donors (extends CRM contacts with giving levels + lifetime stats)
- Donations (cash/check/card/ACH/stock/crypto/in-kind/pledge/matching)
- Pledges (installment tracking with fulfilment status)
- Funds (unrestricted/temporarily/permanently restricted/endowment)
- Campaigns (fundraising drives with goals and thermometers)
- Grants (prospect > writing > submitted > awarded > reporting lifecycle)
- Volunteers (skills, availability, background checks)
- Volunteer Hours (activity logging with approval)
- Memberships (levels, dues, auto-renew)
- Tax Receipts (IRS-compliant acknowledgment letters)
- Events (galas, auctions, community events with registration)
- Event Registrations (tickets, dietary, table assignment)

Helpers (4):
- DonorHelper: profiles, giving history, level auto-calculation, lapsed detection
- CampaignHelper: active campaigns, thermometer data, fundraising summary
- VolunteerHelper: registration, hours logging, skills matching, reporting
- TaxReceiptHelper: IRS-compliant receipt generation, year-end batch

Infrastructure:
- Joomla 6 architecture (PHP 8.3+, DI container, typed properties)
- Admin dashboard with fundraising thermometers + grant deadlines
- 6 webservice routes (donors, donations, campaigns, grants, volunteers, events)
- Package manifest with DLID update server
- GPL-3.0 license
2026-06-12 00:41:21 -05:00
66 changed files with 3225 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Build Package
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Build package ZIP
run: |
cd source
# Create individual package ZIPs
for pkg_dir in packages/*/; do
pkg_name=$(basename "$pkg_dir")
cd "$pkg_dir"
zip -r "../../${pkg_name}.zip" . -x "*.git*"
cd ../..
done
# Create main package ZIP with all sub-packages + manifest
zip -j "pkg_mokosuitenpo.zip" pkg_*.xml script.php updates.xml *.zip 2>/dev/null || true
ls -la *.zip
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: source/pkg_mokosuitenpo.zip
generate_release_notes: true
+6
View File
@@ -0,0 +1,6 @@
[submodule "packages/MokoSuiteCRM"]
path = packages/MokoSuiteCRM
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git
[submodule "packages/MokoSuiteClient"]
path = packages/MokoSuiteClient
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git
+14
View File
@@ -0,0 +1,14 @@
# MokoSuite NPO
Nonprofit management for MokoSuite. Layer 2 add-on (requires CRM).
## Features
- Donor management with giving levels
- Donation tracking with fund allocation
- Pledge management
- Campaign/fundraising with thermometers
- Grant lifecycle management
- Volunteer management with hours
- Membership program
- Tax receipt generation (IRS-compliant)
- Event management with registration
- Fund accounting (restricted vs unrestricted)
+1
View File
@@ -0,0 +1 @@
GPL-3.0-or-later - See https://www.gnu.org/licenses/gpl-3.0.html
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokosuitenpo">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="npo.donations" title="Manage Donations" />
<action name="npo.grants" title="Manage Grants" />
</section>
</access>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fieldset name="basic" label="NPO Settings">
<field name="org_name" type="text" default="" label="Organization Name" />
<field name="org_ein" type="text" default="" label="EIN / Tax ID" />
<field name="fiscal_year_start" type="text" default="01-01" label="Fiscal Year Start (MM-DD)" />
</fieldset>
<fieldset name="donations" label="Donations">
<field name="auto_receipt" type="radio" default="1" label="Auto Tax Receipts" class="btn-group btn-group-yesno"><option value="1">JYES</option><option value="0">JNO</option></field>
<field name="receipt_prefix" type="text" default="RCP" label="Receipt Prefix" />
<field name="min_receipt_amount" type="number" default="250" label="Min Receipt Amount ($)" />
</fieldset>
<fieldset name="permissions" label="Permissions">
<field name="rules" type="rules" component="com_mokosuitenpo" section="component" />
</fieldset>
</config>
@@ -0,0 +1,20 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(ComponentInterface::class, function (Container $container) {
$component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class));
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
return $component;
});
}
};
@@ -0,0 +1,11 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
protected $default_view = 'dashboard';
}
@@ -0,0 +1,31 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class CampaignsModel extends BaseDatabaseModel
{
public function getItems(string $status = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('c.*')
->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS raised')
->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS donation_count')
->from($db->quoteName('#__mokosuitenpo_campaigns', 'c'))
->order('c.start_date DESC');
if ($status) $query->where($db->quoteName('c.status') . ' = ' . $db->quote($status));
$db->setQuery($query, 0, $limit);
$campaigns = $db->loadObjectList() ?: [];
foreach ($campaigns as &$c) {
$c->progress_pct = (float) $c->goal > 0 ? round((float) $c->raised / (float) $c->goal * 100, 1) : 0;
}
return $campaigns;
}
}
@@ -0,0 +1,48 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class DonationsModel extends BaseDatabaseModel
{
public function getItems(string $from = '', string $to = '', int $donorId = 0, int $fundId = 0, int $campaignId = 0, int $limit = 50, int $offset = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('d.*, cd.name AS donor_name, f.name AS fund_name, c.title AS campaign_title')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = d.fund_id')
->join('LEFT', $db->quoteName('#__mokosuitenpo_campaigns', 'c') . ' ON c.id = d.campaign_id')
->order('d.donation_date DESC');
if ($from) $query->where($db->quoteName('d.donation_date') . ' >= ' . $db->quote($from));
if ($to) $query->where($db->quoteName('d.donation_date') . ' <= ' . $db->quote($to));
if ($donorId) $query->where('d.donor_id = ' . $donorId);
if ($fundId) $query->where('d.fund_id = ' . $fundId);
if ($campaignId) $query->where('d.campaign_id = ' . $campaignId);
$db->setQuery($query, $offset, $limit);
return $db->loadObjectList() ?: [];
}
public function getSummary(string $from = '', string $to = ''): object
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('COUNT(*) AS total_donations')
->select('COALESCE(SUM(amount), 0) AS total_amount')
->select('COALESCE(AVG(amount), 0) AS avg_amount')
->select('COUNT(DISTINCT donor_id) AS unique_donors')
->from('#__mokosuitenpo_donations');
if ($from) $query->where($db->quoteName('donation_date') . ' >= ' . $db->quote($from));
if ($to) $query->where($db->quoteName('donation_date') . ' <= ' . $db->quote($to));
$db->setQuery($query);
return $db->loadObject() ?: (object) ['total_donations' => 0, 'total_amount' => 0, 'avg_amount' => 0, 'unique_donors' => 0];
}
}
@@ -0,0 +1,52 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class DonorsModel extends BaseDatabaseModel
{
public function getItems(string $search = '', string $type = '', string $level = '', int $limit = 50, int $offset = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('d.*, cd.name AS donor_name, cd.email_to AS email, cd.telephone')
->from($db->quoteName('#__mokosuitenpo_donors', 'd'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->order('d.last_gift_date DESC');
if ($search) {
$query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')
. ' OR ' . $db->quoteName('cd.email_to') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
}
if ($type) $query->where($db->quoteName('d.donor_type') . ' = ' . $db->quote($type));
if ($level) $query->where($db->quoteName('d.donor_level') . ' = ' . $db->quote($level));
$db->setQuery($query, $offset, $limit);
return $db->loadObjectList() ?: [];
}
public function getDonor(int $id): ?object
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('d.*, cd.name AS donor_name, cd.email_to, cd.telephone, cd.address')
->from($db->quoteName('#__mokosuitenpo_donors', 'd'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->where('d.id = ' . $id));
return $db->loadObject();
}
public function getTopDonors(int $limit = 20): array
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('d.*, cd.name AS donor_name')
->from($db->quoteName('#__mokosuitenpo_donors', 'd'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->where($db->quoteName('d.lifetime_giving') . ' > 0')
->order('d.lifetime_giving DESC'), 0, $limit);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,26 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class EventsModel extends BaseDatabaseModel
{
public function getItems(string $status = '', string $from = '', string $to = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('e.*')
->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_event_registrations r WHERE r.event_id = e.id), 0) AS registrations')
->from($db->quoteName('#__mokosuitenpo_events', 'e'))
->order('e.event_date ASC');
if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status));
if ($from) $query->where($db->quoteName('e.event_date') . ' >= ' . $db->quote($from));
if ($to) $query->where($db->quoteName('e.event_date') . ' <= ' . $db->quote($to));
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,41 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class GrantsModel extends BaseDatabaseModel
{
public function getItems(string $status = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('g.*')
->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
->order('g.deadline DESC');
if ($status) $query->where($db->quoteName('g.status') . ' = ' . $db->quote($status));
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
public function getGrant(int $id): ?object
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_grants')->where('id = ' . $id));
return $db->loadObject();
}
public function getSummary(): object
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total')
->select('COALESCE(SUM(CASE WHEN status IN (' . $db->quote('awarded') . ',' . $db->quote('active') . ') THEN amount END), 0) AS awarded')
->select('COALESCE(SUM(CASE WHEN status = ' . $db->quote('pending') . ' THEN amount END), 0) AS pending')
->from('#__mokosuitenpo_grants'));
return $db->loadObject() ?: (object) ['total' => 0, 'awarded' => 0, 'pending' => 0];
}
}
@@ -0,0 +1,40 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class MembershipsModel extends BaseDatabaseModel
{
public function getItems(string $status = '', string $type = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('m.*, cd.name AS member_name, cd.email_to AS email')
->from($db->quoteName('#__mokosuitenpo_memberships', 'm'))
->join('LEFT', $db->quoteName('#__mokosuitenpo_donors', 'd') . ' ON d.id = m.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->order('m.expiry_date ASC');
if ($status) $query->where($db->quoteName('m.status') . ' = ' . $db->quote($status));
if ($type) $query->where($db->quoteName('m.membership_type') . ' = ' . $db->quote($type));
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
public function getExpiringSoon(int $days = 30): array
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('m.*, cd.name AS member_name, cd.email_to')
->from($db->quoteName('#__mokosuitenpo_memberships', 'm'))
->join('LEFT', $db->quoteName('#__mokosuitenpo_donors', 'd') . ' ON d.id = m.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->where($db->quoteName('m.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('m.expiry_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $days . ' DAY)')
->order('m.expiry_date ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,26 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class VolunteersModel extends BaseDatabaseModel
{
public function getItems(string $status = '', string $search = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('v.*, cd.name AS volunteer_name, cd.email_to AS email, cd.telephone')
->select('(SELECT COALESCE(SUM(vh.hours), 0) FROM #__mokosuitenpo_volunteer_hours vh WHERE vh.volunteer_id = v.id) AS total_hours')
->from($db->quoteName('#__mokosuitenpo_volunteers', 'v'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id')
->order('cd.name ASC');
if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status));
if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%'));
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,23 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\View\Campaigns;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public array $campaigns = [];
public object $summary;
public function display($tpl = null): void
{
$this->campaigns = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns();
$this->summary = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getFundraisingSummary();
ToolbarHelper::title('NPO — Campaigns', 'icon-bullhorn');
ToolbarHelper::addNew('campaigns.add');
parent::display($tpl);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public object $donorStats;
public object $fundraising;
public object $volunteerStats;
public array $activeCampaigns = [];
public array $recentDonations = [];
public array $upcomingGrants = [];
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$this->donorStats = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getDonorSummary();
$this->fundraising = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getFundraisingSummary();
$this->volunteerStats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats();
$this->activeCampaigns= \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns();
// Recent donations
$db->setQuery($db->getQuery(true)
->select('d.*, cd.name AS donor_name, don.anonymous_giving')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->order('d.donation_date DESC, d.created DESC'), 0, 10);
$this->recentDonations = $db->loadObjectList() ?: [];
// Upcoming grant deadlines
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_grants')
->where($db->quoteName('status') . ' IN (' . $db->quote('prospect') . ',' . $db->quote('writing') . ')')
->where($db->quoteName('application_deadline') . ' >= CURDATE()')
->order('application_deadline ASC'), 0, 5);
$this->upcomingGrants = $db->loadObjectList() ?: [];
ToolbarHelper::title('MokoSuite NPO Dashboard', 'icon-heart');
parent::display($tpl);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\View\Donors;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public array $donors = [];
public array $filters = [];
public object $stats;
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$this->filters = [
'level' => $input->getString('filter_level', ''),
'type' => $input->getString('filter_type', ''),
'search' => $input->getString('filter_search', ''),
];
$query = $db->getQuery(true)
->select('d.*, cd.name AS donor_name, cd.email_to, cd.telephone')
->from($db->quoteName('#__mokosuitenpo_donors', 'd'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->order('d.lifetime_giving DESC');
if ($this->filters['level']) $query->where($db->quoteName('d.donor_level') . ' = ' . $db->quote($this->filters['level']));
if ($this->filters['type']) $query->where($db->quoteName('d.donor_type') . ' = ' . $db->quote($this->filters['type']));
if ($this->filters['search']) {
$like = $db->quote('%' . $db->escape($this->filters['search'], true) . '%');
$query->where('cd.name LIKE ' . $like);
}
$db->setQuery($query, 0, 100);
$this->donors = $db->loadObjectList() ?: [];
$this->stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getDonorSummary();
ToolbarHelper::title('NPO — Donors', 'icon-heart');
parent::display($tpl);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\View\Events;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public array $events = [];
public function display($tpl = null): void
{
$this->events = \Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::getUpcomingEvents(30);
ToolbarHelper::title('NPO — Events', 'icon-calendar');
ToolbarHelper::addNew('events.add');
parent::display($tpl);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\View\Grants;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public array $grants = [];
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('g.*, f.name AS fund_name')
->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = g.fund_id')
->order('FIELD(g.status,' . $db->quote('prospect') . ',' . $db->quote('writing') . ',' . $db->quote('submitted') . ',' . $db->quote('pending') . ',' . $db->quote('awarded') . ',' . $db->quote('reporting') . ',' . $db->quote('declined') . ',' . $db->quote('closed') . ') ASC, g.application_deadline ASC'));
$this->grants = $db->loadObjectList() ?: [];
ToolbarHelper::title('NPO — Grants', 'icon-file-invoice');
ToolbarHelper::addNew('grants.add');
parent::display($tpl);
}
}
@@ -0,0 +1,23 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\View\Memberships;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public array $members = [];
public object $summary;
public function display($tpl = null): void
{
$this->members = \Moko\Plugin\System\MokoSuiteNpo\Helper\MembershipHelper::getActiveMembers();
$this->summary = \Moko\Plugin\System\MokoSuiteNpo\Helper\MembershipHelper::getSummary();
ToolbarHelper::title('NPO — Memberships', 'icon-id-card');
ToolbarHelper::addNew('memberships.add');
parent::display($tpl);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Administrator\View\Volunteers;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public array $volunteers = [];
public object $stats;
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('v.*, cd.name AS volunteer_name, cd.email_to, cd.telephone')
->from($db->quoteName('#__mokosuitenpo_volunteers', 'v'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id')
->order('cd.name ASC'));
$this->volunteers = $db->loadObjectList() ?: [];
$this->stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats();
ToolbarHelper::title('NPO — Volunteers', 'icon-users');
parent::display($tpl);
}
}
@@ -0,0 +1,6 @@
<?php defined('_JEXEC') or die; $campaigns=$this->campaigns; $summary=$this->summary; ?>
<div class="row g-3 mb-4"><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success">$<?php echo number_format((float)$summary->total_raised,0); ?></div><small>Raised This Year</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$summary->unique_donors; ?></div><small>Donors</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$summary->total_gifts; ?></div><small>Gifts</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold">$<?php echo number_format((float)$summary->avg_gift,0); ?></div><small>Avg Gift</small></div></div></div></div>
<?php foreach($campaigns as $c): ?>
<div class="card shadow-sm mb-3"><div class="card-body"><div class="d-flex justify-content-between mb-1"><strong><?php echo htmlspecialchars($c->title); ?></strong><span><?php echo $c->progress_pct; ?>%</span></div><div class="progress" style="height:20px;"><div class="progress-bar bg-success" style="width:<?php echo $c->progress_pct; ?>%">$<?php echo number_format((float)$c->raised_amount,0); ?> / $<?php echo number_format((float)$c->goal_amount,0); ?></div></div><small class="text-muted"><?php echo (int)$c->donor_count; ?> donors<?php echo $c->days_remaining!==null?" - {$c->days_remaining} days left":""; ?></small></div></div>
<?php endforeach; ?>
<?php if(empty($campaigns)): ?><p class="text-muted text-center">No active campaigns.</p><?php endif; ?>
@@ -0,0 +1,95 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
$ds = $this->donorStats;
$fr = $this->fundraising;
$vs = $this->volunteerStats;
$campaigns = $this->activeCampaigns;
$recent = $this->recentDonations;
$grants = $this->upcomingGrants;
?>
<div class="mokosuitenpo-dashboard">
<!-- Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card shadow-sm border-success"><div class="card-body text-center"><div class="fs-3 fw-bold text-success">$<?php echo number_format((float) $fr->total_raised, 0); ?></div><small class="text-muted">Raised This Year</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int) $ds->total_donors; ?></div><small class="text-muted">Total Donors</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-primary"><?php echo (int) $fr->total_gifts; ?></div><small class="text-muted">Gifts</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold">$<?php echo number_format((float) $fr->avg_gift, 0); ?></div><small class="text-muted">Avg Gift</small></div></div></div>
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-info"><?php echo (int) $vs->active; ?></div><small class="text-muted">Active Volunteers</small><br><small><?php echo number_format((float) $vs->total_hours, 0); ?> hours</small></div></div></div>
</div>
<div class="row g-3">
<!-- Active Campaigns -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">Active Campaigns</h5></div>
<div class="card-body">
<?php foreach ($campaigns as $c) : ?>
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<strong><?php echo $this->escape($c->title); ?></strong>
<span><?php echo $c->progress_pct; ?>%</span>
</div>
<div class="progress" style="height: 20px;">
<div class="progress-bar bg-success" style="width: <?php echo $c->progress_pct; ?>%">
$<?php echo number_format((float) $c->raised_amount, 0); ?> / $<?php echo number_format((float) $c->goal_amount, 0); ?>
</div>
</div>
<small class="text-muted"><?php echo (int) $c->donor_count; ?> donors<?php echo $c->days_remaining !== null ? " &middot; {$c->days_remaining} days left" : ''; ?></small>
</div>
<?php endforeach; ?>
<?php if (empty($campaigns)) : ?><p class="text-muted">No active campaigns.</p><?php endif; ?>
</div>
</div>
</div>
<!-- Recent Donations -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">Recent Donations</h5></div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Donor</th><th>Amount</th><th>Type</th><th>Date</th></tr></thead>
<tbody>
<?php foreach ($recent as $d) : ?>
<tr>
<td><?php echo $d->anonymous_giving ? '<em>Anonymous</em>' : $this->escape($d->donor_name ?? ''); ?></td>
<td class="fw-bold text-success">$<?php echo number_format((float) $d->amount, 2); ?></td>
<td><span class="badge bg-secondary"><?php echo ucfirst($d->donation_type); ?></span></td>
<td class="small text-muted"><?php echo date('M j', strtotime($d->donation_date)); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Grants -->
<?php if (!empty($grants)) : ?>
<div class="card shadow-sm mt-3">
<div class="card-header"><h5 class="mb-0">Upcoming Grant Deadlines</h5></div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Grant</th><th>Funder</th><th>Amount</th><th>Deadline</th><th>Status</th></tr></thead>
<tbody>
<?php foreach ($grants as $g) :
$daysLeft = max(0, round((strtotime($g->application_deadline) - time()) / 86400));
?>
<tr class="<?php echo $daysLeft <= 7 ? 'table-danger' : ''; ?>">
<td><strong><?php echo $this->escape($g->title); ?></strong></td>
<td><?php echo $this->escape($g->funder_name); ?></td>
<td>$<?php echo number_format((float) $g->amount_requested, 0); ?></td>
<td><span class="badge bg-<?php echo $daysLeft <= 7 ? 'danger' : ($daysLeft <= 14 ? 'warning' : 'info'); ?>"><?php echo $daysLeft; ?> days</span></td>
<td><?php echo ucfirst($g->status); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,27 @@
<?php
defined('_JEXEC') or die;
$donors = $this->donors;
$stats = $this->stats;
$f = $this->filters;
$levelColors = ['prospect'=>'secondary','first_time'=>'info','repeat'=>'primary','major'=>'warning','legacy'=>'success','lapsed'=>'danger'];
?>
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$stats->total_donors; ?></div><small>Total Donors</small></div></div></div>
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success">$<?php echo number_format((float)$stats->total_lifetime,0); ?></div><small>Lifetime Giving</small></div></div></div>
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int)$stats->major_donors; ?></div><small>Major Donors</small></div></div></div>
<div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold">$<?php echo number_format((float)$stats->avg_lifetime,0); ?></div><small>Avg Lifetime</small></div></div></div>
</div>
<table class="table table-striped table-hover">
<thead class="table-light"><tr><th>Donor</th><th>Email</th><th>Type</th><th>Level</th><th>Lifetime</th><th>Gifts</th><th>Last Gift</th></tr></thead>
<tbody>
<?php foreach($donors as $d): ?>
<tr><td><strong><?php echo $this->escape($d->donor_name??''); ?></strong></td>
<td class="small"><?php echo $this->escape($d->email_to??''); ?></td>
<td><?php echo ucfirst($d->donor_type); ?></td>
<td><span class="badge bg-<?php echo $levelColors[$d->donor_level]??'secondary'; ?>"><?php echo ucfirst(str_replace('_',' ',$d->donor_level)); ?></span></td>
<td class="fw-bold text-success">$<?php echo number_format((float)$d->lifetime_giving,2); ?></td>
<td><?php echo (int)$d->gift_count; ?></td>
<td class="small"><?php echo $d->last_gift_date?date('M j, Y',strtotime($d->last_gift_date)):'Never'; ?></td></tr>
<?php endforeach; ?>
<?php if(empty($donors)): ?><tr><td colspan="7" class="text-muted text-center py-4">No donors</td></tr><?php endif; ?>
</tbody></table>
@@ -0,0 +1,9 @@
<?php
defined('_JEXEC') or die;
$events=$this->events;
?>
<table class="table table-striped"><thead><tr><th>Event</th><th>Type</th><th>Date</th><th>Registered</th><th>Status</th></tr></thead><tbody>
<?php foreach($events as $e): ?>
<tr><td><?php echo $this->escape($e->title); ?></td><td><?php echo ucfirst($e->event_type); ?></td><td><?php echo date("M j",strtotime($e->start_date)); ?></td><td><?php echo (int)$e->registered; ?></td><td><?php echo ucfirst($e->status); ?></td></tr>
<?php endforeach; ?>
</tbody></table>
@@ -0,0 +1,7 @@
<?php defined('_JEXEC') or die; $grants=$this->grants; $statusColors=["prospect"=>"secondary","writing"=>"info","submitted"=>"primary","pending"=>"warning","awarded"=>"success","declined"=>"danger","reporting"=>"info","closed"=>"dark"]; ?>
<table class="table table-striped table-hover"><thead class="table-light"><tr><th>Grant</th><th>Funder</th><th>Requested</th><th>Awarded</th><th>Status</th><th>Deadline</th></tr></thead><tbody>
<?php foreach($grants as $g): $daysLeft=$g->application_deadline?max(0,round((strtotime($g->application_deadline)-time())/86400)):null; ?>
<tr class="<?php echo $daysLeft!==null&&$daysLeft<=7?"table-danger":""; ?>"><td><strong><?php echo htmlspecialchars($g->title); ?></strong></td><td><?php echo htmlspecialchars($g->funder_name); ?></td><td>$<?php echo number_format((float)$g->amount_requested,0); ?></td><td><?php echo $g->amount_awarded?"$".number_format((float)$g->amount_awarded,0):"&mdash;"; ?></td><td><span class="badge bg-<?php echo $statusColors[$g->status]??"secondary"; ?>"><?php echo ucfirst($g->status); ?></span></td><td><?php echo $g->application_deadline?date("M j",strtotime($g->application_deadline)):"&mdash;"; ?><?php if($daysLeft!==null&&$daysLeft<=30): ?> <span class="badge bg-<?php echo $daysLeft<=7?"danger":"warning"; ?>"><?php echo $daysLeft; ?>d</span><?php endif; ?></td></tr>
<?php endforeach; ?>
<?php if(empty($grants)): ?><tr><td colspan="6" class="text-muted text-center py-4">No grants</td></tr><?php endif; ?>
</tbody></table>
@@ -0,0 +1,10 @@
<?php
defined('_JEXEC') or die;
$members=$this->members;$sum=$this->summary;
?>
<div class="row g-3 mb-4"><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$sum->total_active; ?></div><small>Active</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success">$<?php echo number_format((float)$sum->annual_revenue,0); ?></div><small>Annual Dues</small></div></div></div></div>
<table class="table table-striped"><thead><tr><th>Member</th><th>Level</th><th>Dues</th><th>Expires</th></tr></thead><tbody>
<?php foreach($members as $m): ?>
<tr><td><?php echo $this->escape($m->member_name); ?></td><td><?php echo ucfirst($m->membership_level); ?></td><td>$<?php echo number_format((float)$m->annual_dues,0); ?></td><td><?php echo $m->end_date?date("M j, Y",strtotime($m->end_date)):"Lifetime"; ?></td></tr>
<?php endforeach; ?>
</tbody></table>
@@ -0,0 +1,10 @@
<?php
defined('_JEXEC') or die;
$vols=$this->volunteers;$s=$this->stats;
?>
<div class="row g-3 mb-4"><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$s->active; ?></div><small>Active</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo number_format((float)$s->total_hours,0); ?></div><small>Hours</small></div></div></div></div>
<table class="table table-striped"><thead><tr><th>Name</th><th>Status</th><th>Hours</th></tr></thead><tbody>
<?php foreach($vols as $v): ?>
<tr><td><?php echo $this->escape($v->volunteer_name); ?></td><td><?php echo ucfirst($v->status); ?></td><td><?php echo number_format((float)$v->total_hours,1); ?></td></tr>
<?php endforeach; ?>
</tbody></table>
@@ -0,0 +1,109 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\Database\DatabaseInterface;
/**
* Donations + Donors + Campaigns API.
*/
class NpoDonationsController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied.']);
Factory::getApplication()->close();
}
}
public function listDonations(): void
{
$this->requireAuth('npo.donations');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$query = $db->getQuery(true)
->select('d.*, cd.name AS donor_name, f.name AS fund_name, c.title AS campaign_title')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = d.fund_id')
->join('LEFT', $db->quoteName('#__mokosuitenpo_campaigns', 'c') . ' ON c.id = d.campaign_id')
->order('d.donation_date DESC');
$campaignId = $input->getInt('campaign_id', 0);
if ($campaignId) $query->where('d.campaign_id = ' . $campaignId);
$fundId = $input->getInt('fund_id', 0);
if ($fundId) $query->where('d.fund_id = ' . $fundId);
$db->setQuery($query, 0, 100);
$this->sendJson($db->loadObjectList() ?: []);
}
public function createDonation(): void
{
$this->requireAuth('npo.donations');
$input = Factory::getApplication()->getInput();
$contactId = $input->getInt('contact_id', 0);
$donor = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getOrCreateDonor($contactId);
$donationId = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::recordDonation(
(int) $donor->id,
$input->getFloat('amount', 0),
$input->getInt('fund_id', 1),
$input->getString('donation_type', 'cash'),
$input->getInt('campaign_id', 0) ?: null,
[
'date' => $input->getString('date', date('Y-m-d')),
'payment_method' => $input->getString('payment_method', ''),
'reference' => $input->getString('reference', ''),
'tribute_type' => $input->getString('tribute_type', ''),
'tribute_name' => $input->getString('tribute_name', ''),
'notes' => $input->getString('notes', ''),
]
);
$this->sendJson(['id' => $donationId, 'message' => 'Donation recorded.']);
}
public function listCampaigns(): void
{
$campaigns = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns();
$this->sendJson($campaigns);
}
public function thermometer(): void
{
$campaignId = Factory::getApplication()->getInput()->getInt('campaign_id', 0);
$data = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getThermometerData($campaignId);
$this->sendJson($data);
}
public function listDonors(): void
{
$donors = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getTopDonors(50);
$this->sendJson($donors);
}
public function fundraisingSummary(): void
{
$year = Factory::getApplication()->getInput()->getInt('year', (int) date('Y'));
$this->sendJson(\Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getFundraisingSummary($year));
}
private function sendJson(mixed $data): void
{
$app = Factory::getApplication();
$app->getDocument()->setMimeEncoding('application/json');
echo json_encode(['data' => $data], JSON_THROW_ON_ERROR);
$app->close();
}
}
@@ -0,0 +1,103 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\Database\DatabaseInterface;
/**
* Events + Volunteers + Memberships API.
*/
class NpoEventsController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied.']);
Factory::getApplication()->close();
}
}
public function listEvents(): void
{
$events = \Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::getUpcomingEvents(50);
$this->sendJson($events);
}
public function registerForEvent(): void
{
$input = Factory::getApplication()->getInput();
$regId = \Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::register(
$input->getInt('event_id', 0), [
'contact_id' => $input->getInt('contact_id', 0),
'name' => $input->getString('name', ''),
'email' => $input->getString('email', ''),
'phone' => $input->getString('phone', ''),
'tickets' => $input->getInt('tickets', 1),
'amount' => $input->getFloat('amount', 0),
'dietary' => $input->getString('dietary', ''),
]
);
$this->sendJson(['id' => $regId, 'message' => 'Registered.']);
}
public function checkIn(): void
{
$this->requireAuth('core.manage');
$regId = Factory::getApplication()->getInput()->getInt('registration_id', 0);
\Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::checkIn($regId);
$this->sendJson(['message' => 'Checked in.']);
}
public function listVolunteers(): void
{
$this->requireAuth('core.manage');
$stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats();
$this->sendJson($stats);
}
public function logVolunteerHours(): void
{
$this->requireAuth('core.manage');
$input = Factory::getApplication()->getInput();
$logId = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::logHours(
$input->getInt('volunteer_id', 0),
$input->getString('activity', ''),
$input->getFloat('hours', 0),
$input->getString('date', date('Y-m-d')),
$input->getString('notes', '')
);
$this->sendJson(['id' => $logId, 'message' => 'Hours logged.']);
}
public function membershipSummary(): void
{
$this->requireAuth('core.manage');
$summary = \Moko\Plugin\System\MokoSuiteNpo\Helper\MembershipHelper::getSummary();
$this->sendJson($summary);
}
public function grantPipeline(): void
{
$this->requireAuth('npo.grants');
$pipeline = \Moko\Plugin\System\MokoSuiteNpo\Helper\GrantHelper::getPipelineSummary();
$reports = \Moko\Plugin\System\MokoSuiteNpo\Helper\GrantHelper::getReportsDue();
$this->sendJson(['pipeline' => $pipeline, 'reports_due' => $reports]);
}
private function sendJson(mixed $data): void
{
$app = Factory::getApplication();
$app->getDocument()->setMimeEncoding('application/json');
echo json_encode(['data' => $data], JSON_THROW_ON_ERROR);
$app->close();
}
}
@@ -0,0 +1,132 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\Database\DatabaseInterface;
/**
* Grants + Campaigns API.
*
* GET /grants — List grants
* GET /grants/{id} — Grant detail with milestones
* POST /grants/{id}/report — Submit grant report
* GET /campaigns — List campaigns with progress
* GET /campaigns/{id} — Campaign detail with donation totals
*/
class NpoGrantsController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied.']);
Factory::getApplication()->close();
}
}
public function listGrants(): void
{
$this->requireAuth('npo.grants');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$query = $db->getQuery(true)
->select('g.*')
->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
->order('g.deadline DESC');
$status = $input->getString('status', '');
if ($status) $query->where($db->quoteName('g.status') . ' = ' . $db->quote($status));
$db->setQuery($query, 0, 100);
$this->sendJson($db->loadObjectList() ?: []);
}
public function getGrant(): void
{
$this->requireAuth('npo.grants');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_grants')
->where('id = ' . $id));
$grant = $db->loadObject();
if (!$grant) {
http_response_code(404);
$this->sendJson(['error' => 'Grant not found']);
return;
}
$this->sendJson($grant);
}
public function listCampaigns(): void
{
$this->requireAuth('npo.campaigns');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('c.*')
->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS raised')
->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS donation_count')
->from($db->quoteName('#__mokosuitenpo_campaigns', 'c'))
->order('c.start_date DESC'));
$campaigns = $db->loadObjectList() ?: [];
foreach ($campaigns as &$c) {
$c->progress_pct = (float) $c->goal > 0 ? round((float) $c->raised / (float) $c->goal * 100, 1) : 0;
}
$this->sendJson($campaigns);
}
public function getCampaign(): void
{
$this->requireAuth('npo.campaigns');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db->setQuery($db->getQuery(true)
->select('c.*')
->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS raised')
->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS donation_count')
->from($db->quoteName('#__mokosuitenpo_campaigns', 'c'))
->where('c.id = ' . $id));
$campaign = $db->loadObject();
if (!$campaign) {
http_response_code(404);
$this->sendJson(['error' => 'Campaign not found']);
return;
}
$campaign->progress_pct = (float) $campaign->goal > 0 ? round((float) $campaign->raised / (float) $campaign->goal * 100, 1) : 0;
// Recent donations
$db->setQuery($db->getQuery(true)
->select('d.*, cd.name AS donor_name')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->where('d.campaign_id = ' . $id)
->order('d.donation_date DESC'), 0, 20);
$campaign->recent_donations = $db->loadObjectList() ?: [];
$this->sendJson($campaign);
}
private function sendJson(mixed $data): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
Factory::getApplication()->close();
}
}
@@ -0,0 +1,135 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\Database\DatabaseInterface;
/**
* Volunteers + Volunteer Hours API.
*
* GET /volunteers — List volunteers
* POST /volunteers — Register volunteer
* GET /volunteers/{id} — Volunteer detail with hours
* POST /volunteers/{id}/log — Log volunteer hours
* GET /volunteer-stats — Volunteer program statistics
*/
class NpoVolunteersController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied.']);
Factory::getApplication()->close();
}
}
public function listVolunteers(): void
{
$this->requireAuth('npo.volunteers');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$query = $db->getQuery(true)
->select('v.*, cd.name AS volunteer_name, cd.email_to AS email, cd.telephone')
->select('(SELECT COALESCE(SUM(vh.hours), 0) FROM #__mokosuitenpo_volunteer_hours vh WHERE vh.volunteer_id = v.id) AS total_hours')
->from($db->quoteName('#__mokosuitenpo_volunteers', 'v'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id')
->order('cd.name ASC');
$status = $input->getString('status', '');
if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status));
$skill = $input->getString('skill', '');
if ($skill) $query->where($db->quoteName('v.skills') . ' LIKE ' . $db->quote('%' . $skill . '%'));
$db->setQuery($query, 0, 100);
$this->sendJson($db->loadObjectList() ?: []);
}
public function register(): void
{
$this->requireAuth('npo.volunteers');
$input = Factory::getApplication()->getInput();
$volunteerId = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::registerVolunteer(
$input->getInt('contact_id', 0),
$input->getString('skills', ''),
$input->getString('availability', ''),
$input->getString('notes', '')
);
$this->sendJson(['success' => true, 'volunteer_id' => $volunteerId]);
}
public function getVolunteer(): void
{
$this->requireAuth('npo.volunteers');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db->setQuery($db->getQuery(true)
->select('v.*, cd.name AS volunteer_name, cd.email_to, cd.telephone')
->from($db->quoteName('#__mokosuitenpo_volunteers', 'v'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id')
->where('v.id = ' . $id));
$volunteer = $db->loadObject();
if (!$volunteer) {
http_response_code(404);
$this->sendJson(['error' => 'Volunteer not found']);
return;
}
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_volunteer_hours')
->where('volunteer_id = ' . $id)
->order('date DESC'), 0, 50);
$volunteer->recent_hours = $db->loadObjectList() ?: [];
$this->sendJson($volunteer);
}
public function logHours(): void
{
$this->requireAuth('npo.volunteers');
$input = Factory::getApplication()->getInput();
$hourId = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::logHours(
$input->getInt('id', 0),
$input->getFloat('hours', 0),
$input->getString('date', date('Y-m-d')),
$input->getString('activity', ''),
$input->getString('notes', '')
);
$this->sendJson(['success' => true, 'hour_id' => $hourId]);
}
public function stats(): void
{
$this->requireAuth('npo.volunteers');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_volunteers')
->select('SUM(CASE WHEN status = ' . $db->quote('active') . ' THEN 1 ELSE 0 END) AS active')
->select('(SELECT COALESCE(SUM(hours), 0) FROM #__mokosuitenpo_volunteer_hours) AS total_hours')
->select('(SELECT COALESCE(SUM(hours), 0) FROM #__mokosuitenpo_volunteer_hours WHERE MONTH(date) = MONTH(CURDATE()) AND YEAR(date) = YEAR(CURDATE())) AS hours_this_month')
->from('#__mokosuitenpo_volunteers'));
$this->sendJson($db->loadObject());
}
private function sendJson(mixed $data): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
Factory::getApplication()->close();
}
}
@@ -0,0 +1,8 @@
/* MokoSuite NPO Styles */
.mokosuitenpo-dashboard .card { border-radius: 0.5rem; }
.donation-amount-btn.active { background-color: #198754 !important; color: #fff !important; }
.thermometer-bar { transition: width 0.8s ease-in-out; }
.donor-level-prospect { color: #6c757d; }
.donor-level-major { color: #ffc107; font-weight: bold; }
.donor-level-legacy { color: #198754; font-weight: bold; }
@media print { .btn, .toolbar { display: none !important; } }
@@ -0,0 +1,10 @@
document.addEventListener('DOMContentLoaded', function() {
var buttons = document.querySelectorAll('.donation-amount-btn, [onclick*="amount"]');
var amountInput = document.getElementById('amount');
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
buttons.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
});
});
});
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="component" method="upgrade">
<name>MokoSuite NPO</name>
<author>Moko Consulting</author>
<creationDate>2026-06-11</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>01.01.00</version>
<php_minimum>8.3</php_minimum>
<description>MokoSuite NPO component</description>
<namespace path="src">Moko\Component\MokoSuiteNpo</namespace>
<administration>
<menu>MokoSuite NPO</menu>
<files folder="admin"><folder>src</folder><folder>services</folder><folder>tmpl</folder></files>
</administration>
<files folder="site"><folder>src</folder><folder>tmpl</folder></files>
<api><files folder="api"><folder>src</folder></files></api>
<media destination="com_mokosuitenpo" folder="media"><folder>css</folder><folder>js</folder></media>
</extension>
@@ -0,0 +1,11 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
protected $default_view = 'donate';
}
@@ -0,0 +1,21 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Component\Router\RouterBase;
class Router extends RouterBase
{
public function build(&$query): array
{
$segments = [];
if (isset($query['view'])) { $segments[] = $query['view']; unset($query['view']); }
if (isset($query['id'])) { $segments[] = $query['id']; unset($query['id']); }
return $segments;
}
public function parse(&$segments): array
{
$vars = [];
if (!empty($segments[0])) $vars['view'] = array_shift($segments);
if (!empty($segments[0]) && is_numeric($segments[0])) $vars['id'] = (int) array_shift($segments);
return $vars;
}
}
@@ -0,0 +1,48 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\View\CampaignPage;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Public campaign page — shows thermometer, recent donors, donate button.
*/
class HtmlView extends BaseHtmlView
{
public ?object $campaign = null;
public object $thermometer;
public array $recentDonors = [];
public function display($tpl = null): void
{
$app = Factory::getApplication();
$campaignId = $app->getInput()->getInt('id', 0);
if (!$campaignId) {
$app->enqueueMessage('Campaign not found.', 'error');
return;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_campaigns')
->where('id = ' . $campaignId)
->where($db->quoteName('public_page') . ' = 1'));
$this->campaign = $db->loadObject();
if (!$this->campaign) {
$app->enqueueMessage('Campaign not found.', 'error');
return;
}
$this->thermometer = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getThermometerData($campaignId);
$this->recentDonors = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getCampaignDonations($campaignId, 10);
parent::display($tpl);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\View\Donate;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Public online donation page — accepts donations with fund/campaign selection.
*/
class HtmlView extends BaseHtmlView
{
public array $campaigns = [];
public array $funds = [];
public object $orgInfo;
public bool $submitted = false;
public function display($tpl = null): void
{
$app = Factory::getApplication();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$params = $app->getParams('com_mokosuitenpo');
$this->orgInfo = (object) [
'name' => $params->get('org_name', $app->get('sitename')),
'ein' => $params->get('org_ein', ''),
'address' => $params->get('org_address', ''),
];
// Active campaigns for dropdown
$this->campaigns = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns();
// Available funds
$db->setQuery($db->getQuery(true)
->select('id, name, fund_type')
->from('#__mokosuitenpo_funds')
->where($db->quoteName('published') . ' = 1')
->order('sort_order ASC, name ASC'));
$this->funds = $db->loadObjectList() ?: [];
// Handle POST submission
if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) {
$this->processDonation($app, $db);
}
parent::display($tpl);
}
private function processDonation($app, $db): void
{
$input = $app->getInput();
$name = $input->getString('donor_name', '');
$email = $input->getString('donor_email', '');
$amount = $input->getFloat('amount', 0);
$fundId = $input->getInt('fund_id', 1);
$campaignId = $input->getInt('campaign_id', 0) ?: null;
if (!$name || !$email || $amount <= 0) {
$app->enqueueMessage('Please fill in all required fields.', 'warning');
return;
}
// Find or create contact
$db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')
->where($db->quoteName('email_to') . ' = ' . $db->quote($email)));
$contactId = (int) $db->loadResult();
if (!$contactId) {
$db->insertObject('#__contact_details', (object) [
'name' => $name, 'email_to' => $email, 'published' => 1,
'created' => Factory::getDate()->toSql(),
], 'id');
$contactId = (int) $db->loadResult() ?: $db->insertid();
}
// Get or create donor
$donor = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getOrCreateDonor($contactId);
// Record donation
$donationId = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::recordDonation(
(int) $donor->id, $amount, $fundId, 'credit_card', $campaignId, [
'date' => date('Y-m-d'),
'payment_method' => 'online',
'tribute_type' => $input->getString('tribute_type', '') ?: null,
'tribute_name' => $input->getString('tribute_name', '') ?: null,
'notes' => 'Online donation',
]
);
// Auto-generate receipt if configured
$params = Factory::getApplication()->getParams('com_mokosuitenpo');
if ($params->get('auto_receipt', true) && $amount >= (float) $params->get('min_receipt_amount', 250)) {
$receiptId = \Moko\Plugin\System\MokoSuiteNpo\Helper\TaxReceiptHelper::generate($donationId);
if ($receiptId) {
\Moko\Plugin\System\MokoSuiteNpo\Helper\TaxReceiptHelper::sendReceipt($receiptId);
}
}
$this->submitted = true;
}
}
@@ -0,0 +1,48 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\View\EventCalendar;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Public event calendar — upcoming events with registration.
*/
class HtmlView extends BaseHtmlView
{
public array $events = [];
public string $month;
public string $year;
public function display($tpl = null): void
{
$app = Factory::getApplication();
$input = $app->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$this->month = $input->getString('month', date('m'));
$this->year = $input->getString('year', date('Y'));
$startDate = $this->year . '-' . $this->month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$db->setQuery($db->getQuery(true)
->select('e.*')
->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_event_registrations r WHERE r.event_id = e.id), 0) AS registration_count')
->from($db->quoteName('#__mokosuitenpo_events', 'e'))
->where($db->quoteName('e.published') . ' = 1')
->where($db->quoteName('e.event_date') . ' BETWEEN ' . $db->quote($startDate) . ' AND ' . $db->quote($endDate))
->order('e.event_date ASC, e.start_time ASC'));
$this->events = $db->loadObjectList() ?: [];
foreach ($this->events as &$event) {
$event->spots_left = (int) $event->capacity > 0
? max(0, (int) $event->capacity - (int) $event->registration_count)
: null;
}
parent::display($tpl);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\View\GrantPortal;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Grant portal — public list of active grants with application info and reporting status.
*/
class HtmlView extends BaseHtmlView
{
public array $activeGrants = [];
public object $summary;
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Active/awarded grants
$db->setQuery($db->getQuery(true)
->select('g.*')
->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
->where($db->quoteName('g.status') . ' IN (' . $db->quote('awarded') . ',' . $db->quote('active') . ',' . $db->quote('reporting') . ')')
->order('g.deadline ASC'));
$this->activeGrants = $db->loadObjectList() ?: [];
// Summary stats
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_grants')
->select('COALESCE(SUM(CASE WHEN status IN (' . $db->quote('awarded') . ',' . $db->quote('active') . ') THEN amount END), 0) AS total_awarded')
->select('COALESCE(SUM(CASE WHEN status = ' . $db->quote('pending') . ' THEN amount END), 0) AS pending_amount')
->from('#__mokosuitenpo_grants'));
$this->summary = $db->loadObject() ?: (object) ['total_grants' => 0, 'total_awarded' => 0, 'pending_amount' => 0];
parent::display($tpl);
}
}
@@ -0,0 +1,61 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\View\VolunteerSignup;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Public volunteer signup page.
*/
class HtmlView extends BaseHtmlView
{
public bool $submitted = false;
public object $stats;
public function display($tpl = null): void
{
$app = Factory::getApplication();
$this->stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats();
if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) {
$input = $app->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$name = $input->getString('name', '');
$email = $input->getString('email', '');
$phone = $input->getString('phone', '');
if ($name && $email) {
// Find or create contact
$db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')
->where($db->quoteName('email_to') . ' = ' . $db->quote($email)));
$contactId = (int) $db->loadResult();
if (!$contactId) {
$db->insertObject('#__contact_details', (object) [
'name' => $name, 'email_to' => $email, 'telephone' => $phone,
'published' => 1, 'created' => Factory::getDate()->toSql(),
], 'id');
$contactId = $db->insertid();
}
$skills = $input->get('skills', [], 'ARRAY');
$availability = [];
foreach (['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] as $day) {
if ($input->getInt($day, 0)) $availability[$day] = true;
}
\Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::register(
(int) $contactId, $skills, $availability
);
$this->submitted = true;
}
}
parent::display($tpl);
}
}
@@ -0,0 +1,41 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
$c = $this->campaign;
$t = $this->thermometer;
$donors = $this->recentDonors;
if (!$c) return;
?>
<div class="container py-4" style="max-width: 800px;">
<div class="text-center mb-4">
<h2><?php echo $this->escape($c->title); ?></h2>
<?php if ($c->image) : ?><img src="<?php echo $this->escape($c->image); ?>" class="img-fluid rounded mb-3" alt="" /><?php endif; ?>
<?php if ($c->description) : ?><p class="lead"><?php echo nl2br($this->escape($c->description)); ?></p><?php endif; ?>
</div>
<?php if ($c->thermometer) : ?>
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><h4 class="mb-0">$<?php echo number_format((float)$t->raised, 0); ?></h4><h4 class="text-muted mb-0">$<?php echo number_format((float)$t->goal, 0); ?> goal</h4></div>
<div class="progress" style="height: 30px;"><div class="progress-bar bg-success" style="width: <?php echo $t->pct; ?>%"><strong><?php echo $t->pct; ?>%</strong></div></div>
<div class="d-flex justify-content-between mt-2"><small class="text-muted"><?php echo (int)$t->donors; ?> donors</small><small class="text-muted">$<?php echo number_format((float)$t->remaining, 0); ?> to go</small></div>
</div>
</div>
<?php endif; ?>
<div class="text-center mb-4"><a href="<?php echo Route::_('index.php?option=com_mokosuitenpo&view=donate&campaign_id=' . $c->id); ?>" class="btn btn-success btn-lg"><span class="icon-heart"></span> Donate Now</a></div>
<?php if (!empty($donors)) : ?>
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">Recent Supporters</h5></div>
<div class="card-body p-0">
<?php foreach ($donors as $d) : ?>
<div class="d-flex justify-content-between p-2 border-bottom">
<span><?php echo $this->escape($d->donor_name); ?></span>
<strong class="text-success">$<?php echo number_format((float)$d->amount, 0); ?></strong>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,104 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Uri\Uri;
$org = $this->orgInfo;
$campaigns = $this->campaigns;
$funds = $this->funds;
if ($this->submitted) : ?>
<div class="container py-5 text-center">
<div class="mb-3"><span class="icon-heart text-danger" style="font-size: 4rem;"></span></div>
<h2>Thank You!</h2>
<p class="text-muted">Your donation has been received. A receipt will be emailed to you shortly.</p>
</div>
<?php return; endif; ?>
<div class="container py-4" style="max-width: 700px;">
<div class="text-center mb-4">
<h2>Support <?php echo $this->escape($org->name); ?></h2>
<p class="text-muted">Your generosity makes our work possible.</p>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="<?php echo Uri::current(); ?>" id="donate-form">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Full Name <span class="text-danger">*</span></label>
<input type="text" name="donor_name" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Email <span class="text-danger">*</span></label>
<input type="email" name="donor_email" class="form-control" required />
</div>
</div>
<div class="mb-3">
<label class="form-label">Donation Amount <span class="text-danger">*</span></label>
<div class="btn-group w-100 mb-2" role="group">
<?php foreach ([25, 50, 100, 250, 500, 1000] as $amt) : ?>
<button type="button" class="btn btn-outline-success" onclick="document.getElementById('amount').value=<?php echo $amt; ?>">$<?php echo $amt; ?></button>
<?php endforeach; ?>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="amount" id="amount" class="form-control form-control-lg" step="0.01" min="1" required />
</div>
</div>
<?php if (!empty($funds)) : ?>
<div class="mb-3">
<label class="form-label">Designate to Fund</label>
<select name="fund_id" class="form-select">
<?php foreach ($funds as $f) : ?>
<option value="<?php echo (int) $f->id; ?>"><?php echo $this->escape($f->name); ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if (!empty($campaigns)) : ?>
<div class="mb-3">
<label class="form-label">Support a Campaign</label>
<select name="campaign_id" class="form-select">
<option value="">General Donation</option>
<?php foreach ($campaigns as $c) : ?>
<option value="<?php echo (int) $c->id; ?>"><?php echo $this->escape($c->title); ?> (<?php echo $c->progress_pct; ?>% funded)</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="mb-3">
<label class="form-label">In Honor / Memory Of (optional)</label>
<div class="row g-2">
<div class="col-4">
<select name="tribute_type" class="form-select form-select-sm">
<option value="">None</option>
<option value="in_honor">In Honor Of</option>
<option value="in_memory">In Memory Of</option>
</select>
</div>
<div class="col-8">
<input type="text" name="tribute_name" class="form-control form-control-sm" placeholder="Name" />
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-lg w-100">
<span class="icon-heart"></span> Donate Now
</button>
<p class="text-center text-muted mt-3 small">
<?php echo $this->escape($org->name); ?> is a 501(c)(3) nonprofit.
<?php if ($org->ein) : ?>EIN: <?php echo $this->escape($org->ein); ?>.<?php endif; ?>
Your donation is tax-deductible.
</p>
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
</div>
@@ -0,0 +1,57 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
$prevMonth = date('Y-m', strtotime($this->year . '-' . $this->month . '-01 -1 month'));
$nextMonth = date('Y-m', strtotime($this->year . '-' . $this->month . '-01 +1 month'));
[$prevY, $prevM] = explode('-', $prevMonth);
[$nextY, $nextM] = explode('-', $nextMonth);
$monthName = date('F Y', strtotime($this->year . '-' . $this->month . '-01'));
?>
<div class="npo-event-calendar">
<div class="d-flex justify-content-between align-items-center mb-4">
<a href="<?php echo Route::_('index.php?option=com_mokosuitenpo&view=eventcalendar&month=' . $prevM . '&year=' . $prevY); ?>" class="btn btn-outline-secondary">&laquo; Previous</a>
<h2 class="mb-0"><?php echo htmlspecialchars($monthName); ?></h2>
<a href="<?php echo Route::_('index.php?option=com_mokosuitenpo&view=eventcalendar&month=' . $nextM . '&year=' . $nextY); ?>" class="btn btn-outline-secondary">Next &raquo;</a>
</div>
<?php if (empty($this->events)) : ?>
<div class="alert alert-info">No events scheduled for <?php echo htmlspecialchars($monthName); ?>.</div>
<?php else : ?>
<div class="row g-3">
<?php foreach ($this->events as $event) : ?>
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small mb-1">
<?php echo date('D, M j', strtotime($event->event_date)); ?>
<?php if ($event->start_time) : ?>
&middot; <?php echo date('g:i A', strtotime($event->start_time)); ?>
<?php endif; ?>
</div>
<h5 class="card-title"><?php echo htmlspecialchars($event->title); ?></h5>
<?php if ($event->location) : ?>
<p class="text-muted small mb-2"><i class="icon-location"></i> <?php echo htmlspecialchars($event->location); ?></p>
<?php endif; ?>
<p class="card-text"><?php echo htmlspecialchars(substr($event->description ?? '', 0, 150)); ?></p>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<?php if ($event->spots_left !== null) : ?>
<span class="badge bg-<?php echo $event->spots_left > 0 ? 'success' : 'danger'; ?>">
<?php echo $event->spots_left > 0 ? $event->spots_left . ' spots left' : 'Full'; ?>
</span>
<?php else : ?>
<span class="badge bg-info">Open</span>
<?php endif; ?>
<?php if ($event->spots_left === null || $event->spots_left > 0) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitenpo&view=eventregistration&event_id=' . $event->id); ?>" class="btn btn-sm btn-primary">Register</a>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,74 @@
<?php
defined('_JEXEC') or die;
$summary = $this->summary;
?>
<div class="npo-grant-portal">
<h2>Grant Programs</h2>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary">$<?php echo number_format((float) $summary->total_awarded); ?></h3>
<p class="text-muted mb-0">Total Awarded</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="text-info"><?php echo (int) $summary->total_grants; ?></h3>
<p class="text-muted mb-0">Grants</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="text-warning">$<?php echo number_format((float) $summary->pending_amount); ?></h3>
<p class="text-muted mb-0">Pending</p>
</div>
</div>
</div>
</div>
<?php if (empty($this->activeGrants)) : ?>
<div class="alert alert-info">No active grant programs at this time.</div>
<?php else : ?>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Grant</th>
<th>Funder</th>
<th>Amount</th>
<th>Status</th>
<th>Deadline</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->activeGrants as $grant) : ?>
<tr>
<td>
<strong><?php echo htmlspecialchars($grant->title); ?></strong>
<?php if (!empty($grant->description)) : ?>
<br><small class="text-muted"><?php echo htmlspecialchars(substr($grant->description, 0, 100)); ?></small>
<?php endif; ?>
</td>
<td><?php echo htmlspecialchars($grant->funder ?? ''); ?></td>
<td>$<?php echo number_format((float) $grant->amount); ?></td>
<td>
<span class="badge bg-<?php echo match($grant->status) {
'awarded' => 'success', 'active' => 'primary',
'reporting' => 'info', default => 'secondary'
}; ?>"><?php echo ucfirst($grant->status); ?></span>
</td>
<td><?php echo $grant->deadline ? date('M j, Y', strtotime($grant->deadline)) : '—'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,36 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Uri\Uri;
$stats = $this->stats;
if ($this->submitted) : ?>
<div class="container py-5 text-center">
<span class="icon-users text-success" style="font-size:4rem;"></span>
<h2>Welcome aboard!</h2>
<p class="text-muted">Thank you for volunteering. We will be in touch soon.</p>
</div>
<?php return; endif; ?>
<div class="container py-4" style="max-width:600px;">
<h2 class="text-center mb-2">Volunteer With Us</h2>
<p class="text-center text-muted mb-4"><?php echo (int)$stats->active; ?> volunteers have contributed <?php echo number_format((float)$stats->total_hours,0); ?> hours</p>
<div class="card shadow-sm"><div class="card-body">
<form method="post" action="<?php echo Uri::current(); ?>">
<div class="row g-3 mb-3">
<div class="col-md-6"><label class="form-label">Name *</label><input type="text" name="name" class="form-control" required /></div>
<div class="col-md-6"><label class="form-label">Email *</label><input type="email" name="email" class="form-control" required /></div>
</div>
<div class="mb-3"><label class="form-label">Phone</label><input type="tel" name="phone" class="form-control" /></div>
<div class="mb-3"><label class="form-label">Skills (select all that apply)</label>
<?php foreach (['Teaching','Cooking','Driving','Admin','Fundraising','Marketing','IT','Construction','Music','Childcare'] as $s) : ?>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="skills[]" value="<?php echo $s; ?>" /><label class="form-check-label"><?php echo $s; ?></label></div>
<?php endforeach; ?>
</div>
<div class="mb-3"><label class="form-label">Availability</label><div class="d-flex gap-2 flex-wrap">
<?php foreach (['monday'=>'Mon','tuesday'=>'Tue','wednesday'=>'Wed','thursday'=>'Thu','friday'=>'Fri','saturday'=>'Sat','sunday'=>'Sun'] as $k=>$l) : ?>
<div class="form-check"><input class="form-check-input" type="checkbox" name="<?php echo $k; ?>" value="1" /><label class="form-check-label"><?php echo $l; ?></label></div>
<?php endforeach; ?>
</div></div>
<button type="submit" class="btn btn-primary btn-lg w-100">Sign Up to Volunteer</button>
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
</div></div>
</div>
@@ -0,0 +1,2 @@
PLG_SYSTEM_MOKOSUITENPO="System - MokoSuite NPO"
PLG_SYSTEM_MOKOSUITENPO_DESC="MokoSuite NPO system plugin - nonprofit database schema and helpers."
@@ -0,0 +1,2 @@
PLG_SYSTEM_MOKOSUITENPO="System - MokoSuite NPO"
PLG_SYSTEM_MOKOSUITENPO_DESC="MokoSuite NPO system plugin."
@@ -0,0 +1,328 @@
--
-- MokoSuite NPO Tables
--
-- Donors piggyback on CRM contacts. This table adds NPO-specific fields.
-- The contact_id FK links to #__contact_details.
-- ============================================================
-- Donor Profiles — extends CRM contacts with giving data
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_donors` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT NOT NULL COMMENT 'FK to #__contact_details',
`donor_type` ENUM('individual','corporate','foundation','government','anonymous') NOT NULL DEFAULT 'individual',
`donor_level` ENUM('prospect','first_time','repeat','major','legacy','lapsed') NOT NULL DEFAULT 'prospect',
`lifetime_giving` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`largest_gift` DECIMAL(15,2) DEFAULT NULL,
`first_gift_date` DATE DEFAULT NULL,
`last_gift_date` DATE DEFAULT NULL,
`gift_count` INT UNSIGNED NOT NULL DEFAULT 0,
`preferred_fund` INT UNSIGNED DEFAULT NULL,
`communication_preference` ENUM('email','mail','phone','none') NOT NULL DEFAULT 'email',
`tax_id` VARCHAR(50) DEFAULT NULL COMMENT 'EIN for corporate donors',
`recognition_name` VARCHAR(255) DEFAULT NULL COMMENT 'Name for public recognition (may differ from contact)',
`anonymous_giving` TINYINT NOT NULL DEFAULT 0,
`notes` TEXT,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_contact` (`contact_id`),
KEY `idx_level` (`donor_level`),
KEY `idx_type` (`donor_type`),
KEY `idx_lifetime` (`lifetime_giving`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Funds — restricted vs unrestricted fund tracking
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_funds` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`code` VARCHAR(20) NOT NULL DEFAULT '',
`fund_type` ENUM('unrestricted','temporarily_restricted','permanently_restricted','endowment','operating','capital') NOT NULL DEFAULT 'unrestricted',
`description` TEXT,
`gl_account_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to ERP chart of accounts',
`target_amount` DECIMAL(15,2) DEFAULT NULL,
`current_balance` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`published` TINYINT NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_code` (`code`),
KEY `idx_type` (`fund_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Seed default funds
INSERT IGNORE INTO `#__mokosuitenpo_funds`
(`id`, `name`, `code`, `fund_type`, `description`, `published`, `created`) VALUES
(1, 'General Operating Fund', 'GEN', 'unrestricted', 'Unrestricted general operating support.', 1, NOW()),
(2, 'Building Fund', 'BLDG', 'temporarily_restricted', 'Restricted for facility improvements.', 1, NOW()),
(3, 'Scholarship Fund', 'SCHOL', 'temporarily_restricted', 'Restricted for student scholarships.', 1, NOW()),
(4, 'Endowment', 'ENDOW', 'permanently_restricted', 'Permanently restricted endowment principal.', 1, NOW());
-- ============================================================
-- Donations — individual gifts with fund allocation
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_donations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`donor_id` INT UNSIGNED NOT NULL,
`fund_id` INT UNSIGNED NOT NULL DEFAULT 1,
`campaign_id` INT UNSIGNED DEFAULT NULL,
`amount` DECIMAL(15,2) NOT NULL,
`donation_type` ENUM('cash','check','credit_card','ach','stock','crypto','in_kind','pledge','matching') NOT NULL DEFAULT 'cash',
`donation_date` DATE NOT NULL,
`payment_method` VARCHAR(50) DEFAULT NULL,
`payment_reference` VARCHAR(100) DEFAULT NULL COMMENT 'Check number, transaction ID',
`is_tax_deductible` TINYINT NOT NULL DEFAULT 1,
`fair_market_value` DECIMAL(15,2) DEFAULT NULL COMMENT 'For in-kind donations',
`in_kind_description` TEXT COMMENT 'Description of in-kind gift',
`is_recurring` TINYINT NOT NULL DEFAULT 0,
`recurring_frequency` ENUM('weekly','monthly','quarterly','annually') DEFAULT NULL,
`recurring_end_date` DATE DEFAULT NULL,
`tribute_type` ENUM('in_honor','in_memory') DEFAULT NULL,
`tribute_name` VARCHAR(255) DEFAULT NULL,
`receipt_sent` TINYINT NOT NULL DEFAULT 0,
`receipt_sent_date` DATE DEFAULT NULL,
`acknowledgment_sent` TINYINT NOT NULL DEFAULT 0,
`notes` TEXT,
`order_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM orders for online donations',
`journal_entry_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to ERP journal entries',
`created` DATETIME NOT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_donor` (`donor_id`),
KEY `idx_fund` (`fund_id`),
KEY `idx_campaign` (`campaign_id`),
KEY `idx_date` (`donation_date`),
KEY `idx_type` (`donation_type`),
KEY `idx_recurring` (`is_recurring`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Pledges — promised future donations
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_pledges` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`donor_id` INT UNSIGNED NOT NULL,
`fund_id` INT UNSIGNED NOT NULL DEFAULT 1,
`campaign_id` INT UNSIGNED DEFAULT NULL,
`total_amount` DECIMAL(15,2) NOT NULL,
`amount_fulfilled` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`pledge_date` DATE NOT NULL,
`due_date` DATE DEFAULT NULL,
`frequency` ENUM('one_time','monthly','quarterly','annually') NOT NULL DEFAULT 'one_time',
`installments` INT UNSIGNED NOT NULL DEFAULT 1,
`status` ENUM('active','fulfilled','partial','cancelled','lapsed') NOT NULL DEFAULT 'active',
`notes` TEXT,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_donor` (`donor_id`),
KEY `idx_status` (`status`),
KEY `idx_due` (`due_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Campaigns — fundraising drives
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_campaigns` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`description` TEXT,
`campaign_type` ENUM('annual','capital','endowment','emergency','event','peer_to_peer','crowdfunding','grant_match') NOT NULL DEFAULT 'annual',
`fund_id` INT UNSIGNED DEFAULT NULL,
`goal_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`raised_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`donor_count` INT UNSIGNED NOT NULL DEFAULT 0,
`start_date` DATE NOT NULL,
`end_date` DATE DEFAULT NULL,
`status` ENUM('planning','active','paused','completed','cancelled') NOT NULL DEFAULT 'planning',
`public_page` TINYINT NOT NULL DEFAULT 1 COMMENT 'Show on public donation page',
`thermometer` TINYINT NOT NULL DEFAULT 1 COMMENT 'Show progress thermometer',
`image` VARCHAR(500) DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_type` (`campaign_type`),
KEY `idx_dates` (`start_date`, `end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Grants — application, award, and reporting lifecycle
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_grants` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`funder_name` VARCHAR(255) NOT NULL,
`funder_contact_id` INT DEFAULT NULL COMMENT 'FK to CRM contacts',
`amount_requested` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`amount_awarded` DECIMAL(15,2) DEFAULT NULL,
`fund_id` INT UNSIGNED DEFAULT NULL,
`status` ENUM('prospect','writing','submitted','pending','awarded','declined','reporting','closed') NOT NULL DEFAULT 'prospect',
`application_deadline` DATE DEFAULT NULL,
`submitted_date` DATE DEFAULT NULL,
`award_date` DATE DEFAULT NULL,
`start_date` DATE DEFAULT NULL,
`end_date` DATE DEFAULT NULL,
`reporting_frequency` ENUM('monthly','quarterly','semi_annual','annual','final') DEFAULT NULL,
`next_report_due` DATE DEFAULT NULL,
`restrictions` TEXT COMMENT 'Grant restrictions/requirements',
`match_required` TINYINT NOT NULL DEFAULT 0,
`match_ratio` VARCHAR(20) DEFAULT NULL COMMENT 'e.g., 1:1, 2:1',
`notes` TEXT,
`created_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_funder` (`funder_contact_id`),
KEY `idx_deadline` (`application_deadline`),
KEY `idx_report_due` (`next_report_due`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Volunteers — hours, skills, availability
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_volunteers` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT NOT NULL COMMENT 'FK to #__contact_details',
`status` ENUM('prospect','active','inactive','alumni') NOT NULL DEFAULT 'prospect',
`skills` JSON DEFAULT NULL,
`availability` JSON DEFAULT NULL COMMENT '{"monday":true,"tuesday":true,...}',
`total_hours` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`start_date` DATE DEFAULT NULL,
`background_check` TINYINT NOT NULL DEFAULT 0,
`background_check_date` DATE DEFAULT NULL,
`emergency_contact_name` VARCHAR(255) DEFAULT NULL,
`emergency_contact_phone` VARCHAR(50) DEFAULT NULL,
`t_shirt_size` VARCHAR(10) DEFAULT NULL,
`notes` TEXT,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_contact` (`contact_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Volunteer Hours Log
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_volunteer_hours` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`volunteer_id` INT UNSIGNED NOT NULL,
`activity` VARCHAR(255) NOT NULL,
`hours` DECIMAL(5,2) NOT NULL,
`volunteer_date` DATE NOT NULL,
`supervisor` VARCHAR(255) DEFAULT NULL,
`notes` TEXT,
`approved` TINYINT NOT NULL DEFAULT 0,
`approved_by` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_volunteer` (`volunteer_id`),
KEY `idx_date` (`volunteer_date`),
KEY `idx_approved` (`approved`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Memberships — dues-based membership program
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_memberships` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT NOT NULL,
`membership_level` ENUM('basic','silver','gold','platinum','lifetime','honorary') NOT NULL DEFAULT 'basic',
`start_date` DATE NOT NULL,
`end_date` DATE DEFAULT NULL,
`annual_dues` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`status` ENUM('active','expired','cancelled','pending','grace') NOT NULL DEFAULT 'pending',
`auto_renew` TINYINT NOT NULL DEFAULT 0,
`member_number` VARCHAR(50) DEFAULT NULL,
`joined_date` DATE DEFAULT NULL,
`notes` TEXT,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_contact` (`contact_id`),
KEY `idx_level` (`membership_level`),
KEY `idx_status` (`status`),
KEY `idx_end` (`end_date`),
UNIQUE KEY `idx_member_number` (`member_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Tax Receipts — IRS-compliant donation acknowledgments
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_tax_receipts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`donation_id` INT UNSIGNED DEFAULT NULL,
`donor_id` INT UNSIGNED NOT NULL,
`receipt_number` VARCHAR(50) NOT NULL,
`tax_year` INT NOT NULL,
`amount` DECIMAL(15,2) NOT NULL,
`date_issued` DATE NOT NULL,
`delivery_method` ENUM('email','mail','both') NOT NULL DEFAULT 'email',
`sent` TINYINT NOT NULL DEFAULT 0,
`sent_date` DATETIME DEFAULT NULL,
`file_path` VARCHAR(500) DEFAULT NULL COMMENT 'PDF path',
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_receipt_number` (`receipt_number`),
KEY `idx_donor` (`donor_id`),
KEY `idx_year` (`tax_year`),
KEY `idx_donation` (`donation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Events — fundraisers, galas, community events
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_events` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`description` TEXT,
`event_type` ENUM('gala','auction','walkathon','golf','concert','dinner','community','virtual','hybrid') NOT NULL DEFAULT 'community',
`campaign_id` INT UNSIGNED DEFAULT NULL,
`venue` VARCHAR(255) DEFAULT NULL,
`address` TEXT,
`start_date` DATETIME NOT NULL,
`end_date` DATETIME DEFAULT NULL,
`ticket_price` DECIMAL(10,2) DEFAULT NULL,
`capacity` INT UNSIGNED DEFAULT NULL,
`registered` INT UNSIGNED NOT NULL DEFAULT 0,
`revenue_goal` DECIMAL(15,2) DEFAULT NULL,
`actual_revenue` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`status` ENUM('planning','registration_open','sold_out','in_progress','completed','cancelled') NOT NULL DEFAULT 'planning',
`public` TINYINT NOT NULL DEFAULT 1,
`image` VARCHAR(500) DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_campaign` (`campaign_id`),
KEY `idx_type` (`event_type`),
KEY `idx_status` (`status`),
KEY `idx_date` (`start_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Event Registrations
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_event_registrations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL,
`contact_id` INT DEFAULT NULL,
`guest_name` VARCHAR(255) NOT NULL,
`guest_email` VARCHAR(255) DEFAULT NULL,
`guest_phone` VARCHAR(50) DEFAULT NULL,
`ticket_count` INT UNSIGNED NOT NULL DEFAULT 1,
`amount_paid` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`dietary_restrictions` VARCHAR(500) DEFAULT NULL,
`table_assignment` VARCHAR(50) DEFAULT NULL,
`status` ENUM('registered','confirmed','attended','cancelled','no_show') NOT NULL DEFAULT 'registered',
`checked_in_at` DATETIME DEFAULT NULL,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_event` (`event_id`),
KEY `idx_contact` (`contact_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,12 @@
DROP TABLE IF EXISTS `#__mokosuitenpo_event_registrations`;
DROP TABLE IF EXISTS `#__mokosuitenpo_events`;
DROP TABLE IF EXISTS `#__mokosuitenpo_tax_receipts`;
DROP TABLE IF EXISTS `#__mokosuitenpo_memberships`;
DROP TABLE IF EXISTS `#__mokosuitenpo_volunteer_hours`;
DROP TABLE IF EXISTS `#__mokosuitenpo_volunteers`;
DROP TABLE IF EXISTS `#__mokosuitenpo_grants`;
DROP TABLE IF EXISTS `#__mokosuitenpo_campaigns`;
DROP TABLE IF EXISTS `#__mokosuitenpo_pledges`;
DROP TABLE IF EXISTS `#__mokosuitenpo_donations`;
DROP TABLE IF EXISTS `#__mokosuitenpo_funds`;
DROP TABLE IF EXISTS `#__mokosuitenpo_donors`;
@@ -0,0 +1,98 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Campaign/fundraising helper — goals, thermometers, analytics.
*/
class CampaignHelper
{
public static function getActiveCampaigns(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('c.*, f.name AS fund_name')
->from($db->quoteName('#__mokosuitenpo_campaigns', 'c'))
->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = c.fund_id')
->where($db->quoteName('c.status') . ' = ' . $db->quote('active'))
->order('c.end_date ASC'));
$campaigns = $db->loadObjectList() ?: [];
foreach ($campaigns as &$c) {
$c->progress_pct = $c->goal_amount > 0 ? min(100, round($c->raised_amount / $c->goal_amount * 100)) : 0;
$c->remaining = max(0, (float) $c->goal_amount - (float) $c->raised_amount);
$c->days_remaining = $c->end_date ? max(0, round((strtotime($c->end_date) - time()) / 86400)) : null;
}
return $campaigns;
}
public static function getThermometerData(int $campaignId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_campaigns')->where('id = ' . $campaignId));
$c = $db->loadObject();
if (!$c) return (object) ['goal' => 0, 'raised' => 0, 'pct' => 0, 'donors' => 0];
return (object) [
'goal' => (float) $c->goal_amount,
'raised' => (float) $c->raised_amount,
'pct' => $c->goal_amount > 0 ? min(100, round($c->raised_amount / $c->goal_amount * 100)) : 0,
'donors' => (int) $c->donor_count,
'remaining' => max(0, (float) $c->goal_amount - (float) $c->raised_amount),
'title' => $c->title,
'end_date' => $c->end_date,
];
}
public static function getCampaignDonations(int $campaignId, int $limit = 50): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('d.*, cd.name AS donor_name')
->select('don.anonymous_giving, don.recognition_name')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->where('d.campaign_id = ' . $campaignId)
->order('d.donation_date DESC'), 0, $limit);
$donations = $db->loadObjectList() ?: [];
// Anonymize where needed
foreach ($donations as &$d) {
if ($d->anonymous_giving) {
$d->donor_name = 'Anonymous';
} elseif ($d->recognition_name) {
$d->donor_name = $d->recognition_name;
}
}
return $donations;
}
public static function getFundraisingSummary(int $year = 0): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$year = $year ?: (int) date('Y');
$db->setQuery($db->getQuery(true)
->select('COUNT(DISTINCT d.donor_id) AS unique_donors')
->select('COUNT(*) AS total_gifts')
->select('COALESCE(SUM(d.amount), 0) AS total_raised')
->select('COALESCE(AVG(d.amount), 0) AS avg_gift')
->select('MAX(d.amount) AS largest_gift')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->where('YEAR(d.donation_date) = ' . $year));
return $db->loadObject() ?: (object) ['unique_donors' => 0, 'total_gifts' => 0, 'total_raised' => 0, 'avg_gift' => 0, 'largest_gift' => 0];
}
}
@@ -0,0 +1,165 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Donor management — profiles, giving history, recognition levels.
*/
class DonorHelper
{
public const LEVELS = [
'prospect' => ['min' => 0, 'label' => 'Prospect'],
'first_time' => ['min' => 1, 'label' => 'First-Time Donor'],
'repeat' => ['min' => 100, 'label' => 'Repeat Donor'],
'major' => ['min' => 5000, 'label' => 'Major Donor'],
'legacy' => ['min' => 50000, 'label' => 'Legacy Donor'],
];
public static function getOrCreateDonor(int $contactId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_donors')
->where('contact_id = ' . $contactId));
$donor = $db->loadObject();
if ($donor) return $donor;
$donor = (object) [
'contact_id' => $contactId,
'donor_type' => 'individual',
'donor_level' => 'prospect',
'lifetime_giving' => 0,
'gift_count' => 0,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitenpo_donors', $donor, 'id');
return $donor;
}
public static function recordDonation(int $donorId, float $amount, int $fundId = 1, string $type = 'cash', ?int $campaignId = null, array $extra = []): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$donation = (object) [
'donor_id' => $donorId,
'fund_id' => $fundId,
'campaign_id' => $campaignId,
'amount' => $amount,
'donation_type' => $type,
'donation_date' => $extra['date'] ?? date('Y-m-d'),
'payment_method' => $extra['payment_method'] ?? null,
'payment_reference' => $extra['reference'] ?? null,
'is_tax_deductible' => (int) ($extra['tax_deductible'] ?? 1),
'tribute_type' => $extra['tribute_type'] ?? null,
'tribute_name' => $extra['tribute_name'] ?? null,
'notes' => $extra['notes'] ?? '',
'created' => Factory::getDate()->toSql(),
'created_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuitenpo_donations', $donation, 'id');
// Update donor stats
self::updateDonorStats($donorId);
// Update fund balance
$db->setQuery($db->getQuery(true)
->update('#__mokosuitenpo_funds')
->set('current_balance = current_balance + ' . (float) $amount)
->where('id = ' . $fundId));
$db->execute();
// Update campaign raised amount
if ($campaignId) {
$db->setQuery($db->getQuery(true)
->update('#__mokosuitenpo_campaigns')
->set('raised_amount = raised_amount + ' . (float) $amount)
->set('donor_count = donor_count + 1')
->where('id = ' . $campaignId));
$db->execute();
}
return (int) $donation->id;
}
public static function updateDonorStats(int $donorId): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS gift_count, COALESCE(SUM(amount), 0) AS lifetime, MAX(amount) AS largest, MIN(donation_date) AS first_date, MAX(donation_date) AS last_date')
->from('#__mokosuitenpo_donations')
->where('donor_id = ' . $donorId));
$stats = $db->loadObject();
$lifetime = (float) ($stats->lifetime ?? 0);
$level = 'prospect';
foreach (array_reverse(self::LEVELS) as $key => $config) {
if ($lifetime >= $config['min']) { $level = $key; break; }
}
$db->updateObject('#__mokosuitenpo_donors', (object) [
'id' => $donorId,
'lifetime_giving' => $lifetime,
'largest_gift' => (float) ($stats->largest ?? 0),
'first_gift_date' => $stats->first_date,
'last_gift_date' => $stats->last_date,
'gift_count' => (int) ($stats->gift_count ?? 0),
'donor_level' => $level,
'modified' => Factory::getDate()->toSql(),
], 'id');
}
public static function getDonorSummary(): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_donors')
->select('SUM(lifetime_giving) AS total_lifetime')
->select('SUM(CASE WHEN donor_level = ' . $db->quote('major') . ' THEN 1 ELSE 0 END) AS major_donors')
->select('SUM(CASE WHEN donor_level = ' . $db->quote('lapsed') . ' THEN 1 ELSE 0 END) AS lapsed_donors')
->select('AVG(lifetime_giving) AS avg_lifetime')
->from('#__mokosuitenpo_donors'));
return $db->loadObject() ?: (object) ['total_donors' => 0, 'total_lifetime' => 0, 'major_donors' => 0, 'lapsed_donors' => 0, 'avg_lifetime' => 0];
}
public static function getTopDonors(int $limit = 10, string $dateFrom = ''): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('d.*, cd.name AS donor_name, cd.email_to')
->from($db->quoteName('#__mokosuitenpo_donors', 'd'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->where($db->quoteName('d.anonymous_giving') . ' = 0')
->order('d.lifetime_giving DESC');
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
public static function getLapsedDonors(int $monthsInactive = 12): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$cutoff = date('Y-m-d', strtotime("-{$monthsInactive} months"));
$db->setQuery($db->getQuery(true)
->select('d.*, cd.name AS donor_name, cd.email_to')
->from($db->quoteName('#__mokosuitenpo_donors', 'd'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->where($db->quoteName('d.last_gift_date') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('d.gift_count') . ' > 0')
->order('d.last_gift_date ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,87 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* NPO event management — fundraisers, galas, community events.
*/
class EventHelper
{
public static function getUpcomingEvents(int $limit = 10): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('e.*, c.title AS campaign_title')
->select('(SELECT COUNT(*) FROM #__mokosuitenpo_event_registrations r WHERE r.event_id = e.id) AS registered')
->from($db->quoteName('#__mokosuitenpo_events', 'e'))
->join('LEFT', $db->quoteName('#__mokosuitenpo_campaigns', 'c') . ' ON c.id = e.campaign_id')
->where($db->quoteName('e.start_date') . ' >= NOW()')
->where($db->quoteName('e.status') . ' != ' . $db->quote('cancelled'))
->order('e.start_date ASC'), 0, $limit);
$events = $db->loadObjectList() ?: [];
foreach ($events as &$ev) {
$ev->spots_remaining = $ev->capacity ? max(0, (int) $ev->capacity - (int) $ev->registered) : null;
$ev->is_sold_out = $ev->capacity && (int) $ev->registered >= (int) $ev->capacity;
}
return $events;
}
public static function register(int $eventId, array $data): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$reg = (object) [
'event_id' => $eventId,
'contact_id' => (int) ($data['contact_id'] ?? 0) ?: null,
'guest_name' => $data['name'] ?? '',
'guest_email' => $data['email'] ?? '',
'guest_phone' => $data['phone'] ?? '',
'ticket_count'=> (int) ($data['tickets'] ?? 1),
'amount_paid' => (float) ($data['amount'] ?? 0),
'dietary_restrictions' => $data['dietary'] ?? '',
'status' => 'registered',
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitenpo_event_registrations', $reg, 'id');
// Update event registered count
$db->setQuery($db->getQuery(true)
->update('#__mokosuitenpo_events')
->set('registered = registered + ' . (int) $reg->ticket_count)
->set('actual_revenue = actual_revenue + ' . (float) $reg->amount_paid)
->where('id = ' . $eventId));
$db->execute();
return (int) $reg->id;
}
public static function getEventRegistrations(int $eventId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_event_registrations')
->where('event_id = ' . $eventId)
->order('created ASC'));
return $db->loadObjectList() ?: [];
}
public static function checkIn(int $registrationId): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->updateObject('#__mokosuitenpo_event_registrations', (object) [
'id' => $registrationId, 'status' => 'attended', 'checked_in_at' => Factory::getDate()->toSql(),
], 'id');
}
}
@@ -0,0 +1,72 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Grant lifecycle management — prospect to close with reporting tracking.
*/
class GrantHelper
{
public static function getPipelineSummary(): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total')
->select('SUM(CASE WHEN status IN (' . $db->quote('prospect') . ',' . $db->quote('writing') . ',' . $db->quote('submitted') . ',' . $db->quote('pending') . ') THEN amount_requested ELSE 0 END) AS pipeline_value')
->select('SUM(CASE WHEN status = ' . $db->quote('awarded') . ' THEN amount_awarded ELSE 0 END) AS awarded_value')
->select('SUM(CASE WHEN status = ' . $db->quote('awarded') . ' THEN 1 ELSE 0 END) AS awarded_count')
->select('SUM(CASE WHEN status = ' . $db->quote('declined') . ' THEN 1 ELSE 0 END) AS declined_count')
->from('#__mokosuitenpo_grants'));
$stats = $db->loadObject() ?: (object) ['total' => 0, 'pipeline_value' => 0, 'awarded_value' => 0, 'awarded_count' => 0, 'declined_count' => 0];
$closed = (int) $stats->awarded_count + (int) $stats->declined_count;
$stats->win_rate = $closed > 0 ? round($stats->awarded_count / $closed * 100) : 0;
return $stats;
}
public static function getUpcomingDeadlines(int $daysAhead = 30): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_grants')
->where($db->quoteName('status') . ' IN (' . $db->quote('prospect') . ',' . $db->quote('writing') . ')')
->where($db->quoteName('application_deadline') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
->order('application_deadline ASC'));
return $db->loadObjectList() ?: [];
}
public static function getReportsDue(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_grants')
->where($db->quoteName('status') . ' IN (' . $db->quote('awarded') . ',' . $db->quote('reporting') . ')')
->where($db->quoteName('next_report_due') . ' IS NOT NULL')
->where($db->quoteName('next_report_due') . ' <= DATE_ADD(CURDATE(), INTERVAL 30 DAY)')
->order('next_report_due ASC'));
return $db->loadObjectList() ?: [];
}
public static function advanceStatus(int $grantId, string $newStatus): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$update = (object) ['id' => $grantId, 'status' => $newStatus, 'modified' => Factory::getDate()->toSql()];
if ($newStatus === 'submitted') $update->submitted_date = date('Y-m-d');
if ($newStatus === 'awarded') $update->award_date = date('Y-m-d');
$db->updateObject('#__mokosuitenpo_grants', $update, 'id');
}
}
@@ -0,0 +1,75 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Membership management — dues, levels, renewals.
*/
class MembershipHelper
{
public static function getActiveMembers(string $level = ''): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('m.*, cd.name AS member_name, cd.email_to, cd.telephone')
->from($db->quoteName('#__mokosuitenpo_memberships', 'm'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = m.contact_id')
->where($db->quoteName('m.status') . ' = ' . $db->quote('active'))
->order('m.end_date ASC');
if ($level) $query->where($db->quoteName('m.membership_level') . ' = ' . $db->quote($level));
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
public static function getExpiring(int $daysAhead = 30): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('m.*, cd.name AS member_name, cd.email_to')
->from($db->quoteName('#__mokosuitenpo_memberships', 'm'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = m.contact_id')
->where($db->quoteName('m.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('m.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
->order('m.end_date ASC'));
return $db->loadObjectList() ?: [];
}
public static function getSummary(): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_active')
->select('COALESCE(SUM(annual_dues), 0) AS annual_revenue')
->select('SUM(CASE WHEN membership_level = ' . $db->quote('lifetime') . ' THEN 1 ELSE 0 END) AS lifetime_count')
->from('#__mokosuitenpo_memberships')
->where($db->quoteName('status') . ' = ' . $db->quote('active')));
return $db->loadObject() ?: (object) ['total_active' => 0, 'annual_revenue' => 0, 'lifetime_count' => 0];
}
public static function renew(int $membershipId, int $months = 12): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)->select('end_date')->from('#__mokosuitenpo_memberships')->where('id = ' . $membershipId));
$currentEnd = $db->loadResult();
$newEnd = date('Y-m-d', strtotime(($currentEnd && strtotime($currentEnd) > time() ? $currentEnd : 'now') . " +{$months} months"));
$db->updateObject('#__mokosuitenpo_memberships', (object) [
'id' => $membershipId, 'end_date' => $newEnd, 'status' => 'active', 'modified' => Factory::getDate()->toSql(),
], 'id');
return true;
}
}
@@ -0,0 +1,145 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* IRS-compliant tax receipt generation and delivery.
*/
class TaxReceiptHelper
{
public static function generate(int $donationId): ?int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('d.*, don.contact_id, cd.name AS donor_name, cd.email_to, cd.address')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->where('d.id = ' . $donationId));
$donation = $db->loadObject();
if (!$donation || !$donation->is_tax_deductible) return null;
$params = Factory::getApplication()->getParams('com_mokosuitenpo');
$prefix = $params->get('receipt_prefix', 'RCP');
$taxYear = date('Y', strtotime($donation->donation_date));
$seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')
->from('#__mokosuitenpo_tax_receipts')
->where('tax_year = ' . $taxYear))->loadResult() + 1;
$receiptNumber = $prefix . '-' . $taxYear . '-' . str_pad($seq, 5, '0', STR_PAD_LEFT);
$receipt = (object) [
'donation_id' => $donationId,
'donor_id' => $donation->donor_id,
'receipt_number' => $receiptNumber,
'tax_year' => $taxYear,
'amount' => $donation->amount,
'date_issued' => date('Y-m-d'),
'delivery_method'=> $donation->email_to ? 'email' : 'mail',
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitenpo_tax_receipts', $receipt, 'id');
// Mark donation as receipt sent
$db->updateObject('#__mokosuitenpo_donations', (object) [
'id' => $donationId,
'receipt_sent' => 1,
'receipt_sent_date' => date('Y-m-d'),
], 'id');
return (int) $receipt->id;
}
public static function sendReceipt(int $receiptId): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('r.*, cd.name AS donor_name, cd.email_to, cd.address')
->from($db->quoteName('#__mokosuitenpo_tax_receipts', 'r'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = r.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->where('r.id = ' . $receiptId));
$receipt = $db->loadObject();
if (!$receipt || !$receipt->email_to) return false;
$params = Factory::getApplication()->getParams('com_mokosuitenpo');
$orgName = $params->get('org_name', Factory::getApplication()->get('sitename'));
$orgEin = $params->get('org_ein', '');
$orgAddress = $params->get('org_address', '');
$body = "TAX RECEIPT\n"
. str_repeat('=', 50) . "\n\n"
. "Receipt Number: {$receipt->receipt_number}\n"
. "Date: " . date('F j, Y', strtotime($receipt->date_issued)) . "\n\n"
. "Organization: {$orgName}\n"
. ($orgEin ? "EIN: {$orgEin}\n" : '')
. ($orgAddress ? "Address: {$orgAddress}\n" : '')
. "\n"
. "Donor: {$receipt->donor_name}\n"
. "Donation Amount: \$" . number_format((float) $receipt->amount, 2) . "\n"
. "Tax Year: {$receipt->tax_year}\n\n"
. "No goods or services were provided in exchange for this contribution.\n\n"
. "This receipt serves as your official acknowledgment for tax purposes.\n"
. "Please retain for your records.\n\n"
. "Thank you for your generous support!\n";
$mailer = Factory::getMailer();
$mailer->addRecipient($receipt->email_to, $receipt->donor_name);
$mailer->setSubject("Tax Receipt {$receipt->receipt_number} - {$orgName}");
$mailer->setBody($body);
$result = $mailer->Send();
if ($result) {
$db->updateObject('#__mokosuitenpo_tax_receipts', (object) [
'id' => $receiptId, 'sent' => 1, 'sent_date' => Factory::getDate()->toSql(),
], 'id');
}
return (bool) $result;
}
public static function generateYearEndReceipts(int $taxYear): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('DISTINCT donor_id')
->from('#__mokosuitenpo_donations')
->where('YEAR(donation_date) = ' . $taxYear)
->where('is_tax_deductible = 1')
->where('receipt_sent = 0'));
$donorIds = $db->loadColumn() ?: [];
$generated = 0;
foreach ($donorIds as $donorId) {
$db->setQuery($db->getQuery(true)
->select('id')
->from('#__mokosuitenpo_donations')
->where('donor_id = ' . (int) $donorId)
->where('YEAR(donation_date) = ' . $taxYear)
->where('is_tax_deductible = 1')
->where('receipt_sent = 0'));
$donationIds = $db->loadColumn() ?: [];
foreach ($donationIds as $did) {
$receiptId = self::generate((int) $did);
if ($receiptId) {
self::sendReceipt($receiptId);
$generated++;
}
}
}
return $generated;
}
}
@@ -0,0 +1,102 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Volunteer management — registration, hours, skills matching.
*/
class VolunteerHelper
{
public static function register(int $contactId, array $skills = [], array $availability = []): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$volunteer = (object) [
'contact_id' => $contactId,
'status' => 'active',
'skills' => !empty($skills) ? json_encode($skills) : null,
'availability' => !empty($availability) ? json_encode($availability) : null,
'start_date' => date('Y-m-d'),
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitenpo_volunteers', $volunteer, 'id');
return (int) $volunteer->id;
}
public static function logHours(int $volunteerId, string $activity, float $hours, string $date = '', string $notes = ''): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$log = (object) [
'volunteer_id' => $volunteerId,
'activity' => $activity,
'hours' => $hours,
'volunteer_date'=> $date ?: date('Y-m-d'),
'notes' => $notes,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitenpo_volunteer_hours', $log, 'id');
// Update total hours
$db->setQuery($db->getQuery(true)
->update('#__mokosuitenpo_volunteers')
->set('total_hours = total_hours + ' . (float) $hours)
->where('id = ' . $volunteerId));
$db->execute();
return (int) $log->id;
}
public static function getVolunteerStats(): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_volunteers')
->select('SUM(CASE WHEN status = ' . $db->quote('active') . ' THEN 1 ELSE 0 END) AS active')
->select('COALESCE(SUM(total_hours), 0) AS total_hours')
->select('COALESCE(AVG(total_hours), 0) AS avg_hours')
->from('#__mokosuitenpo_volunteers'));
return $db->loadObject() ?: (object) ['total_volunteers' => 0, 'active' => 0, 'total_hours' => 0, 'avg_hours' => 0];
}
public static function findBySkill(string $skill): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('v.*, cd.name, cd.email_to, cd.telephone')
->from($db->quoteName('#__mokosuitenpo_volunteers', 'v'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id')
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('v.skills') . ' LIKE ' . $db->quote('%' . $db->escape($skill) . '%'))
->order('v.total_hours DESC'));
return $db->loadObjectList() ?: [];
}
public static function getHoursReport(string $dateFrom = '', string $dateTo = ''): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$dateFrom = $dateFrom ?: date('Y-01-01');
$dateTo = $dateTo ?: date('Y-m-d');
$db->setQuery($db->getQuery(true)
->select('cd.name AS volunteer_name, SUM(vh.hours) AS total_hours, COUNT(*) AS sessions')
->from($db->quoteName('#__mokosuitenpo_volunteer_hours', 'vh'))
->join('INNER', $db->quoteName('#__mokosuitenpo_volunteers', 'v') . ' ON v.id = vh.volunteer_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id')
->where('vh.volunteer_date BETWEEN ' . $db->quote($dateFrom) . ' AND ' . $db->quote($dateTo))
->group('vh.volunteer_id')
->order('total_hours DESC'));
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,133 @@
<?php
namespace Moko\Plugin\Task\MokoSuiteNpo\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Database\DatabaseInterface;
use Joomla\Event\SubscriberInterface;
/**
* NPO scheduled tasks: tax receipts, donor lapse detection, grant reminders, pledge follow-up.
*/
class NpoAutomation extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
protected const TASKS_MAP = [
'mokosuite.npo.receipts.yearend' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_RECEIPTS_YEAREND',
'method' => 'generateYearEndReceipts',
],
'mokosuite.npo.donors.lapsed' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_DONORS_LAPSED',
'method' => 'detectLapsedDonors',
],
'mokosuite.npo.grants.reminders' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_GRANTS_REMINDERS',
'method' => 'sendGrantReminders',
],
'mokosuite.npo.pledges.followup' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_PLEDGES_FOLLOWUP',
'method' => 'followUpPledges',
],
];
public static function getSubscribedEvents(): array
{
return [
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
'onTaskOptionsList' => 'advertiseRoutines',
];
}
private function generateYearEndReceipts(ExecuteTaskEvent $event): int
{
$count = \Moko\Plugin\System\MokoSuiteNpo\Helper\TaxReceiptHelper::generateYearEndReceipts((int) date('Y') - 1);
Log::add("NPO year-end receipts: generated {$count}", Log::INFO, 'mokosuite.npo');
return Status::OK;
}
private function detectLapsedDonors(ExecuteTaskEvent $event): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->update('#__mokosuitenpo_donors')
->set($db->quoteName('donor_level') . ' = ' . $db->quote('lapsed'))
->where($db->quoteName('last_gift_date') . ' < DATE_SUB(NOW(), INTERVAL 12 MONTH)')
->where($db->quoteName('gift_count') . ' > 0')
->where($db->quoteName('donor_level') . ' != ' . $db->quote('lapsed')));
$db->execute();
$lapsed = $db->getAffectedRows();
if ($lapsed > 0) {
Log::add("NPO lapsed donors: {$lapsed} marked as lapsed", Log::INFO, 'mokosuite.npo');
}
return Status::OK;
}
private function sendGrantReminders(ExecuteTaskEvent $event): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_grants')
->where($db->quoteName('status') . ' IN (' . $db->quote('prospect') . ',' . $db->quote('writing') . ')')
->where($db->quoteName('application_deadline') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY)'));
$grants = $db->loadObjectList() ?: [];
if (!empty($grants)) {
$body = "Grant deadlines approaching:\n\n";
foreach ($grants as $g) {
$days = max(0, round((strtotime($g->application_deadline) - time()) / 86400));
$body .= "- {$g->title} ({$g->funder_name}) — {$days} days left\n";
}
$mailer = Factory::getMailer();
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
$mailer->setSubject('NPO: ' . count($grants) . ' grant deadlines approaching');
$mailer->setBody($body);
$mailer->Send();
}
return Status::OK;
}
private function followUpPledges(ExecuteTaskEvent $event): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('p.*, cd.name AS donor_name, cd.email_to')
->from($db->quoteName('#__mokosuitenpo_pledges', 'p'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = p.donor_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
->where($db->quoteName('p.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('p.due_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY)'));
$pledges = $db->loadObjectList() ?: [];
$sent = 0;
foreach ($pledges as $p) {
if (!$p->email_to) continue;
$remaining = (float) $p->total_amount - (float) $p->amount_fulfilled;
$mailer = Factory::getMailer();
$mailer->addRecipient($p->email_to, $p->donor_name);
$mailer->setSubject('Pledge reminder — $' . number_format($remaining, 2) . ' remaining');
$mailer->setBody("Hi {$p->donor_name},\n\nThis is a friendly reminder about your pledge of \${$p->total_amount}. Your remaining balance is \${$remaining}.\n\nThank you for your generosity!");
$mailer->Send();
$sent++;
}
Log::add("NPO pledge follow-up: sent {$sent} reminders", Log::INFO, 'mokosuite.npo');
return Status::OK;
}
}
@@ -0,0 +1,27 @@
<?php
namespace Moko\Plugin\WebServices\MokoSuiteNpo\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Event\Application\BeforeApiRouteEvent;
use Joomla\Event\SubscriberInterface;
final class MokoSuiteNpoApi extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return ['onBeforeApiRoute' => 'onBeforeApiRoute'];
}
public function onBeforeApiRoute(BeforeApiRouteEvent $event): void
{
$router = $event->getRouter();
$router->createCRUDRoutes('v1/mokosuite/npo/donors', 'npodonors', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/donations', 'npodonations', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/campaigns', 'npocampaigns', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/grants', 'npogrants', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/volunteers', 'npovolunteers', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/events', 'npoevents', ['component' => 'com_mokosuitenpo']);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="package" method="upgrade">
<name>Package - MokoSuite NPO</name>
<packagename>mokosuitenpo</packagename>
<version>01.01.00</version>
<creationDate>2026-06-11</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MokoSuite NPO - nonprofit management: donors, donations, campaigns, grants, volunteers, fund accounting. Layer 2 add-on for MokoSuite (requires CRM).</description>
<php_minimum>8.3</php_minimum>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
<scriptfile>script.php</scriptfile>
<files folder="packages">
<file type="plugin" id="plg_system_mokosuitenpo" group="system">plg_system_mokosuitenpo.zip</file>
<file type="component" id="com_mokosuitenpo">com_mokosuitenpo.zip</file>
<file type="plugin" id="plg_webservices_mokosuitenpo" group="webservices">plg_webservices_mokosuitenpo.zip</file>
<file type="plugin" id="plg_task_mokosuitenpo_task" group="task">plg_task_mokosuitenpo.zip</file>
</files>
<updateservers>
<server type="extension" priority="1" name="Package - MokoSuite NPO">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/updates.xml</server>
</updateservers>
</extension>
+8
View File
@@ -0,0 +1,8 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Installer\InstallerAdapter;
class Pkg_mokosuitenpoInstallerScript
{
public function preflight(string $type, InstallerAdapter $adapter): bool { return true; }
public function postflight(string $type, InstallerAdapter $adapter): void {}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<updates><update>
<name>Package - MokoSuite NPO</name>
<element>pkg_mokosuitenpo</element>
<type>package</type>
<version>01.01.00</version>
<targetplatform name="joomla" version="6.[0-9]" />
<php_minimum>8.3</php_minimum>
</update></updates>