Skip to content

Marketing Planning Module - Implementation Plan

Status: COMPLETE ✓

Completed: 2026-01-21

All components of the Marketing Planning module have been implemented: - Database schema and 8 JPA entities - 8 canonical entities with MarketingFacade (~1800 lines) - 44 REST API endpoints in MarketingApi - WhatsApp notifications for review workflow - Front-end UI (mktplan.html + mktplan.js) - Entity configuration and API role mappings


Executive Summary

This document provides a detailed implementation plan for the Marketing Planning module as specified in the Marketing Planning Requirements. The module enables team members to plan and manage social marketing campaigns with activities, media artifacts, workflow management, and WhatsApp notifications.

1. Database Design

1.1 Schema Overview

All tables will be created in the nts schema following TQPro naming conventions.

1.2 Tables

1.2.1 Team Member Table (mkt_teammember)

CREATE TABLE nts.mkt_teammember (
    teammemberid    INTEGER PRIMARY KEY,
    userid          INTEGER REFERENCES nts.reguser(id),
    displayname     VARCHAR(100) NOT NULL,
    email           VARCHAR(150),
    whatsappnum     VARCHAR(20),
    role            VARCHAR(50),      -- 'admin', 'reviewer', 'creator'
    active          BOOLEAN DEFAULT TRUE,
    created         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    createdby       INTEGER,
    modified        TIMESTAMP,
    modifiedby      INTEGER
);

CREATE SEQUENCE nts.mkt_teammember_seq START 1000;

1.2.2 Audience Segment Table (mkt_audience)

CREATE TABLE nts.mkt_audience (
    audienceid      INTEGER PRIMARY KEY,
    segmentname     VARCHAR(100) NOT NULL,
    description     VARCHAR(500),
    active          BOOLEAN DEFAULT TRUE,
    created         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    createdby       INTEGER
);

CREATE SEQUENCE nts.mkt_audience_seq START 1000;

1.2.3 Campaign/Project Table (mkt_campaign)

CREATE TABLE nts.mkt_campaign (
    campaignid      INTEGER PRIMARY KEY,
    campaignname    VARCHAR(200) NOT NULL,
    description     TEXT,
    goals           TEXT,
    targetedapproach TEXT,
    status          VARCHAR(30) DEFAULT 'CREATED',
        -- CREATED, WORK_IN_PROGRESS, COMPLETED_SCHEDULED,
        -- STARTED, ONGOING, PAUSED, ENDED
    startdate       DATE,
    enddate         DATE,
    createdby       INTEGER REFERENCES nts.mkt_teammember(teammemberid),
    created         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    modifiedby      INTEGER,
    modified        TIMESTAMP
);

CREATE SEQUENCE nts.mkt_campaign_seq START 1000;

1.2.4 Activity Table (mkt_activity)

CREATE TABLE nts.mkt_activity (
    activityid      INTEGER PRIMARY KEY,
    campaignid      INTEGER NOT NULL REFERENCES nts.mkt_campaign(campaignid),
    activityname    VARCHAR(200) NOT NULL,
    activitytype    VARCHAR(30) NOT NULL,
        -- GOOGLE_AD, INSTAGRAM_REEL, INSTAGRAM_STORY, INSTAGRAM_CAROUSEL,
        -- FACEBOOK_REEL, FACEBOOK_STORY, TIKTOK_STORY, TWITTER_POST, BLOG_POST
    description     TEXT,
    storytext       TEXT,               -- Rich text/HTML formatted content
    status          VARCHAR(30) DEFAULT 'CREATED',
        -- CREATED, ASSIGNED, WORK_IN_PROGRESS, SENT_FOR_REVIEW,
        -- APPROVED, CANCELLED
    startdate       DATE,
    duedate         DATE,
    reviewerid      INTEGER REFERENCES nts.mkt_teammember(teammemberid),
    createdby       INTEGER REFERENCES nts.mkt_teammember(teammemberid),
    created         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    modifiedby      INTEGER,
    modified        TIMESTAMP
);

CREATE SEQUENCE nts.mkt_activity_seq START 1000;

CREATE INDEX idx_activity_campaign ON nts.mkt_activity(campaignid);
CREATE INDEX idx_activity_status ON nts.mkt_activity(status);

1.2.5 Activity Assignment Table (mkt_activityassignment)

CREATE TABLE nts.mkt_activityassignment (
    assignmentid    INTEGER PRIMARY KEY,
    activityid      INTEGER NOT NULL REFERENCES nts.mkt_activity(activityid),
    teammemberid    INTEGER NOT NULL REFERENCES nts.mkt_teammember(teammemberid),
    assignedat      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    assignedby      INTEGER REFERENCES nts.mkt_teammember(teammemberid),
    active          BOOLEAN DEFAULT TRUE
);

CREATE SEQUENCE nts.mkt_activityassignment_seq START 1000;

CREATE INDEX idx_assignment_activity ON nts.mkt_activityassignment(activityid);
CREATE INDEX idx_assignment_member ON nts.mkt_activityassignment(teammemberid);

1.2.6 Activity Media Table (mkt_activitymedia)

CREATE TABLE nts.mkt_activitymedia (
    mediaid         INTEGER PRIMARY KEY,
    activityid      INTEGER NOT NULL REFERENCES nts.mkt_activity(activityid),
    mediatype       VARCHAR(30) NOT NULL,  -- VIDEO, IMAGE, GRAPHIC
    mediaurl        VARCHAR(500) NOT NULL,
    filename        VARCHAR(200),
    description     VARCHAR(500),
    sortorder       INTEGER DEFAULT 0,
    created         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    createdby       INTEGER
);

CREATE SEQUENCE nts.mkt_activitymedia_seq START 1000;

CREATE INDEX idx_media_activity ON nts.mkt_activitymedia(activityid);
CREATE TABLE nts.mkt_activityaudience (
    activityaudienceid INTEGER PRIMARY KEY,
    activityid      INTEGER NOT NULL REFERENCES nts.mkt_activity(activityid),
    audienceid      INTEGER NOT NULL REFERENCES nts.mkt_audience(audienceid),
    created         TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE SEQUENCE nts.mkt_activityaudience_seq START 1000;

CREATE INDEX idx_actaud_activity ON nts.mkt_activityaudience(activityid);

1.2.8 Change Log Table (mkt_changelog)

CREATE TABLE nts.mkt_changelog (
    logid           INTEGER PRIMARY KEY,
    entitytype      VARCHAR(30) NOT NULL,  -- CAMPAIGN, ACTIVITY
    entityid        INTEGER NOT NULL,
    action          VARCHAR(50) NOT NULL,  -- STATUS_CHANGE, FIELD_UPDATE, CREATE, etc.
    oldvalue        TEXT,
    newvalue        TEXT,
    fieldname       VARCHAR(100),
    changedby       INTEGER REFERENCES nts.mkt_teammember(teammemberid),
    changedat       TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    notificationsent BOOLEAN DEFAULT FALSE
);

CREATE SEQUENCE nts.mkt_changelog_seq START 1000;

CREATE INDEX idx_changelog_entity ON nts.mkt_changelog(entitytype, entityid);
CREATE INDEX idx_changelog_date ON nts.mkt_changelog(changedat);

1.3 Entity-Relationship Diagram

┌─────────────────┐     ┌─────────────────┐
│  mkt_teammember │     │   mkt_audience  │
├─────────────────┤     ├─────────────────┤
│ teammemberid PK │     │ audienceid PK   │
│ userid FK       │     │ segmentname     │
│ displayname     │     │ description     │
│ whatsappnum     │     │ active          │
└────────┬────────┘     └────────┬────────┘
         │                       │
         │ creates/reviews       │
         ▼                       │
┌─────────────────┐              │
│  mkt_campaign   │              │
├─────────────────┤              │
│ campaignid PK   │              │
│ campaignname    │              │
│ status          │              │
│ createdby FK    │              │
└────────┬────────┘              │
         │ contains              │
         ▼                       │
┌─────────────────┐              │
│  mkt_activity   │◄─────────────┘
├─────────────────┤   (via mkt_activityaudience)
│ activityid PK   │
│ campaignid FK   │
│ activitytype    │
│ status          │
│ storytext       │
│ reviewerid FK   │
│ createdby FK    │
└───┬─────────┬───┘
    │         │
    ▼         ▼
┌────────────┐  ┌─────────────────────┐
│mkt_activity│  │mkt_activityassignment│
│   media    │  ├─────────────────────┤
├────────────┤  │ assignmentid PK     │
│mediaid PK  │  │ activityid FK       │
│activityid  │  │ teammemberid FK     │
│mediaurl    │  └─────────────────────┘
└────────────┘

2. Backend Implementation

2.1 Module Structure

All backend code will be added to the tqapp module following the standard package structure:

tqapp/src/main/java/com/perun/tlinq/
├── client/nts/db/marketing/           # Database entities
│   ├── MktTeammemberEntity.java
│   ├── MktAudienceEntity.java
│   ├── MktCampaignEntity.java
│   ├── MktActivityEntity.java
│   ├── MktActivityassignmentEntity.java
│   ├── MktActivitymediaEntity.java
│   ├── MktActivityaudienceEntity.java
│   └── MktChangelogEntity.java
├── entity/marketing/                   # Canonical entities
│   ├── CTeamMember.java
│   ├── CAudienceSegment.java
│   ├── CCampaign.java
│   ├── CActivity.java
│   ├── CActivityAssignment.java
│   ├── CActivityMedia.java
│   ├── CActivityAudience.java
│   ├── CChangeLog.java
│   └── MarketingFacade.java           # Business facade

2.2 Database Entity Examples

2.2.1 MktCampaignEntity.java

package com.perun.tlinq.client.nts.db.marketing;

import com.perun.tlinq.client.nts.entity.NTSEntity;
import com.perun.tlinq.annotation.TlinqClientEntity;
import com.perun.tlinq.annotation.TlinqEntityField;
import jakarta.persistence.*;
import java.io.Serializable;
import java.sql.Timestamp;
import java.sql.Date;

@Entity
@Table(name = "mkt_campaign", schema = "nts", catalog = "tlinq")
@TlinqClientEntity
public class MktCampaignEntity extends NTSEntity implements Serializable {

    @TlinqEntityField
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "mkt_campaign_gen")
    @SequenceGenerator(name = "mkt_campaign_gen", sequenceName = "nts.mkt_campaign_seq", allocationSize = 1)
    @Column(name = "campaignid")
    private Integer campaignId;

    @TlinqEntityField
    @Column(name = "campaignname", length = 200)
    private String campaignName;

    @TlinqEntityField
    @Column(name = "description", columnDefinition = "TEXT")
    private String description;

    @TlinqEntityField
    @Column(name = "goals", columnDefinition = "TEXT")
    private String goals;

    @TlinqEntityField
    @Column(name = "targetedapproach", columnDefinition = "TEXT")
    private String targetedApproach;

    @TlinqEntityField
    @Column(name = "status", length = 30)
    private String status;

    @TlinqEntityField
    @Column(name = "startdate")
    private Date startDate;

    @TlinqEntityField
    @Column(name = "enddate")
    private Date endDate;

    @TlinqEntityField
    @Column(name = "createdby")
    private Integer createdBy;

    @TlinqEntityField
    @Column(name = "created")
    private Timestamp created;

    @TlinqEntityField
    @Column(name = "modifiedby")
    private Integer modifiedBy;

    @TlinqEntityField
    @Column(name = "modified")
    private Timestamp modified;

    // Getters and Setters
    public Integer getCampaignId() { return campaignId; }
    public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }

    public String getCampaignName() { return campaignName; }
    public void setCampaignName(String campaignName) { this.campaignName = campaignName; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public String getGoals() { return goals; }
    public void setGoals(String goals) { this.goals = goals; }

    public String getTargetedApproach() { return targetedApproach; }
    public void setTargetedApproach(String targetedApproach) { this.targetedApproach = targetedApproach; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public Date getStartDate() { return startDate; }
    public void setStartDate(Date startDate) { this.startDate = startDate; }

    public Date getEndDate() { return endDate; }
    public void setEndDate(Date endDate) { this.endDate = endDate; }

    public Integer getCreatedBy() { return createdBy; }
    public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }

    public Timestamp getCreated() { return created; }
    public void setCreated(Timestamp created) { this.created = created; }

    public Integer getModifiedBy() { return modifiedBy; }
    public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }

    public Timestamp getModified() { return modified; }
    public void setModified(Timestamp modified) { this.modified = modified; }
}

