Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88cb3a2fe5 | |||
| 29e87cb572 | |||
| 33f4da29f9 | |||
| d257afa23e | |||
| 38dabdce76 | |||
| c9c5ff7254 | |||
| 9f304902f9 | |||
| 7a033da7ed | |||
| f97c86bc71 | |||
| 2b6d52eeb3 | |||
| 675fff8ca6 | |||
| aee7bd071d | |||
| 63e7e8a922 | |||
| 72c6d9a0e6 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Submodule
+1
Submodule packages/MokoSuiteCRM added at 0c9d985d56
Submodule
+1
Submodule packages/MokoSuiteClient added at 6cd16d9845
@@ -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 ? " · {$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):"—"; ?></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)):"—"; ?><?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">« 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 »</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) : ?>
|
||||
· <?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']);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user