2.2.2 MktActivityEntity.java

package com.perun.tlinq.client.nts.db.marketing;

import com.perun.tlinq.client.nts.entity.NTSEntity;
import com.perun.tlinq.annotation.TlinqClientEntity;
import com.perun.tlinq.annotation.TlinqEntityField;
import jakarta.persistence.*;
import java.io.Serializable;
import java.sql.Timestamp;
import java.sql.Date;

@Entity
@Table(name = "mkt_activity", schema = "nts", catalog = "tlinq")
@TlinqClientEntity
public class MktActivityEntity extends NTSEntity implements Serializable {

    @TlinqEntityField
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "mkt_activity_gen")
    @SequenceGenerator(name = "mkt_activity_gen", sequenceName = "nts.mkt_activity_seq", allocationSize = 1)
    @Column(name = "activityid")
    private Integer activityId;

    @TlinqEntityField
    @Column(name = "campaignid")
    private Integer campaignId;

    @TlinqEntityField
    @Column(name = "activityname", length = 200)
    private String activityName;

    @TlinqEntityField
    @Column(name = "activitytype", length = 30)
    private String activityType;

    @TlinqEntityField
    @Column(name = "description", columnDefinition = "TEXT")
    private String description;

    @TlinqEntityField
    @Column(name = "storytext", columnDefinition = "TEXT")
    private String storyText;

    @TlinqEntityField
    @Column(name = "status", length = 30)
    private String status;

    @TlinqEntityField
    @Column(name = "startdate")
    private Date startDate;

    @TlinqEntityField
    @Column(name = "duedate")
    private Date dueDate;

    @TlinqEntityField
    @Column(name = "reviewerid")
    private Integer reviewerId;

    @TlinqEntityField
    @Column(name = "createdby")
    private Integer createdBy;

    @TlinqEntityField
    @Column(name = "created")
    private Timestamp created;

    @TlinqEntityField
    @Column(name = "modifiedby")
    private Integer modifiedBy;

    @TlinqEntityField
    @Column(name = "modified")
    private Timestamp modified;

    // Getters and Setters
    public Integer getActivityId() { return activityId; }
    public void setActivityId(Integer activityId) { this.activityId = activityId; }

    public Integer getCampaignId() { return campaignId; }
    public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }

    public String getActivityName() { return activityName; }
    public void setActivityName(String activityName) { this.activityName = activityName; }

    public String getActivityType() { return activityType; }
    public void setActivityType(String activityType) { this.activityType = activityType; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public String getStoryText() { return storyText; }
    public void setStoryText(String storyText) { this.storyText = storyText; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public Date getStartDate() { return startDate; }
    public void setStartDate(Date startDate) { this.startDate = startDate; }

    public Date getDueDate() { return dueDate; }
    public void setDueDate(Date dueDate) { this.dueDate = dueDate; }

    public Integer getReviewerId() { return reviewerId; }
    public void setReviewerId(Integer reviewerId) { this.reviewerId = reviewerId; }

    public Integer getCreatedBy() { return createdBy; }
    public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }

    public Timestamp getCreated() { return created; }
    public void setCreated(Timestamp created) { this.created = created; }

    public Integer getModifiedBy() { return modifiedBy; }
    public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }

    public Timestamp getModified() { return modified; }
    public void setModified(Timestamp modified) { this.modified = modified; }
}

2.3 Canonical Entity Examples

2.3.1 CCampaign.java

package com.perun.tlinq.entity.marketing;

import com.perun.tlinq.entity.TlinqEntity;
import java.io.Serializable;
import java.util.Date;
import java.util.List;

public class CCampaign extends TlinqEntity implements Serializable {

    private Integer campaignId;
    private String campaignName;
    private String description;
    private String goals;
    private String targetedApproach;
    private String status;
    private Date startDate;
    private Date endDate;
    private Integer createdBy;
    private String createdByName;       // Denormalized for display
    private Date created;
    private Integer modifiedBy;
    private Date modified;

    // Associated activities (loaded on demand)
    private List<CActivity> activities;

    // Getters and Setters
    public Integer getCampaignId() { return campaignId; }
    public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }

    public String getCampaignName() { return campaignName; }
    public void setCampaignName(String campaignName) { this.campaignName = campaignName; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public String getGoals() { return goals; }
    public void setGoals(String goals) { this.goals = goals; }

    public String getTargetedApproach() { return targetedApproach; }
    public void setTargetedApproach(String targetedApproach) { this.targetedApproach = targetedApproach; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public Date getStartDate() { return startDate; }
    public void setStartDate(Date startDate) { this.startDate = startDate; }

    public Date getEndDate() { return endDate; }
    public void setEndDate(Date endDate) { this.endDate = endDate; }

    public Integer getCreatedBy() { return createdBy; }
    public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }

    public String getCreatedByName() { return createdByName; }
    public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }

    public Date getCreated() { return created; }
    public void setCreated(Date created) { this.created = created; }

    public Integer getModifiedBy() { return modifiedBy; }
    public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }

    public Date getModified() { return modified; }
    public void setModified(Date modified) { this.modified = modified; }

    public List<CActivity> getActivities() { return activities; }
    public void setActivities(List<CActivity> activities) { this.activities = activities; }
}

2.3.2 CActivity.java

package com.perun.tlinq.entity.marketing;

import com.perun.tlinq.entity.TlinqEntity;
import java.io.Serializable;
import java.util.Date;
import java.util.List;

public class CActivity extends TlinqEntity implements Serializable {

    private Integer activityId;
    private Integer campaignId;
    private String campaignName;        // Denormalized for display
    private String activityName;
    private String activityType;
    private String description;
    private String storyText;
    private String status;
    private Date startDate;
    private Date dueDate;
    private Integer reviewerId;
    private String reviewerName;        // Denormalized for display
    private Integer createdBy;
    private String createdByName;       // Denormalized for display
    private Date created;
    private Integer modifiedBy;
    private Date modified;

    // Associated data (loaded on demand)
    private List<CTeamMember> assignees;
    private List<CActivityMedia> mediaItems;
    private List<CAudienceSegment> audienceSegments;

    // Getters and Setters
    public Integer getActivityId() { return activityId; }
    public void setActivityId(Integer activityId) { this.activityId = activityId; }

    public Integer getCampaignId() { return campaignId; }
    public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }

    public String getCampaignName() { return campaignName; }
    public void setCampaignName(String campaignName) { this.campaignName = campaignName; }

    public String getActivityName() { return activityName; }
    public void setActivityName(String activityName) { this.activityName = activityName; }

    public String getActivityType() { return activityType; }
    public void setActivityType(String activityType) { this.activityType = activityType; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public String getStoryText() { return storyText; }
    public void setStoryText(String storyText) { this.storyText = storyText; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public Date getStartDate() { return startDate; }
    public void setStartDate(Date startDate) { this.startDate = startDate; }

    public Date getDueDate() { return dueDate; }
    public void setDueDate(Date dueDate) { this.dueDate = dueDate; }

    public Integer getReviewerId() { return reviewerId; }
    public void setReviewerId(Integer reviewerId) { this.reviewerId = reviewerId; }

    public String getReviewerName() { return reviewerName; }
    public void setReviewerName(String reviewerName) { this.reviewerName = reviewerName; }

    public Integer getCreatedBy() { return createdBy; }
    public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }

    public String getCreatedByName() { return createdByName; }
    public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }

    public Date getCreated() { return created; }
    public void setCreated(Date created) { this.created = created; }

    public Integer getModifiedBy() { return modifiedBy; }
    public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }

    public Date getModified() { return modified; }
    public void setModified(Date modified) { this.modified = modified; }

    public List<CTeamMember> getAssignees() { return assignees; }
    public void setAssignees(List<CTeamMember> assignees) { this.assignees = assignees; }

    public List<CActivityMedia> getMediaItems() { return mediaItems; }
    public void setMediaItems(List<CActivityMedia> mediaItems) { this.mediaItems = mediaItems; }

    public List<CAudienceSegment> getAudienceSegments() { return audienceSegments; }
    public void setAudienceSegments(List<CAudienceSegment> audienceSegments) { this.audienceSegments = audienceSegments; }
}

2.4 Business Facade

2.4.1 MarketingFacade.java

package com.perun.tlinq.entity.marketing;

import com.perun.tlinq.entity.EntityFacade;
import com.perun.tlinq.exception.TlinqClientException;
import com.perun.tlinq.search.SelectCriteriaList;
import java.util.List;
import java.util.Map;

public class MarketingFacade extends EntityFacade {

    private static final String CAMPAIGN_ENTITY = "Campaign";
    private static final String ACTIVITY_ENTITY = "Activity";
    private static final String TEAMMEMBER_ENTITY = "TeamMember";
    private static final String AUDIENCE_ENTITY = "AudienceSegment";

    private CCampaign currentCampaign = null;
    private CActivity currentActivity = null;

    // Constructor variants
    public MarketingFacade(String token) {
        super(token);
    }

    public MarketingFacade(String token, Integer campaignId) throws TlinqClientException {
        super(token);
        loadCampaign(campaignId);
    }

    // ==================== Campaign Operations ====================

    public void loadCampaign(Integer campaignId) throws TlinqClientException {
        currentCampaign = (CCampaign) read(CAMPAIGN_ENTITY, campaignId);
        if (currentCampaign != null) {
            loadCampaignActivities();
        }
    }

    public List<CCampaign> listCampaigns(SelectCriteriaList criteria) throws TlinqClientException {
        return search(CAMPAIGN_ENTITY, criteria);
    }

    public CCampaign createCampaign(CCampaign campaign) throws TlinqClientException {
        campaign.setStatus("CREATED");
        return (CCampaign) create(CAMPAIGN_ENTITY, campaign);
    }

    public CCampaign updateCampaign(CCampaign campaign) throws TlinqClientException {
        return (CCampaign) update(CAMPAIGN_ENTITY, campaign);
    }

    public void changeCampaignStatus(Integer campaignId, String newStatus) throws TlinqClientException {
        loadCampaign(campaignId);
        validateCampaignStatusTransition(currentCampaign.getStatus(), newStatus);

        String oldStatus = currentCampaign.getStatus();
        currentCampaign.setStatus(newStatus);
        updateCampaign(currentCampaign);

        // Log the change
        logChange("CAMPAIGN", campaignId, "STATUS_CHANGE", oldStatus, newStatus, "status");

        // Send notification
        sendStatusChangeNotification("CAMPAIGN", campaignId, oldStatus, newStatus);
    }

    private void validateCampaignStatusTransition(String from, String to) throws TlinqClientException {
        // CREATED -> WORK_IN_PROGRESS
        // WORK_IN_PROGRESS -> COMPLETED_SCHEDULED (only if all activities approved)
        // COMPLETED_SCHEDULED -> STARTED
        // STARTED -> ONGOING | PAUSED | ENDED
        // ONGOING -> PAUSED | ENDED
        // PAUSED -> ONGOING | ENDED

        boolean valid = switch (from) {
            case "CREATED" -> "WORK_IN_PROGRESS".equals(to);
            case "WORK_IN_PROGRESS" -> "COMPLETED_SCHEDULED".equals(to);
            case "COMPLETED_SCHEDULED" -> "STARTED".equals(to) || "WORK_IN_PROGRESS".equals(to);
            case "STARTED" -> List.of("ONGOING", "PAUSED", "ENDED").contains(to);
            case "ONGOING" -> List.of("PAUSED", "ENDED").contains(to);
            case "PAUSED" -> List.of("ONGOING", "ENDED").contains(to);
            default -> false;
        };

        if (!valid) {
            throw new TlinqClientException("Invalid status transition from " + from + " to " + to);
        }

        // Special check: COMPLETED_SCHEDULED requires all activities approved
        if ("COMPLETED_SCHEDULED".equals(to)) {
            loadCampaignActivities();
            for (CActivity activity : currentCampaign.getActivities()) {
                if (!"APPROVED".equals(activity.getStatus()) && !"CANCELLED".equals(activity.getStatus())) {
                    throw new TlinqClientException("Cannot complete campaign: Activity '" +
                        activity.getActivityName() + "' is not approved");
                }
            }
        }
    }

    private void loadCampaignActivities() throws TlinqClientException {
        SelectCriteriaList criteria = new SelectCriteriaList();
        criteria.addCriteria("campaignId", currentCampaign.getCampaignId());
        List<CActivity> activities = search(ACTIVITY_ENTITY, criteria);
        currentCampaign.setActivities(activities);
    }

    // ==================== Activity Operations ====================

    public void loadActivity(Integer activityId) throws TlinqClientException {
        currentActivity = (CActivity) read(ACTIVITY_ENTITY, activityId);
        if (currentActivity != null) {
            loadActivityDetails();
        }
    }

    public CActivity createActivity(CActivity activity) throws TlinqClientException {
        activity.setStatus("CREATED");
        CActivity created = (CActivity) create(ACTIVITY_ENTITY, activity);
        logChange("ACTIVITY", created.getActivityId(), "CREATE", null, "CREATED", null);
        return created;
    }

    public CActivity updateActivity(CActivity activity) throws TlinqClientException {
        if ("APPROVED".equals(activity.getStatus())) {
            throw new TlinqClientException("Cannot modify approved activity");
        }
        return (CActivity) update(ACTIVITY_ENTITY, activity);
    }

    public void changeActivityStatus(Integer activityId, String newStatus) throws TlinqClientException {
        loadActivity(activityId);
        validateActivityStatusTransition(currentActivity.getStatus(), newStatus);

        String oldStatus = currentActivity.getStatus();
        currentActivity.setStatus(newStatus);
        update(ACTIVITY_ENTITY, currentActivity);

        // Log the change
        logChange("ACTIVITY", activityId, "STATUS_CHANGE", oldStatus, newStatus, "status");

        // Send notification
        sendStatusChangeNotification("ACTIVITY", activityId, oldStatus, newStatus);

        // If activity is revoked (back to WORK_IN_PROGRESS), update campaign status
        if ("WORK_IN_PROGRESS".equals(newStatus) && "APPROVED".equals(oldStatus)) {
            CCampaign campaign = (CCampaign) read(CAMPAIGN_ENTITY, currentActivity.getCampaignId());
            if ("COMPLETED_SCHEDULED".equals(campaign.getStatus())) {
                campaign.setStatus("WORK_IN_PROGRESS");
                update(CAMPAIGN_ENTITY, campaign);
                logChange("CAMPAIGN", campaign.getCampaignId(), "STATUS_CHANGE",
                    "COMPLETED_SCHEDULED", "WORK_IN_PROGRESS", "status");
            }
        }
    }

    private void validateActivityStatusTransition(String from, String to) throws TlinqClientException {
        // CREATED -> ASSIGNED
        // ASSIGNED -> WORK_IN_PROGRESS | CANCELLED
        // WORK_IN_PROGRESS -> SENT_FOR_REVIEW | CANCELLED
        // SENT_FOR_REVIEW -> APPROVED | WORK_IN_PROGRESS (returned)
        // APPROVED -> WORK_IN_PROGRESS (revoked by admin)

        boolean valid = switch (from) {
            case "CREATED" -> "ASSIGNED".equals(to);
            case "ASSIGNED" -> List.of("WORK_IN_PROGRESS", "CANCELLED").contains(to);
            case "WORK_IN_PROGRESS" -> List.of("SENT_FOR_REVIEW", "CANCELLED").contains(to);
            case "SENT_FOR_REVIEW" -> List.of("APPROVED", "WORK_IN_PROGRESS").contains(to);
            case "APPROVED" -> "WORK_IN_PROGRESS".equals(to);  // Admin revoke
            default -> false;
        };

        if (!valid) {
            throw new TlinqClientException("Invalid status transition from " + from + " to " + to);
        }
    }

    public void sendForReview(Integer activityId, Integer reviewerId) throws TlinqClientException {
        loadActivity(activityId);

        if (!"WORK_IN_PROGRESS".equals(currentActivity.getStatus())) {
            throw new TlinqClientException("Activity must be in WORK_IN_PROGRESS status to send for review");
        }

        currentActivity.setReviewerId(reviewerId);
        currentActivity.setStatus("SENT_FOR_REVIEW");
        update(ACTIVITY_ENTITY, currentActivity);

        logChange("ACTIVITY", activityId, "SENT_FOR_REVIEW", null, String.valueOf(reviewerId), "reviewerId");

        // Send WhatsApp notification to reviewer
        sendReviewRequestNotification(activityId, reviewerId);
    }

    public void approveActivity(Integer activityId) throws TlinqClientException {
        changeActivityStatus(activityId, "APPROVED");
        sendApprovalNotification(activityId, true);
    }

    public void returnActivity(Integer activityId, String reason) throws TlinqClientException {
        changeActivityStatus(activityId, "WORK_IN_PROGRESS");
        sendApprovalNotification(activityId, false);
    }

    private void loadActivityDetails() throws TlinqClientException {
        // Load assignees
        SelectCriteriaList assignCriteria = new SelectCriteriaList();
        assignCriteria.addCriteria("activityId", currentActivity.getActivityId());
        // ... load from assignment entity and resolve team members

        // Load media items
        // ... similar pattern

        // Load audience segments
        // ... similar pattern
    }

    // ==================== Assignment Operations ====================

    public void assignActivity(Integer activityId, List<Integer> teamMemberIds) throws TlinqClientException {
        loadActivity(activityId);

        for (Integer memberId : teamMemberIds) {
            CActivityAssignment assignment = new CActivityAssignment();
            assignment.setActivityId(activityId);
            assignment.setTeamMemberId(memberId);
            create("ActivityAssignment", assignment);
        }

        // Update activity status to ASSIGNED if currently CREATED
        if ("CREATED".equals(currentActivity.getStatus())) {
            changeActivityStatus(activityId, "ASSIGNED");
        }
    }

    // ==================== Media Operations ====================

    public CActivityMedia addMedia(Integer activityId, CActivityMedia media) throws TlinqClientException {
        loadActivity(activityId);
        if ("APPROVED".equals(currentActivity.getStatus())) {
            throw new TlinqClientException("Cannot add media to approved activity");
        }

        media.setActivityId(activityId);
        return (CActivityMedia) create("ActivityMedia", media);
    }

    public void removeMedia(Integer mediaId) throws TlinqClientException {
        // Verify activity is not approved before deleting
        CActivityMedia media = (CActivityMedia) read("ActivityMedia", mediaId);
        loadActivity(media.getActivityId());

        if ("APPROVED".equals(currentActivity.getStatus())) {
            throw new TlinqClientException("Cannot remove media from approved activity");
        }

        delete("ActivityMedia", mediaId);
    }

    // ==================== Change Logging ====================

    private void logChange(String entityType, Integer entityId, String action,
            String oldValue, String newValue, String fieldName) throws TlinqClientException {
        CChangeLog log = new CChangeLog();
        log.setEntityType(entityType);
        log.setEntityId(entityId);
        log.setAction(action);
        log.setOldValue(oldValue);
        log.setNewValue(newValue);
        log.setFieldName(fieldName);
        // changedBy will be set from session
        create("ChangeLog", log);
    }

    // ==================== Notification Methods ====================

    private void sendStatusChangeNotification(String entityType, Integer entityId,
            String oldStatus, String newStatus) {
        // Implementation: Send WhatsApp message to campaign/activity creator
        // Use external WhatsApp API service
    }

    private void sendReviewRequestNotification(Integer activityId, Integer reviewerId) {
        // Implementation: Send WhatsApp message to reviewer
    }

    private void sendApprovalNotification(Integer activityId, boolean approved) {
        // Implementation: Send WhatsApp message to activity creator
    }

    // ==================== Getters ====================

    public CCampaign getCurrentCampaign() { return currentCampaign; }
    public CActivity getCurrentActivity() { return currentActivity; }
}

2.5 Entity XML Configuration

Create file: config/entities/marketing-entities.xml

<!--
    Entity configurations for Marketing Planning Module
    This file is included in the main tourlinq-config.xml via XInclude
-->

<!-- Team Member Entity -->
<Entity name="TeamMember" class="com.perun.tlinq.entity.marketing.CTeamMember"
        idField="teamMemberId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktTeammemberEntity">
            <ServiceList>
                <Service name="saveTeamMember" action="update"/>
                <Service name="saveTeamMember" action="create"/>
                <Service name="readTeamMember" action="read"/>
                <Service name="searchTeamMembers" action="search"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="teamMemberId" sourceField="teammemberid" mapping="DirectMapping"/>
                <FieldMapping targetField="userId" sourceField="userid" mapping="DirectMapping"/>
                <FieldMapping targetField="displayName" sourceField="displayname" mapping="DirectMapping"/>
                <FieldMapping targetField="email" sourceField="email" mapping="DirectMapping"/>
                <FieldMapping targetField="whatsappNum" sourceField="whatsappnum" mapping="DirectMapping"/>
                <FieldMapping targetField="role" sourceField="role" mapping="DirectMapping"/>
                <FieldMapping targetField="active" sourceField="active" mapping="DirectMapping"/>
                <FieldMapping targetField="created" sourceField="created" mapping="DirectMapping"/>
                <FieldMapping targetField="createdBy" sourceField="createdby" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Audience Segment Entity -->
<Entity name="AudienceSegment" class="com.perun.tlinq.entity.marketing.CAudienceSegment"
        idField="audienceId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktAudienceEntity">
            <ServiceList>
                <Service name="saveAudience" action="update"/>
                <Service name="saveAudience" action="create"/>
                <Service name="readAudience" action="read"/>
                <Service name="searchAudiences" action="search"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="audienceId" sourceField="audienceid" mapping="DirectMapping"/>
                <FieldMapping targetField="segmentName" sourceField="segmentname" mapping="DirectMapping"/>
                <FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
                <FieldMapping targetField="active" sourceField="active" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Campaign Entity -->
<Entity name="Campaign" class="com.perun.tlinq.entity.marketing.CCampaign"
        idField="campaignId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktCampaignEntity">
            <ServiceList>
                <Service name="saveCampaign" action="update"/>
                <Service name="saveCampaign" action="create"/>
                <Service name="readCampaign" action="read"/>
                <Service name="searchCampaigns" action="search"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="campaignId" sourceField="campaignid" mapping="DirectMapping"/>
                <FieldMapping targetField="campaignName" sourceField="campaignname" mapping="DirectMapping"/>
                <FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
                <FieldMapping targetField="goals" sourceField="goals" mapping="DirectMapping"/>
                <FieldMapping targetField="targetedApproach" sourceField="targetedapproach" mapping="DirectMapping"/>
                <FieldMapping targetField="status" sourceField="status" mapping="DirectMapping"/>
                <FieldMapping targetField="startDate" sourceField="startdate" mapping="DirectMapping"/>
                <FieldMapping targetField="endDate" sourceField="enddate" mapping="DirectMapping"/>
                <FieldMapping targetField="createdBy" sourceField="createdby" mapping="DirectMapping"/>
                <FieldMapping targetField="created" sourceField="created" mapping="DirectMapping"/>
                <FieldMapping targetField="modifiedBy" sourceField="modifiedby" mapping="DirectMapping"/>
                <FieldMapping targetField="modified" sourceField="modified" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Activity Entity -->
<Entity name="Activity" class="com.perun.tlinq.entity.marketing.CActivity"
        idField="activityId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivityEntity">
            <ServiceList>
                <Service name="saveActivity" action="update"/>
                <Service name="saveActivity" action="create"/>
                <Service name="readActivity" action="read"/>
                <Service name="searchActivities" action="search"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
                <FieldMapping targetField="campaignId" sourceField="campaignid" mapping="DirectMapping"/>
                <FieldMapping targetField="activityName" sourceField="activityname" mapping="DirectMapping"/>
                <FieldMapping targetField="activityType" sourceField="activitytype" mapping="DirectMapping"/>
                <FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
                <FieldMapping targetField="storyText" sourceField="storytext" mapping="DirectMapping"/>
                <FieldMapping targetField="status" sourceField="status" mapping="DirectMapping"/>
                <FieldMapping targetField="startDate" sourceField="startdate" mapping="DirectMapping"/>
                <FieldMapping targetField="dueDate" sourceField="duedate" mapping="DirectMapping"/>
                <FieldMapping targetField="reviewerId" sourceField="reviewerid" mapping="DirectMapping"/>
                <FieldMapping targetField="createdBy" sourceField="createdby" mapping="DirectMapping"/>
                <FieldMapping targetField="created" sourceField="created" mapping="DirectMapping"/>
                <FieldMapping targetField="modifiedBy" sourceField="modifiedby" mapping="DirectMapping"/>
                <FieldMapping targetField="modified" sourceField="modified" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Activity Assignment Entity -->
<Entity name="ActivityAssignment" class="com.perun.tlinq.entity.marketing.CActivityAssignment"
        idField="assignmentId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivityassignmentEntity">
            <ServiceList>
                <Service name="saveAssignment" action="update"/>
                <Service name="saveAssignment" action="create"/>
                <Service name="readAssignment" action="read"/>
                <Service name="searchAssignments" action="search"/>
                <Service name="deleteAssignment" action="delete"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="assignmentId" sourceField="assignmentid" mapping="DirectMapping"/>
                <FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
                <FieldMapping targetField="teamMemberId" sourceField="teammemberid" mapping="DirectMapping"/>
                <FieldMapping targetField="assignedAt" sourceField="assignedat" mapping="DirectMapping"/>
                <FieldMapping targetField="assignedBy" sourceField="assignedby" mapping="DirectMapping"/>
                <FieldMapping targetField="active" sourceField="active" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Activity Media Entity -->
<Entity name="ActivityMedia" class="com.perun.tlinq.entity.marketing.CActivityMedia"
        idField="mediaId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivitymediaEntity">
            <ServiceList>
                <Service name="saveMedia" action="update"/>
                <Service name="saveMedia" action="create"/>
                <Service name="readMedia" action="read"/>
                <Service name="searchMedia" action="search"/>
                <Service name="deleteMedia" action="delete"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="mediaId" sourceField="mediaid" mapping="DirectMapping"/>
                <FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
                <FieldMapping targetField="mediaType" sourceField="mediatype" mapping="DirectMapping"/>
                <FieldMapping targetField="mediaUrl" sourceField="mediaurl" mapping="DirectMapping"/>
                <FieldMapping targetField="fileName" sourceField="filename" mapping="DirectMapping"/>
                <FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
                <FieldMapping targetField="sortOrder" sourceField="sortorder" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Activity Audience Entity -->
<Entity name="ActivityAudience" class="com.perun.tlinq.entity.marketing.CActivityAudience"
        idField="activityAudienceId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivityaudienceEntity">
            <ServiceList>
                <Service name="saveActivityAudience" action="update"/>
                <Service name="saveActivityAudience" action="create"/>
                <Service name="searchActivityAudience" action="search"/>
                <Service name="deleteActivityAudience" action="delete"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="activityAudienceId" sourceField="activityaudienceid" mapping="DirectMapping"/>
                <FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
                <FieldMapping targetField="audienceId" sourceField="audienceid" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

<!-- Change Log Entity -->
<Entity name="ChangeLog" class="com.perun.tlinq.entity.marketing.CChangeLog"
        idField="logId" defaultFactory="NTSServiceFactory">
    <EntityFactoryList>
        <Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktChangelogEntity">
            <ServiceList>
                <Service name="saveChangeLog" action="create"/>
                <Service name="searchChangeLogs" action="search"/>
            </ServiceList>
            <FieldMappingList>
                <FieldMapping targetField="logId" sourceField="logid" mapping="DirectMapping"/>
                <FieldMapping targetField="entityType" sourceField="entitytype" mapping="DirectMapping"/>
                <FieldMapping targetField="entityId" sourceField="entityid" mapping="DirectMapping"/>
                <FieldMapping targetField="action" sourceField="action" mapping="DirectMapping"/>
                <FieldMapping targetField="oldValue" sourceField="oldvalue" mapping="DirectMapping"/>
                <FieldMapping targetField="newValue" sourceField="newvalue" mapping="DirectMapping"/>
                <FieldMapping targetField="fieldName" sourceField="fieldname" mapping="DirectMapping"/>
                <FieldMapping targetField="changedBy" sourceField="changedby" mapping="DirectMapping"/>
                <FieldMapping targetField="changedAt" sourceField="changedat" mapping="DirectMapping"/>
                <FieldMapping targetField="notificationSent" sourceField="notificationsent" mapping="DirectMapping"/>
            </FieldMappingList>
        </Factory>
    </EntityFactoryList>
</Entity>

Update tourlinq-config.xml DOCTYPE to include:

<!ENTITY marketing-entities SYSTEM "entities/marketing-entities.xml">

And in the <Entities> section:

<!-- Marketing entities -->
&marketing-entities;


3. REST API Implementation

3.1 MarketingApi.java

Create file: tqapi/src/main/java/com/perun/tlinq/api/MarketingApi.java

package com.perun.tlinq.api;

import com.perun.tlinq.entity.marketing.*;
import com.perun.tlinq.api.entity.TlinqApiResponse;
import com.perun.tlinq.exception.TlinqClientException;
import com.perun.tlinq.search.SelectCriteriaList;
import com.perun.tlinq.TlinqErr;

import jakarta.servlet.http.HttpServlet;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

@Path("/marketing")
public class MarketingApi extends HttpServlet {

    private static final Logger logger = Logger.getLogger(MarketingApi.class.getName());

    // ==================== Campaign Endpoints ====================

    @POST
    @Path("/campaign/list")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response listCampaigns(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN listCampaigns for " + session);
            ar = doListCampaigns(session, reqData);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END listCampaigns for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/campaign/read")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response readCampaign(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, true);
            logger.info("BEGIN readCampaign for " + session + " campaignId=" + campaignId);
            ar = doReadCampaign(session, campaignId);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END readCampaign for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/campaign/create")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response createCampaign(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN createCampaign for " + session);
            ar = doCreateCampaign(session, reqData);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END createCampaign for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/campaign/update")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response updateCampaign(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN updateCampaign for " + session);
            ar = doUpdateCampaign(session, reqData);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END updateCampaign for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/campaign/changeStatus")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response changeCampaignStatus(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, true);
            String newStatus = ApiUtil.gmp(reqData, "status", String.class, true);
            logger.info("BEGIN changeCampaignStatus for " + session);
            ar = doChangeCampaignStatus(session, campaignId, newStatus);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END changeCampaignStatus for " + session);
        return Response.ok(ar).build();
    }

    // ==================== Activity Endpoints ====================

    @POST
    @Path("/activity/list")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response listActivities(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, false);
            logger.info("BEGIN listActivities for " + session);
            ar = doListActivities(session, campaignId);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END listActivities for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/read")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response readActivity(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
            logger.info("BEGIN readActivity for " + session);
            ar = doReadActivity(session, activityId);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END readActivity for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/create")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response createActivity(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN createActivity for " + session);
            ar = doCreateActivity(session, reqData);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END createActivity for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/update")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response updateActivity(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN updateActivity for " + session);
            ar = doUpdateActivity(session, reqData);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END updateActivity for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/changeStatus")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response changeActivityStatus(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
            String newStatus = ApiUtil.gmp(reqData, "status", String.class, true);
            logger.info("BEGIN changeActivityStatus for " + session);
            ar = doChangeActivityStatus(session, activityId, newStatus);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END changeActivityStatus for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/sendForReview")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response sendForReview(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
            Integer reviewerId = ApiUtil.gmp(reqData, "reviewerId", Integer.class, true);
            logger.info("BEGIN sendForReview for " + session);
            ar = doSendForReview(session, activityId, reviewerId);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END sendForReview for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/approve")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response approveActivity(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
            logger.info("BEGIN approveActivity for " + session);
            ar = doApproveActivity(session, activityId);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END approveActivity for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/return")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response returnActivity(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
            String reason = ApiUtil.gmp(reqData, "reason", String.class, false);
            logger.info("BEGIN returnActivity for " + session);
            ar = doReturnActivity(session, activityId, reason);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END returnActivity for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/activity/assign")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response assignActivity(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
            List<Integer> teamMemberIds = ApiUtil.gmp(reqData, "teamMemberIds", List.class, true);
            logger.info("BEGIN assignActivity for " + session);
            ar = doAssignActivity(session, activityId, teamMemberIds);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END assignActivity for " + session);
        return Response.ok(ar).build();
    }

    // ==================== Media Endpoints ====================

    @POST
    @Path("/media/add")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response addMedia(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN addMedia for " + session);
            ar = doAddMedia(session, reqData);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END addMedia for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/media/remove")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response removeMedia(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            Integer mediaId = ApiUtil.gmp(reqData, "mediaId", Integer.class, true);
            logger.info("BEGIN removeMedia for " + session);
            ar = doRemoveMedia(session, mediaId);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END removeMedia for " + session);
        return Response.ok(ar).build();
    }

    // ==================== Reference Data Endpoints ====================

    @POST
    @Path("/teamMembers/list")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response listTeamMembers(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN listTeamMembers for " + session);
            ar = doListTeamMembers(session);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END listTeamMembers for " + session);
        return Response.ok(ar).build();
    }

    @POST
    @Path("/audiences/list")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response listAudiences(Map reqData) {
        TlinqApiResponse ar;
        String session = "";
        try {
            session = ApiUtil.gmp(reqData, "session", String.class, true);
            logger.info("BEGIN listAudiences for " + session);
            ar = doListAudiences(session);
        } catch (Exception ex) {
            ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
        }
        logger.info("END listAudiences for " + session);
        return Response.ok(ar).build();
    }

    // ==================== Implementation Methods ====================

    private TlinqApiResponse doListCampaigns(String session, Map reqData) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        SelectCriteriaList criteria = new SelectCriteriaList();
        // Add filtering based on reqData if needed
        List<CCampaign> campaigns = facade.listCampaigns(criteria);
        return new TlinqApiResponse(campaigns);
    }

    private TlinqApiResponse doReadCampaign(String session, Integer campaignId) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session, campaignId);
        return new TlinqApiResponse(facade.getCurrentCampaign());
    }

    private TlinqApiResponse doCreateCampaign(String session, Map reqData) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        CCampaign campaign = new CCampaign();
        campaign.setCampaignName(ApiUtil.gmp(reqData, "campaignName", String.class, true));
        campaign.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
        campaign.setGoals(ApiUtil.gmp(reqData, "goals", String.class, false));
        campaign.setTargetedApproach(ApiUtil.gmp(reqData, "targetedApproach", String.class, false));
        // Set more fields as needed
        CCampaign created = facade.createCampaign(campaign);
        return new TlinqApiResponse(created);
    }

    private TlinqApiResponse doUpdateCampaign(String session, Map reqData) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, true);
        facade.loadCampaign(campaignId);
        CCampaign campaign = facade.getCurrentCampaign();

        // Update fields from reqData
        if (reqData.containsKey("campaignName"))
            campaign.setCampaignName(ApiUtil.gmp(reqData, "campaignName", String.class, false));
        if (reqData.containsKey("description"))
            campaign.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
        if (reqData.containsKey("goals"))
            campaign.setGoals(ApiUtil.gmp(reqData, "goals", String.class, false));
        if (reqData.containsKey("targetedApproach"))
            campaign.setTargetedApproach(ApiUtil.gmp(reqData, "targetedApproach", String.class, false));

        CCampaign updated = facade.updateCampaign(campaign);
        return new TlinqApiResponse(updated);
    }

    private TlinqApiResponse doChangeCampaignStatus(String session, Integer campaignId, String newStatus)
            throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.changeCampaignStatus(campaignId, newStatus);
        return new TlinqApiResponse("Status changed successfully");
    }

    // Similar implementation methods for activities, media, etc.
    // ... (abbreviated for brevity)

    private TlinqApiResponse doListActivities(String session, Integer campaignId) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        SelectCriteriaList criteria = new SelectCriteriaList();
        if (campaignId != null) {
            criteria.addCriteria("campaignId", campaignId);
        }
        // Implementation using facade
        return new TlinqApiResponse("Activities listed");
    }

    private TlinqApiResponse doReadActivity(String session, Integer activityId) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.loadActivity(activityId);
        return new TlinqApiResponse(facade.getCurrentActivity());
    }

    private TlinqApiResponse doCreateActivity(String session, Map reqData) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        CActivity activity = new CActivity();
        activity.setCampaignId(ApiUtil.gmp(reqData, "campaignId", Integer.class, true));
        activity.setActivityName(ApiUtil.gmp(reqData, "activityName", String.class, true));
        activity.setActivityType(ApiUtil.gmp(reqData, "activityType", String.class, true));
        activity.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
        activity.setStoryText(ApiUtil.gmp(reqData, "storyText", String.class, false));
        CActivity created = facade.createActivity(activity);
        return new TlinqApiResponse(created);
    }

    private TlinqApiResponse doUpdateActivity(String session, Map reqData) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        // Implementation
        return new TlinqApiResponse("Activity updated");
    }

    private TlinqApiResponse doChangeActivityStatus(String session, Integer activityId, String newStatus)
            throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.changeActivityStatus(activityId, newStatus);
        return new TlinqApiResponse("Status changed successfully");
    }

    private TlinqApiResponse doSendForReview(String session, Integer activityId, Integer reviewerId)
            throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.sendForReview(activityId, reviewerId);
        return new TlinqApiResponse("Activity sent for review");
    }

    private TlinqApiResponse doApproveActivity(String session, Integer activityId) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.approveActivity(activityId);
        return new TlinqApiResponse("Activity approved");
    }

    private TlinqApiResponse doReturnActivity(String session, Integer activityId, String reason)
            throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.returnActivity(activityId, reason);
        return new TlinqApiResponse("Activity returned");
    }

    private TlinqApiResponse doAssignActivity(String session, Integer activityId, List<Integer> teamMemberIds)
            throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.assignActivity(activityId, teamMemberIds);
        return new TlinqApiResponse("Activity assigned");
    }

    private TlinqApiResponse doAddMedia(String session, Map reqData) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
        CActivityMedia media = new CActivityMedia();
        media.setMediaType(ApiUtil.gmp(reqData, "mediaType", String.class, true));
        media.setMediaUrl(ApiUtil.gmp(reqData, "mediaUrl", String.class, true));
        media.setFileName(ApiUtil.gmp(reqData, "fileName", String.class, false));
        media.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
        CActivityMedia created = facade.addMedia(activityId, media);
        return new TlinqApiResponse(created);
    }

    private TlinqApiResponse doRemoveMedia(String session, Integer mediaId) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        facade.removeMedia(mediaId);
        return new TlinqApiResponse("Media removed");
    }

    private TlinqApiResponse doListTeamMembers(String session) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        // Implementation
        return new TlinqApiResponse("Team members listed");
    }

    private TlinqApiResponse doListAudiences(String session) throws TlinqClientException {
        MarketingFacade facade = new MarketingFacade(session);
        // Implementation
        return new TlinqApiResponse("Audiences listed");
    }
}

3.2 API Role Configuration

Add to config/api-roles.properties:

# Marketing Planning API
marketing/campaign/list=agent,admin
marketing/campaign/read=agent,admin
marketing/campaign/create=agent,admin
marketing/campaign/update=agent,admin
marketing/campaign/changeStatus=agent,admin
marketing/activity/list=agent,admin
marketing/activity/read=agent,admin
marketing/activity/create=agent,admin
marketing/activity/update=agent,admin
marketing/activity/changeStatus=agent,admin
marketing/activity/sendForReview=agent,admin
marketing/activity/approve=admin,reviewer
marketing/activity/return=admin,reviewer
marketing/activity/assign=agent,admin
marketing/media/add=agent,admin
marketing/media/remove=agent,admin
marketing/teamMembers/list=agent,admin
marketing/audiences/list=agent,admin

4. Frontend Implementation

4.1 File Structure

tqweb-adm/
├── mktplan.html                    # Main marketing planning page
├── js/
│   └── modules/
│       └── mktplan.js              # Marketing planning ES6 module
└── css/
    └── mktplan.css                 # (optional) Additional styles

4.2 HTML Page: mktplan.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Marketing Planning - TQPro Admin</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
    <link href="css/tqapp.css" rel="stylesheet">
    <style>
        :root {
            --primary-color: #362c5d;
            --primary-dark: #2a2149;
            --secondary-color: #FFC166;
            --success-color: #3adb76;
            --warning-color: #ffae00;
            --alert-color: #cc4b37;
        }

        .sidebar-tree {
            height: calc(100vh - 200px);
            overflow-y: auto;
            border-right: 1px solid #dee2e6;
        }

        .tree-item {
            padding: 8px 12px;
            cursor: pointer;
            border-radius: 4px;
            margin: 2px 0;
        }

        .tree-item:hover {
            background-color: #f8f9fa;
        }

        .tree-item.selected {
            background-color: var(--primary-color);
            color: white;
        }

        .tree-item.campaign {
            font-weight: 600;
        }

        .tree-item.activity {
            padding-left: 28px;
            font-size: 0.9em;
        }

        .campaign-header {
            background: linear-gradient(to right, #fafafa, #ffffff);
            border-bottom: 1px solid #dee2e6;
            padding: 20px;
            min-height: 150px;
        }

        .activity-workspace {
            padding: 20px;
            height: calc(80vh - 200px);
            overflow-y: auto;
        }

        .status-badge {
            font-size: 0.75rem;
            padding: 4px 8px;
            border-radius: 12px;
        }

        .status-CREATED { background-color: #e9ecef; color: #495057; }
        .status-ASSIGNED { background-color: #cce5ff; color: #004085; }
        .status-WORK_IN_PROGRESS { background-color: #fff3cd; color: #856404; }
        .status-SENT_FOR_REVIEW { background-color: #d4edda; color: #155724; }
        .status-APPROVED { background-color: #28a745; color: white; }
        .status-CANCELLED { background-color: #dc3545; color: white; }
        .status-COMPLETED_SCHEDULED { background-color: #17a2b8; color: white; }
        .status-STARTED { background-color: #007bff; color: white; }
        .status-ONGOING { background-color: #6610f2; color: white; }
        .status-PAUSED { background-color: #fd7e14; color: white; }
        .status-ENDED { background-color: #6c757d; color: white; }

        .approved-indicator {
            background-color: #28a745;
            color: white;
            padding: 10px 20px;
            border-radius: 4px;
            font-weight: 600;
            text-align: center;
            margin-bottom: 15px;
        }

        .media-gallery {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
        }

        .media-item {
            width: 120px;
            height: 120px;
            border: 1px solid #dee2e6;
            border-radius: 8px;
            overflow: hidden;
            position: relative;
        }

        .media-item img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }

        .media-item .remove-btn {
            position: absolute;
            top: 4px;
            right: 4px;
            background: rgba(220, 53, 69, 0.9);
            color: white;
            border: none;
            border-radius: 50%;
            width: 24px;
            height: 24px;
            cursor: pointer;
        }

        .audience-tags {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }

        .audience-tag {
            background-color: #e9ecef;
            padding: 4px 12px;
            border-radius: 16px;
            font-size: 0.85rem;
        }

        .rich-text-editor {
            min-height: 200px;
            border: 1px solid #ced4da;
            border-radius: 4px;
        }

        .action-toolbar {
            background-color: #f8f9fa;
            border-bottom: 1px solid #dee2e6;
            padding: 10px 20px;
        }
    </style>
</head>
<body>
    <!-- Header loaded from template -->
    <header id="pgheader" data-load-template="header_bootstrap.html"></header>

    <div class="container-fluid mt-3">
        <div class="row">
            <!-- Left Sidebar: Campaign/Activity Tree -->
            <div class="col-md-3">
                <div class="card">
                    <div class="card-header d-flex justify-content-between align-items-center">
                        <span><i class="bi bi-diagram-3"></i> Campaigns</span>
                        <button class="btn btn-sm btn-primary" onclick="MktPlan.showCreateCampaignModal()">
                            <i class="bi bi-plus"></i>
                        </button>
                    </div>
                    <div class="card-body sidebar-tree" id="campaignTree">
                        <!-- Campaign tree will be populated here -->
                    </div>
                </div>
            </div>

            <!-- Main Content Area -->
            <div class="col-md-9">
                <!-- Action Toolbar -->
                <div class="action-toolbar mb-3 rounded">
                    <div class="btn-group me-2">
                        <button class="btn btn-outline-primary btn-sm" onclick="MktPlan.createActivity()"
                                id="btnAddActivity" disabled>
                            <i class="bi bi-plus-circle"></i> New Activity
                        </button>
                        <button class="btn btn-outline-secondary btn-sm" onclick="MktPlan.saveActivity()"
                                id="btnSaveActivity" disabled>
                            <i class="bi bi-save"></i> Save
                        </button>
                    </div>
                    <div class="btn-group me-2" id="activityActions" style="display: none;">
                        <button class="btn btn-outline-info btn-sm" onclick="MktPlan.sendForReview()"
                                id="btnSendReview">
                            <i class="bi bi-send"></i> Send for Review
                        </button>
                        <button class="btn btn-outline-success btn-sm" onclick="MktPlan.approveActivity()"
                                id="btnApprove" style="display: none;">
                            <i class="bi bi-check-circle"></i> Approve
                        </button>
                        <button class="btn btn-outline-warning btn-sm" onclick="MktPlan.returnActivity()"
                                id="btnReturn" style="display: none;">
                            <i class="bi bi-arrow-return-left"></i> Return
                        </button>
                    </div>
                    <div class="btn-group" id="campaignActions">
                        <button class="btn btn-outline-primary btn-sm dropdown-toggle"
                                data-bs-toggle="dropdown" id="btnCampaignStatus" disabled>
                            Campaign Status
                        </button>
                        <ul class="dropdown-menu">
                            <li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('WORK_IN_PROGRESS')">Start Work</a></li>
                            <li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('COMPLETED_SCHEDULED')">Mark Complete</a></li>
                            <li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('STARTED')">Start Campaign</a></li>
                            <li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('PAUSED')">Pause</a></li>
                            <li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('ENDED')">End Campaign</a></li>
                        </ul>
                    </div>
                </div>

                <!-- Campaign Header Section (20%) -->
                <div class="campaign-header rounded-top" id="campaignHeader">
                    <div class="row">
                        <div class="col-md-8">
                            <h4 id="campaignName">Select a Campaign</h4>
                            <p class="text-muted" id="campaignDesc">Choose a campaign from the left panel to view details</p>
                        </div>
                        <div class="col-md-4 text-end">
                            <span class="status-badge" id="campaignStatus"></span>
                            <div class="mt-2">
                                <small class="text-muted" id="campaignDates"></small>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- Activity Workspace (80%) -->
                <div class="card rounded-top-0">
                    <div class="card-body activity-workspace" id="activityWorkspace">
                        <div class="text-center text-muted py-5" id="noActivitySelected">
                            <i class="bi bi-cursor-fill" style="font-size: 3rem;"></i>
                            <p class="mt-3">Select an activity from the campaign tree or create a new one</p>
                        </div>

                        <!-- Activity Form (hidden initially) -->
                        <div id="activityForm" style="display: none;">
                            <!-- Approved Indicator -->
                            <div class="approved-indicator" id="approvedIndicator" style="display: none;">
                                <i class="bi bi-check-circle-fill"></i> This activity has been approved
                            </div>

                            <div class="row mb-3">
                                <div class="col-md-6">
                                    <label class="form-label">Activity Name</label>
                                    <input type="text" class="form-control" id="activityName">
                                </div>
                                <div class="col-md-3">
                                    <label class="form-label">Type</label>
                                    <select class="form-select" id="activityType">
                                        <option value="GOOGLE_AD">Google Ad</option>
                                        <option value="INSTAGRAM_REEL">Instagram Reel</option>
                                        <option value="INSTAGRAM_STORY">Instagram Story</option>
                                        <option value="INSTAGRAM_CAROUSEL">Instagram Carousel</option>
                                        <option value="FACEBOOK_REEL">Facebook Reel</option>
                                        <option value="FACEBOOK_STORY">Facebook Story</option>
                                        <option value="TIKTOK_STORY">TikTok Story</option>
                                        <option value="TWITTER_POST">Twitter/X Post</option>
                                        <option value="BLOG_POST">Blog Post</option>
                                    </select>
                                </div>
                                <div class="col-md-3">
                                    <label class="form-label">Status</label>
                                    <span class="status-badge d-block text-center mt-2" id="activityStatus">-</span>
                                </div>
                            </div>

                            <div class="row mb-3">
                                <div class="col-md-4">
                                    <label class="form-label">Start Date</label>
                                    <input type="date" class="form-control" id="activityStartDate">
                                </div>
                                <div class="col-md-4">
                                    <label class="form-label">Due Date</label>
                                    <input type="date" class="form-control" id="activityDueDate">
                                </div>
                                <div class="col-md-4">
                                    <label class="form-label">Assigned To</label>
                                    <select class="form-select" id="activityAssignees" multiple>
                                        <!-- Populated dynamically -->
                                    </select>
                                </div>
                            </div>

                            <div class="mb-3">
                                <label class="form-label">Description / Goals</label>
                                <textarea class="form-control" id="activityDescription" rows="3"></textarea>
                            </div>

                            <div class="mb-3">
                                <label class="form-label">Target Audience Segments</label>
                                <div class="audience-tags" id="audienceTags">
                                    <!-- Audience tags will be added here -->
                                </div>
                                <select class="form-select mt-2" id="audienceSelector" onchange="MktPlan.addAudience(this)">
                                    <option value="">+ Add audience segment</option>
                                    <!-- Populated dynamically -->
                                </select>
                            </div>

                            <div class="mb-3">
                                <label class="form-label">Story Text / Content</label>
                                <div id="storyTextEditor" class="rich-text-editor"></div>
                            </div>

                            <div class="mb-3">
                                <label class="form-label">Media Artifacts</label>
                                <div class="media-gallery" id="mediaGallery">
                                    <!-- Media items will be added here -->
                                </div>
                                <button class="btn btn-outline-secondary btn-sm mt-2" onclick="MktPlan.showAddMediaModal()">
                                    <i class="bi bi-plus-circle"></i> Add Media
                                </button>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- Alert Container -->
    <div id="alertContainer" style="position: fixed; top: 80px; right: 20px; z-index: 9999; max-width: 400px;"></div>

    <!-- Create Campaign Modal -->
    <div class="modal fade" id="createCampaignModal" tabindex="-1">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Create New Campaign</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-3">
                        <label class="form-label">Campaign Name</label>
                        <input type="text" class="form-control" id="newCampaignName">
                    </div>
                    <div class="mb-3">
                        <label class="form-label">Description</label>
                        <textarea class="form-control" id="newCampaignDesc" rows="2"></textarea>
                    </div>
                    <div class="mb-3">
                        <label class="form-label">Goals</label>
                        <textarea class="form-control" id="newCampaignGoals" rows="2"></textarea>
                    </div>
                    <div class="mb-3">
                        <label class="form-label">Targeted Approach</label>
                        <textarea class="form-control" id="newCampaignApproach" rows="2"></textarea>
                    </div>
                    <div class="row">
                        <div class="col-md-6">
                            <label class="form-label">Start Date</label>
                            <input type="date" class="form-control" id="newCampaignStartDate">
                        </div>
                        <div class="col-md-6">
                            <label class="form-label">End Date</label>
                            <input type="date" class="form-control" id="newCampaignEndDate">
                        </div>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
                    <button type="button" class="btn btn-primary" onclick="MktPlan.createCampaign()">Create Campaign</button>
                </div>
            </div>
        </div>
    </div>

    <!-- Add Media Modal -->
    <div class="modal fade" id="addMediaModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Add Media</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-3">
                        <label class="form-label">Media Type</label>
                        <select class="form-select" id="newMediaType">
                            <option value="IMAGE">Image</option>
                            <option value="VIDEO">Video</option>
                            <option value="GRAPHIC">Graphic</option>
                        </select>
                    </div>
                    <div class="mb-3">
                        <label class="form-label">Media URL</label>
                        <input type="url" class="form-control" id="newMediaUrl" placeholder="https://...">
                    </div>
                    <div class="mb-3">
                        <label class="form-label">File Name (optional)</label>
                        <input type="text" class="form-control" id="newMediaFileName">
                    </div>
                    <div class="mb-3">
                        <label class="form-label">Description (optional)</label>
                        <input type="text" class="form-control" id="newMediaDescription">
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
                    <button type="button" class="btn btn-primary" onclick="MktPlan.addMedia()">Add Media</button>
                </div>
            </div>
        </div>
    </div>

    <!-- Send for Review Modal -->
    <div class="modal fade" id="sendReviewModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Send for Review</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-3">
                        <label class="form-label">Select Reviewer</label>
                        <select class="form-select" id="reviewerSelect">
                            <!-- Populated with team members who have reviewer role -->
                        </select>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
                    <button type="button" class="btn btn-primary" onclick="MktPlan.confirmSendForReview()">Send</button>
                </div>
            </div>
        </div>
    </div>

    <!-- Scripts -->
    <script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
    <link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
    <script src="js/pageutil.js"></script>

    <script type="module">
        import { tlinq, getUserSession } from './js/modules/globals.js';
        window.tlinq = tlinq;
        window.getUserSession = getUserSession;
        window.globalsReady = true;
        window.dispatchEvent(new Event('globalsLoaded'));
    </script>

    <script type="module">
        import * as $page from './js/modules/mktplan.js';

        $(document).ready(function() {
            loadBootstrapTemplates();
            $page.initializePage();
            window.MktPlan = $page;
        });
    </script>
</body>
</html>

4.3 JavaScript Module: js/modules/mktplan.js

/**
 * Marketing Planning Module
 * ES6 module for the Marketing Planning functionality
 */

import { tlinq, getUserSession } from './globals.js';

// ==================== API Client ====================

const API = {
    async request(endpoint, data = {}) {
        const session = getUserSession();
        const requestData = { ...data, session };
        return tlinq(`marketing${endpoint}`, requestData);
    },

    // Campaign operations
    async listCampaigns() {
        return this.request('/campaign/list');
    },

    async readCampaign(campaignId) {
        return this.request('/campaign/read', { campaignId });
    },

    async createCampaign(campaign) {
        return this.request('/campaign/create', campaign);
    },

    async updateCampaign(campaign) {
        return this.request('/campaign/update', campaign);
    },

    async changeCampaignStatus(campaignId, status) {
        return this.request('/campaign/changeStatus', { campaignId, status });
    },

    // Activity operations
    async listActivities(campaignId) {
        return this.request('/activity/list', { campaignId });
    },

    async readActivity(activityId) {
        return this.request('/activity/read', { activityId });
    },

    async createActivity(activity) {
        return this.request('/activity/create', activity);
    },

    async updateActivity(activity) {
        return this.request('/activity/update', activity);
    },

    async changeActivityStatus(activityId, status) {
        return this.request('/activity/changeStatus', { activityId, status });
    },

    async sendForReview(activityId, reviewerId) {
        return this.request('/activity/sendForReview', { activityId, reviewerId });
    },

    async approveActivity(activityId) {
        return this.request('/activity/approve', { activityId });
    },

    async returnActivity(activityId, reason) {
        return this.request('/activity/return', { activityId, reason });
    },

    async assignActivity(activityId, teamMemberIds) {
        return this.request('/activity/assign', { activityId, teamMemberIds });
    },

    // Media operations
    async addMedia(activityId, media) {
        return this.request('/media/add', { activityId, ...media });
    },

    async removeMedia(mediaId) {
        return this.request('/media/remove', { mediaId });
    },

    // Reference data
    async listTeamMembers() {
        return this.request('/teamMembers/list');
    },

    async listAudiences() {
        return this.request('/audiences/list');
    }
};

// ==================== Utility Functions ====================

const Utils = {
    formatDate(dateString) {
        if (!dateString) return '';
        const date = new Date(dateString);
        return date.toLocaleDateString('en-US', {
            month: 'short',
            day: 'numeric',
            year: 'numeric'
        });
    },

    showAlert(type, message, duration = 5000) {
        const alertClass = {
            'success': 'alert-success',
            'error': 'alert-danger',
            'warning': 'alert-warning',
            'info': 'alert-info'
        }[type] || 'alert-info';

        const iconClass = {
            'success': 'bi-check-circle-fill',
            'error': 'bi-exclamation-circle-fill',
            'warning': 'bi-exclamation-triangle-fill',
            'info': 'bi-info-circle-fill'
        }[type] || 'bi-info-circle-fill';

        const alert = $(`
            <div class="alert ${alertClass} alert-dismissible fade show" role="alert">
                <i class="bi ${iconClass} me-2"></i>
                ${message}
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        `);

        $('#alertContainer').append(alert);

        if (duration > 0) {
            setTimeout(() => alert.alert('close'), duration);
        }
    },

    sanitizeHtml(html) {
        const temp = document.createElement('div');
        temp.textContent = html;
        return temp.innerHTML;
    },

    getStatusLabel(status) {
        const labels = {
            'CREATED': 'Created',
            'ASSIGNED': 'Assigned',
            'WORK_IN_PROGRESS': 'Work in Progress',
            'SENT_FOR_REVIEW': 'Sent for Review',
            'APPROVED': 'Approved',
            'CANCELLED': 'Cancelled',
            'COMPLETED_SCHEDULED': 'Completed & Scheduled',
            'STARTED': 'Started',
            'ONGOING': 'Ongoing',
            'PAUSED': 'Paused',
            'ENDED': 'Ended'
        };
        return labels[status] || status;
    }
};

// ==================== Page State ====================

let campaigns = [];
let currentCampaign = null;
let currentActivity = null;
let teamMembers = [];
let audiences = [];
let isDirty = false;
let quillEditor = null;

// ==================== Exported Functions ====================

export async function initializePage() {
    await loadReferenceData();
    await loadCampaigns();
    initializeQuillEditor();
    setupEventListeners();
}

function initializeQuillEditor() {
    quillEditor = new Quill('#storyTextEditor', {
        theme: 'snow',
        modules: {
            toolbar: [
                ['bold', 'italic', 'underline'],
                [{ 'list': 'ordered'}, { 'list': 'bullet' }],
                ['link'],
                ['clean']
            ]
        }
    });

    quillEditor.on('text-change', () => {
        isDirty = true;
    });
}

function setupEventListeners() {
    // Track form changes
    $('#activityForm input, #activityForm select, #activityForm textarea').on('change', () => {
        isDirty = true;
    });
}

async function loadReferenceData() {
    try {
        const [membersResult, audiencesResult] = await Promise.all([
            API.listTeamMembers(),
            API.listAudiences()
        ]);

        teamMembers = membersResult.data || [];
        audiences = audiencesResult.data || [];

        populateTeamMemberSelects();
        populateAudienceSelect();
    } catch (error) {
        Utils.showAlert('error', 'Failed to load reference data: ' + error.message);
    }
}

function populateTeamMemberSelects() {
    const assigneeSelect = $('#activityAssignees');
    const reviewerSelect = $('#reviewerSelect');

    assigneeSelect.empty();
    reviewerSelect.empty();

    teamMembers.forEach(member => {
        assigneeSelect.append(`<option value="${member.teamMemberId}">${Utils.sanitizeHtml(member.displayName)}</option>`);
        if (member.role === 'reviewer' || member.role === 'admin') {
            reviewerSelect.append(`<option value="${member.teamMemberId}">${Utils.sanitizeHtml(member.displayName)}</option>`);
        }
    });
}

function populateAudienceSelect() {
    const select = $('#audienceSelector');
    select.empty();
    select.append('<option value="">+ Add audience segment</option>');
    audiences.forEach(aud => {
        select.append(`<option value="${aud.audienceId}">${Utils.sanitizeHtml(aud.segmentName)}</option>`);
    });
}

async function loadCampaigns() {
    try {
        const result = await API.listCampaigns();
        campaigns = result.data || [];
        renderCampaignTree();
    } catch (error) {
        Utils.showAlert('error', 'Failed to load campaigns: ' + error.message);
    }
}

function renderCampaignTree() {
    const tree = $('#campaignTree');
    tree.empty();

    campaigns.forEach(campaign => {
        const campaignItem = $(`
            <div class="tree-item campaign" data-campaign-id="${campaign.campaignId}">
                <i class="bi bi-folder2"></i>
                ${Utils.sanitizeHtml(campaign.campaignName)}
                <span class="status-badge status-${campaign.status} ms-2">${Utils.getStatusLabel(campaign.status)}</span>
            </div>
        `);

        campaignItem.on('click', () => selectCampaign(campaign.campaignId));
        tree.append(campaignItem);

        // Add activities under campaign
        if (campaign.activities && campaign.activities.length > 0) {
            campaign.activities.forEach(activity => {
                const activityItem = $(`
                    <div class="tree-item activity" data-activity-id="${activity.activityId}" data-campaign-id="${campaign.campaignId}">
                        <i class="bi bi-file-earmark"></i>
                        ${Utils.sanitizeHtml(activity.activityName)}
                        <span class="status-badge status-${activity.status}">${Utils.getStatusLabel(activity.status)}</span>
                    </div>
                `);

                activityItem.on('click', (e) => {
                    e.stopPropagation();
                    selectActivity(activity.activityId, campaign.campaignId);
                });
                tree.append(activityItem);
            });
        }
    });
}

async function selectCampaign(campaignId) {
    if (isDirty) {
        if (!confirm('You have unsaved changes. Discard them?')) {
            return;
        }
    }

    try {
        const result = await API.readCampaign(campaignId);
        currentCampaign = result.entity || result.data;

        // Update UI
        $('.tree-item').removeClass('selected');
        $(`.tree-item[data-campaign-id="${campaignId}"]`).addClass('selected');

        renderCampaignHeader();

        // Enable campaign actions
        $('#btnAddActivity').prop('disabled', false);
        $('#btnCampaignStatus').prop('disabled', false);

        // Select first activity if exists
        if (currentCampaign.activities && currentCampaign.activities.length > 0) {
            selectActivity(currentCampaign.activities[0].activityId, campaignId);
        } else {
            currentActivity = null;
            $('#noActivitySelected').show();
            $('#activityForm').hide();
        }

        isDirty = false;
    } catch (error) {
        Utils.showAlert('error', 'Failed to load campaign: ' + error.message);
    }
}

function renderCampaignHeader() {
    if (!currentCampaign) return;

    $('#campaignName').text(currentCampaign.campaignName);
    $('#campaignDesc').text(currentCampaign.description || 'No description');
    $('#campaignStatus').text(Utils.getStatusLabel(currentCampaign.status))
        .attr('class', `status-badge status-${currentCampaign.status}`);

    let dateText = '';
    if (currentCampaign.startDate) {
        dateText += `Start: ${Utils.formatDate(currentCampaign.startDate)}`;
    }
    if (currentCampaign.endDate) {
        dateText += ` | End: ${Utils.formatDate(currentCampaign.endDate)}`;
    }
    $('#campaignDates').text(dateText);
}

async function selectActivity(activityId, campaignId) {
    if (isDirty) {
        if (!confirm('You have unsaved changes. Discard them?')) {
            return;
        }
    }

    try {
        const result = await API.readActivity(activityId);
        currentActivity = result.entity || result.data;

        // Update selection in tree
        $('.tree-item').removeClass('selected');
        $(`.tree-item[data-activity-id="${activityId}"]`).addClass('selected');

        // Make sure campaign is also loaded
        if (!currentCampaign || currentCampaign.campaignId !== campaignId) {
            const campaignResult = await API.readCampaign(campaignId);
            currentCampaign = campaignResult.entity || campaignResult.data;
            renderCampaignHeader();
        }

        renderActivityForm();
        isDirty = false;
    } catch (error) {
        Utils.showAlert('error', 'Failed to load activity: ' + error.message);
    }
}

function renderActivityForm() {
    if (!currentActivity) return;

    $('#noActivitySelected').hide();
    $('#activityForm').show();

    // Populate form fields
    $('#activityName').val(currentActivity.activityName);
    $('#activityType').val(currentActivity.activityType);
    $('#activityStartDate').val(currentActivity.startDate ? currentActivity.startDate.substring(0, 10) : '');
    $('#activityDueDate').val(currentActivity.dueDate ? currentActivity.dueDate.substring(0, 10) : '');
    $('#activityDescription').val(currentActivity.description || '');

    // Set status badge
    $('#activityStatus')
        .text(Utils.getStatusLabel(currentActivity.status))
        .attr('class', `status-badge status-${currentActivity.status}`);

    // Set story text in Quill editor
    if (currentActivity.storyText) {
        quillEditor.root.innerHTML = currentActivity.storyText;
    } else {
        quillEditor.root.innerHTML = '';
    }

    // Render assignees, media, audiences
    renderAssignees();
    renderMediaGallery();
    renderAudienceTags();

    // Update button visibility based on status
    updateActionButtons();

    // Show approved indicator if approved
    if (currentActivity.status === 'APPROVED') {
        $('#approvedIndicator').show();
        disableActivityForm();
    } else {
        $('#approvedIndicator').hide();
        enableActivityForm();
    }
}

function updateActionButtons() {
    const status = currentActivity?.status;

    $('#btnSaveActivity').prop('disabled', !currentActivity || status === 'APPROVED');
    $('#activityActions').toggle(!!currentActivity);

    // Show/hide specific buttons based on status
    $('#btnSendReview').toggle(status === 'WORK_IN_PROGRESS');
    $('#btnApprove').toggle(status === 'SENT_FOR_REVIEW');
    $('#btnReturn').toggle(status === 'SENT_FOR_REVIEW');
}

function disableActivityForm() {
    $('#activityForm input, #activityForm select, #activityForm textarea').prop('disabled', true);
    quillEditor.disable();
}

function enableActivityForm() {
    $('#activityForm input, #activityForm select, #activityForm textarea').prop('disabled', false);
    quillEditor.enable();
}

function renderAssignees() {
    if (currentActivity && currentActivity.assignees) {
        const ids = currentActivity.assignees.map(a => a.teamMemberId);
        $('#activityAssignees').val(ids);
    }
}

function renderMediaGallery() {
    const gallery = $('#mediaGallery');
    gallery.empty();

    if (currentActivity && currentActivity.mediaItems) {
        currentActivity.mediaItems.forEach(media => {
            const item = $(`
                <div class="media-item">
                    ${media.mediaType === 'VIDEO'
                        ? `<i class="bi bi-play-circle" style="font-size: 2rem; display: flex; align-items: center; justify-content: center; height: 100%;"></i>`
                        : `<img src="${Utils.sanitizeHtml(media.mediaUrl)}" alt="${Utils.sanitizeHtml(media.fileName || '')}">`
                    }
                    ${currentActivity.status !== 'APPROVED'
                        ? `<button class="remove-btn" onclick="MktPlan.removeMedia(${media.mediaId})"><i class="bi bi-x"></i></button>`
                        : ''
                    }
                </div>
            `);
            gallery.append(item);
        });
    }
}

function renderAudienceTags() {
    const container = $('#audienceTags');
    container.empty();

    if (currentActivity && currentActivity.audienceSegments) {
        currentActivity.audienceSegments.forEach(aud => {
            const tag = $(`
                <span class="audience-tag">
                    ${Utils.sanitizeHtml(aud.segmentName)}
                    ${currentActivity.status !== 'APPROVED'
                        ? `<i class="bi bi-x ms-1" style="cursor: pointer;" onclick="MktPlan.removeAudience(${aud.audienceId})"></i>`
                        : ''
                    }
                </span>
            `);
            container.append(tag);
        });
    }
}

// ==================== Campaign Operations ====================

export function showCreateCampaignModal() {
    // Clear form
    $('#newCampaignName').val('');
    $('#newCampaignDesc').val('');
    $('#newCampaignGoals').val('');
    $('#newCampaignApproach').val('');
    $('#newCampaignStartDate').val('');
    $('#newCampaignEndDate').val('');

    const modal = new bootstrap.Modal(document.getElementById('createCampaignModal'));
    modal.show();
}

export async function createCampaign() {
    const campaignData = {
        campaignName: $('#newCampaignName').val(),
        description: $('#newCampaignDesc').val(),
        goals: $('#newCampaignGoals').val(),
        targetedApproach: $('#newCampaignApproach').val(),
        startDate: $('#newCampaignStartDate').val() || null,
        endDate: $('#newCampaignEndDate').val() || null
    };

    if (!campaignData.campaignName) {
        Utils.showAlert('warning', 'Campaign name is required');
        return;
    }

    try {
        await API.createCampaign(campaignData);
        Utils.showAlert('success', 'Campaign created successfully');

        // Hide modal and reload campaigns
        bootstrap.Modal.getInstance(document.getElementById('createCampaignModal')).hide();
        await loadCampaigns();
    } catch (error) {
        Utils.showAlert('error', 'Failed to create campaign: ' + error.message);
    }
}

export async function changeCampaignStatus(newStatus) {
    if (!currentCampaign) return;

    try {
        await API.changeCampaignStatus(currentCampaign.campaignId, newStatus);
        Utils.showAlert('success', 'Campaign status updated');
        await selectCampaign(currentCampaign.campaignId);
        await loadCampaigns();
    } catch (error) {
        Utils.showAlert('error', 'Failed to change status: ' + error.message);
    }
}

// ==================== Activity Operations ====================

export async function createActivity() {
    if (!currentCampaign) {
        Utils.showAlert('warning', 'Please select a campaign first');
        return;
    }

    const activityName = prompt('Enter activity name:');
    if (!activityName) return;

    try {
        const activity = {
            campaignId: currentCampaign.campaignId,
            activityName: activityName,
            activityType: 'INSTAGRAM_REEL' // Default type
        };

        const result = await API.createActivity(activity);
        Utils.showAlert('success', 'Activity created');

        await loadCampaigns();
        await selectActivity(result.entity.activityId, currentCampaign.campaignId);
    } catch (error) {
        Utils.showAlert('error', 'Failed to create activity: ' + error.message);
    }
}

export async function saveActivity() {
    if (!currentActivity || currentActivity.status === 'APPROVED') return;

    try {
        const activityData = {
            activityId: currentActivity.activityId,
            activityName: $('#activityName').val(),
            activityType: $('#activityType').val(),
            description: $('#activityDescription').val(),
            storyText: quillEditor.root.innerHTML,
            startDate: $('#activityStartDate').val() || null,
            dueDate: $('#activityDueDate').val() || null
        };

        await API.updateActivity(activityData);

        // Handle assignees
        const selectedAssignees = $('#activityAssignees').val() || [];
        await API.assignActivity(currentActivity.activityId, selectedAssignees.map(Number));

        Utils.showAlert('success', 'Activity saved');
        isDirty = false;

        await loadCampaigns();
    } catch (error) {
        Utils.showAlert('error', 'Failed to save activity: ' + error.message);
    }
}

export function sendForReview() {
    if (!currentActivity) return;
    const modal = new bootstrap.Modal(document.getElementById('sendReviewModal'));
    modal.show();
}

export async function confirmSendForReview() {
    const reviewerId = $('#reviewerSelect').val();
    if (!reviewerId) {
        Utils.showAlert('warning', 'Please select a reviewer');
        return;
    }

    try {
        await API.sendForReview(currentActivity.activityId, parseInt(reviewerId));
        Utils.showAlert('success', 'Activity sent for review');

        bootstrap.Modal.getInstance(document.getElementById('sendReviewModal')).hide();
        await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
        await loadCampaigns();
    } catch (error) {
        Utils.showAlert('error', 'Failed to send for review: ' + error.message);
    }
}

export async function approveActivity() {
    if (!currentActivity) return;

    try {
        await API.approveActivity(currentActivity.activityId);
        Utils.showAlert('success', 'Activity approved');
        await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
        await loadCampaigns();
    } catch (error) {
        Utils.showAlert('error', 'Failed to approve activity: ' + error.message);
    }
}

export async function returnActivity() {
    if (!currentActivity) return;

    const reason = prompt('Reason for returning (optional):');

    try {
        await API.returnActivity(currentActivity.activityId, reason);
        Utils.showAlert('info', 'Activity returned for revision');
        await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
        await loadCampaigns();
    } catch (error) {
        Utils.showAlert('error', 'Failed to return activity: ' + error.message);
    }
}

// ==================== Media Operations ====================

export function showAddMediaModal() {
    if (!currentActivity || currentActivity.status === 'APPROVED') return;

    $('#newMediaType').val('IMAGE');
    $('#newMediaUrl').val('');
    $('#newMediaFileName').val('');
    $('#newMediaDescription').val('');

    const modal = new bootstrap.Modal(document.getElementById('addMediaModal'));
    modal.show();
}

export async function addMedia() {
    const mediaUrl = $('#newMediaUrl').val();
    if (!mediaUrl) {
        Utils.showAlert('warning', 'Media URL is required');
        return;
    }

    try {
        const media = {
            mediaType: $('#newMediaType').val(),
            mediaUrl: mediaUrl,
            fileName: $('#newMediaFileName').val(),
            description: $('#newMediaDescription').val()
        };

        await API.addMedia(currentActivity.activityId, media);
        Utils.showAlert('success', 'Media added');

        bootstrap.Modal.getInstance(document.getElementById('addMediaModal')).hide();
        await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
    } catch (error) {
        Utils.showAlert('error', 'Failed to add media: ' + error.message);
    }
}

export async function removeMedia(mediaId) {
    if (!confirm('Remove this media item?')) return;

    try {
        await API.removeMedia(mediaId);
        Utils.showAlert('success', 'Media removed');
        await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
    } catch (error) {
        Utils.showAlert('error', 'Failed to remove media: ' + error.message);
    }
}

// ==================== Audience Operations ====================

export function addAudience(select) {
    const audienceId = select.value;
    if (!audienceId || !currentActivity || currentActivity.status === 'APPROVED') {
        select.value = '';
        return;
    }

    // Check if already added
    if (currentActivity.audienceSegments?.some(a => a.audienceId === parseInt(audienceId))) {
        Utils.showAlert('info', 'Audience segment already added');
        select.value = '';
        return;
    }

    // Add to local state and re-render
    const audience = audiences.find(a => a.audienceId === parseInt(audienceId));
    if (audience) {
        if (!currentActivity.audienceSegments) {
            currentActivity.audienceSegments = [];
        }
        currentActivity.audienceSegments.push(audience);
        renderAudienceTags();
        isDirty = true;
    }

    select.value = '';
}

export function removeAudience(audienceId) {
    if (!currentActivity || currentActivity.status === 'APPROVED') return;

    currentActivity.audienceSegments = currentActivity.audienceSegments.filter(
        a => a.audienceId !== audienceId
    );
    renderAudienceTags();
    isDirty = true;
}

5. WhatsApp Notification Service

5.1 Notification Service Interface

package com.perun.tlinq.service.notification;

public interface NotificationService {
    void sendWhatsAppMessage(String phoneNumber, String message);
    void sendStatusChangeNotification(String entityType, Integer entityId,
        String oldStatus, String newStatus, Integer userId);
    void sendReviewRequestNotification(Integer activityId, Integer reviewerId);
    void sendApprovalNotification(Integer activityId, boolean approved, Integer userId);
}

5.2 Implementation Options

The WhatsApp notification service can be implemented using:

  1. WhatsApp Business API (recommended for production)
  2. Twilio WhatsApp API
  3. MessageBird WhatsApp API

Configuration should be added to tlinqapi.properties:

# WhatsApp Notification Configuration
whatsapp.enabled=true
whatsapp.provider=twilio
whatsapp.account.sid=your_account_sid
whatsapp.auth.token=your_auth_token
whatsapp.from.number=+14155238886

6. Implementation Phases

Phase 1: Database & Backend Core

  1. Create database tables and sequences
  2. Implement database entities (8 entities)
  3. Implement canonical entities (8 entities)
  4. Create entity XML configuration file
  5. Update tourlinq-config.xml with XInclude

Phase 2: Business Logic

  1. Implement MarketingFacade with:
  2. Campaign CRUD operations
  3. Activity CRUD operations
  4. Status transition logic
  5. Assignment management
  6. Media management
  7. Change logging

Phase 3: REST API

  1. Implement MarketingApi endpoints
  2. Configure API role permissions
  3. Test API endpoints

Phase 4: Frontend

  1. Create HTML page structure
  2. Implement JavaScript module
  3. Build campaign tree navigation
  4. Implement activity workspace
  5. Add rich text editor integration
  6. Implement media gallery

Phase 5: Notifications

  1. Implement WhatsApp notification service
  2. Integrate with status change events
  3. Configure notification templates

Phase 6: Testing & Documentation

  1. Unit tests for facade methods
  2. Integration tests for API endpoints
  3. End-to-end testing
  4. User documentation

7. Status Flow Diagrams

7.1 Campaign Status Flow

                              ┌─────────────┐
                              │   CREATED   │
                              └──────┬──────┘
                           ┌─────────────────────┐
                     ┌────►│  WORK_IN_PROGRESS   │◄────┐
                     │     └──────────┬──────────┘     │
                     │                │                │
                     │  (activity     │ (all activities│
                     │   revoked)     │  approved)     │
                     │                ▼                │
                     │     ┌─────────────────────┐     │
                     └─────│ COMPLETED_SCHEDULED │─────┘
                           └──────────┬──────────┘
                              ┌─────────────┐
                              │   STARTED   │
                              └──────┬──────┘
                           ┌─────────┼─────────┐
                           ▼         │         ▼
                    ┌──────────┐     │   ┌──────────┐
                    │  PAUSED  │◄────┼───│ ONGOING  │
                    └────┬─────┘     │   └────┬─────┘
                         │           │        │
                         └───────────┼────────┘
                              ┌──────────┐
                              │  ENDED   │
                              └──────────┘

7.2 Activity Status Flow

┌─────────────┐     ┌──────────┐     ┌─────────────────────┐
│   CREATED   │────►│ ASSIGNED │────►│  WORK_IN_PROGRESS   │
└─────────────┘     └────┬─────┘     └──────────┬──────────┘
                         │                      │
                         │                      │
                         ▼                      ▼
                  ┌────────────┐       ┌─────────────────┐
                  │ CANCELLED  │       │ SENT_FOR_REVIEW │
                  └────────────┘       └────────┬────────┘
                                     ┌──────────┴──────────┐
                                     │                     │
                                     ▼                     ▼
                              ┌──────────┐    ┌─────────────────────┐
                              │ APPROVED │◄───│  WORK_IN_PROGRESS   │
                              └────┬─────┘    │     (returned)      │
                                   │          └─────────────────────┘
                            (Admin revoke)
                       ┌─────────────────────┐
                       │  WORK_IN_PROGRESS   │
                       └─────────────────────┘

8. Security Considerations

  1. Role-Based Access Control: API endpoints protected by role-based permissions
  2. Data Validation: All input validated at API and facade layers
  3. Audit Logging: All changes logged in mkt_changelog table
  4. Session Management: Session token required for all API calls
  5. XSS Prevention: HTML sanitization in frontend
  6. SQL Injection Prevention: Parameterized queries via JPA

9. File Checklist

Backend Files to Create:

File Location
MktTeammemberEntity.java tqapp/.../client/nts/db/marketing/
MktAudienceEntity.java tqapp/.../client/nts/db/marketing/
MktCampaignEntity.java tqapp/.../client/nts/db/marketing/
MktActivityEntity.java tqapp/.../client/nts/db/marketing/
MktActivityassignmentEntity.java tqapp/.../client/nts/db/marketing/
MktActivitymediaEntity.java tqapp/.../client/nts/db/marketing/
MktActivityaudienceEntity.java tqapp/.../client/nts/db/marketing/
MktChangelogEntity.java tqapp/.../client/nts/db/marketing/
CTeamMember.java tqapp/.../entity/marketing/
CAudienceSegment.java tqapp/.../entity/marketing/
CCampaign.java tqapp/.../entity/marketing/
CActivity.java tqapp/.../entity/marketing/
CActivityAssignment.java tqapp/.../entity/marketing/
CActivityMedia.java tqapp/.../entity/marketing/
CActivityAudience.java tqapp/.../entity/marketing/
CChangeLog.java tqapp/.../entity/marketing/
MarketingFacade.java tqapp/.../entity/marketing/
MarketingApi.java tqapi/.../api/
marketing-entities.xml config/entities/

Frontend Files to Create:

File Location
mktplan.html tqweb-adm/
mktplan.js tqweb-adm/js/modules/

Files to Update:

File Change
tourlinq-config.xml Add marketing-entities.xml include
api-roles.properties Add marketing API permissions
header_bootstrap.html Add Marketing Planning menu item

10. Testing Strategy

Unit Tests

  • Test status transition validation logic
  • Test facade methods for CRUD operations
  • Test field mapping transformations

Integration Tests

  • API endpoint tests with mock data
  • Database operations with test schema
  • WhatsApp notification mock tests

End-to-End Tests

  • Campaign creation workflow
  • Activity management workflow
  • Review and approval workflow
  • Status change notifications

This implementation plan provides a comprehensive blueprint for building the Marketing Planning module following TQPro architectural patterns and best practices.


Broadcast Extension (TQ-109) — IMPLEMENTED

Added WhatsApp and Email broadcast capability as an extension to the Marketing module. Implementation completed 2026-04-03.

Components Added

  • DB Migration: 0058-marketing-broadcast.sql — 3 new tables (mkt_broadcast, mkt_broadcast_recipient, mkt_broadcast_optout), ALTER mkt_audience
  • DB Entities: MktBroadcastEntity, MktBroadcastRecipientEntity, MktBroadcastOptoutEntity
  • Canonical Entities: CBroadcast, CBroadcastRecipient, CBroadcastOptout
  • BroadcastService: Async send orchestrator with Hazelcast distributed locks and rate limiting
  • MarketingFacade: Extended with broadcast CRUD, recipient resolution, opt-out management
  • MarketingApi: 12 new endpoints (broadcast CRUD, Twilio webhooks, email unsubscribe)
  • Frontend: Broadcast panel in mktplan.html/js with conditional rendering per activity type
  • Config: Broadcast properties in tourlinq.properties, role permissions in api-roles.properties
  • Email Templates: File-based HTML templates in config/email-templates/marketing/

See doc/features/marketing/broadcast.md for full feature documentation.