From 8e4bc70d03be9d9d4810e0434d784677f10efdef Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 11 Apr 2026 08:13:09 +0000 Subject: [PATCH] Auto commit: 2026-04-11T08:13:09.677Z --- ...260409100000-create-marketing-campaigns.js | 189 ++ ...260409101000-create-marketing-schedules.js | 145 ++ ...260409102000-create-marketing-referrals.js | 148 ++ ...260409103000-create-marketing-templates.js | 120 + backend/src/db/models/marketing_campaigns.js | 119 + backend/src/db/models/marketing_referrals.js | 68 + backend/src/db/models/marketing_schedules.js | 65 + backend/src/db/models/marketing_templates.js | 54 + backend/src/index.js | 2 + backend/src/routes/school_marketing.js | 609 +++++ frontend/src/menuAside.ts | 4 +- frontend/src/pages/school-marketing.tsx | 2197 ++++++++++++++--- 12 files changed, 3319 insertions(+), 401 deletions(-) create mode 100644 backend/src/db/migrations/20260409100000-create-marketing-campaigns.js create mode 100644 backend/src/db/migrations/20260409101000-create-marketing-schedules.js create mode 100644 backend/src/db/migrations/20260409102000-create-marketing-referrals.js create mode 100644 backend/src/db/migrations/20260409103000-create-marketing-templates.js create mode 100644 backend/src/db/models/marketing_campaigns.js create mode 100644 backend/src/db/models/marketing_referrals.js create mode 100644 backend/src/db/models/marketing_schedules.js create mode 100644 backend/src/db/models/marketing_templates.js create mode 100644 backend/src/routes/school_marketing.js diff --git a/backend/src/db/migrations/20260409100000-create-marketing-campaigns.js b/backend/src/db/migrations/20260409100000-create-marketing-campaigns.js new file mode 100644 index 0000000..995daeb --- /dev/null +++ b/backend/src/db/migrations/20260409100000-create-marketing-campaigns.js @@ -0,0 +1,189 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_campaigns') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_marketing_campaigns_status";', + { transaction }, + ); + + await queryInterface.createTable( + 'marketing_campaigns', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + campaignName: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + campaignType: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + defaultValue: 'تسجيل الطلاب', + }, + objective: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + audience: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + gradeFocus: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + platform: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + tone: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + offer: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + callToAction: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + publishingDate: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + notes: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + generatedContent: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + weeklyPlan: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + designerNotes: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + videoScript: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + messageTemplate: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + assetNames: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + status: { + type: Sequelize.DataTypes.ENUM, + values: ['draft', 'ready', 'scheduled', 'published', 'archived'], + allowNull: false, + defaultValue: 'draft', + }, + isAutoGenerated: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + lastGeneratedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_campaigns') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('marketing_campaigns', { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_marketing_campaigns_status";', + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260409101000-create-marketing-schedules.js b/backend/src/db/migrations/20260409101000-create-marketing-schedules.js new file mode 100644 index 0000000..57e8331 --- /dev/null +++ b/backend/src/db/migrations/20260409101000-create-marketing-schedules.js @@ -0,0 +1,145 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_schedules') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_marketing_schedules_status";', + { transaction }, + ); + + await queryInterface.createTable( + 'marketing_schedules', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + title: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + channel: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + publishAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + type: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + status: { + type: Sequelize.DataTypes.ENUM, + values: ['draft', 'scheduled', 'published', 'cancelled'], + allowNull: false, + defaultValue: 'scheduled', + }, + notes: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + payload: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + campaignId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'marketing_campaigns', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_schedules') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('marketing_schedules', { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_marketing_schedules_status";', + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260409102000-create-marketing-referrals.js b/backend/src/db/migrations/20260409102000-create-marketing-referrals.js new file mode 100644 index 0000000..53ea98e --- /dev/null +++ b/backend/src/db/migrations/20260409102000-create-marketing-referrals.js @@ -0,0 +1,148 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_referrals') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_marketing_referrals_status";', + { transaction }, + ); + + await queryInterface.createTable( + 'marketing_referrals', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + referrerName: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + referrerPhone: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + studentName: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + studentGrade: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + discountAmount: { + type: Sequelize.DataTypes.DECIMAL(10, 2), + allowNull: true, + }, + rewardNotes: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + status: { + type: Sequelize.DataTypes.ENUM, + values: ['new', 'qualified', 'rewarded', 'cancelled'], + allowNull: false, + defaultValue: 'new', + }, + notes: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + inquiryId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'student_inquiries', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_referrals') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('marketing_referrals', { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_marketing_referrals_status";', + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260409103000-create-marketing-templates.js b/backend/src/db/migrations/20260409103000-create-marketing-templates.js new file mode 100644 index 0000000..233a759 --- /dev/null +++ b/backend/src/db/migrations/20260409103000-create-marketing-templates.js @@ -0,0 +1,120 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_templates') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'marketing_templates', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + category: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + channel: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + content: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + previewText: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + isDefault: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.marketing_templates') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('marketing_templates', { transaction }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/marketing_campaigns.js b/backend/src/db/models/marketing_campaigns.js new file mode 100644 index 0000000..e8ff89b --- /dev/null +++ b/backend/src/db/models/marketing_campaigns.js @@ -0,0 +1,119 @@ +module.exports = (sequelize, DataTypes) => { + const marketing_campaigns = sequelize.define( + 'marketing_campaigns', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + campaignName: { + type: DataTypes.TEXT, + allowNull: false, + }, + campaignType: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'تسجيل الطلاب', + }, + objective: { + type: DataTypes.TEXT, + allowNull: true, + }, + audience: { + type: DataTypes.TEXT, + allowNull: true, + }, + gradeFocus: { + type: DataTypes.TEXT, + allowNull: true, + }, + platform: { + type: DataTypes.TEXT, + allowNull: true, + }, + tone: { + type: DataTypes.TEXT, + allowNull: true, + }, + offer: { + type: DataTypes.TEXT, + allowNull: true, + }, + callToAction: { + type: DataTypes.TEXT, + allowNull: true, + }, + publishingDate: { + type: DataTypes.DATE, + allowNull: true, + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + generatedContent: { + type: DataTypes.TEXT, + allowNull: true, + }, + weeklyPlan: { + type: DataTypes.TEXT, + allowNull: true, + }, + designerNotes: { + type: DataTypes.TEXT, + allowNull: true, + }, + videoScript: { + type: DataTypes.TEXT, + allowNull: true, + }, + messageTemplate: { + type: DataTypes.TEXT, + allowNull: true, + }, + assetNames: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + status: { + type: DataTypes.ENUM, + values: ['draft', 'ready', 'scheduled', 'published', 'archived'], + allowNull: false, + defaultValue: 'draft', + }, + isAutoGenerated: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + lastGeneratedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + marketing_campaigns.associate = (db) => { + db.marketing_campaigns.hasMany(db.marketing_schedules, { + as: 'schedules', + foreignKey: 'campaignId', + }); + + db.marketing_campaigns.belongsTo(db.users, { + as: 'createdBy', + }); + + db.marketing_campaigns.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return marketing_campaigns; +}; diff --git a/backend/src/db/models/marketing_referrals.js b/backend/src/db/models/marketing_referrals.js new file mode 100644 index 0000000..a1cfd39 --- /dev/null +++ b/backend/src/db/models/marketing_referrals.js @@ -0,0 +1,68 @@ +module.exports = (sequelize, DataTypes) => { + const marketing_referrals = sequelize.define( + 'marketing_referrals', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + referrerName: { + type: DataTypes.TEXT, + allowNull: false, + }, + referrerPhone: { + type: DataTypes.TEXT, + allowNull: true, + }, + studentName: { + type: DataTypes.TEXT, + allowNull: false, + }, + studentGrade: { + type: DataTypes.TEXT, + allowNull: true, + }, + discountAmount: { + type: DataTypes.DECIMAL(10, 2), + allowNull: true, + }, + rewardNotes: { + type: DataTypes.TEXT, + allowNull: true, + }, + status: { + type: DataTypes.ENUM, + values: ['new', 'qualified', 'rewarded', 'cancelled'], + allowNull: false, + defaultValue: 'new', + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + marketing_referrals.associate = (db) => { + db.marketing_referrals.belongsTo(db.student_inquiries, { + as: 'inquiry', + foreignKey: 'inquiryId', + }); + + db.marketing_referrals.belongsTo(db.users, { + as: 'createdBy', + }); + + db.marketing_referrals.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return marketing_referrals; +}; diff --git a/backend/src/db/models/marketing_schedules.js b/backend/src/db/models/marketing_schedules.js new file mode 100644 index 0000000..8fcbcaa --- /dev/null +++ b/backend/src/db/models/marketing_schedules.js @@ -0,0 +1,65 @@ +module.exports = (sequelize, DataTypes) => { + const marketing_schedules = sequelize.define( + 'marketing_schedules', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + title: { + type: DataTypes.TEXT, + allowNull: false, + }, + channel: { + type: DataTypes.TEXT, + allowNull: true, + }, + publishAt: { + type: DataTypes.DATE, + allowNull: false, + }, + type: { + type: DataTypes.TEXT, + allowNull: true, + }, + status: { + type: DataTypes.ENUM, + values: ['draft', 'scheduled', 'published', 'cancelled'], + allowNull: false, + defaultValue: 'scheduled', + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + payload: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + marketing_schedules.associate = (db) => { + db.marketing_schedules.belongsTo(db.marketing_campaigns, { + as: 'campaign', + foreignKey: 'campaignId', + }); + + db.marketing_schedules.belongsTo(db.users, { + as: 'createdBy', + }); + + db.marketing_schedules.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return marketing_schedules; +}; diff --git a/backend/src/db/models/marketing_templates.js b/backend/src/db/models/marketing_templates.js new file mode 100644 index 0000000..26ffe08 --- /dev/null +++ b/backend/src/db/models/marketing_templates.js @@ -0,0 +1,54 @@ +module.exports = (sequelize, DataTypes) => { + const marketing_templates = sequelize.define( + 'marketing_templates', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + }, + category: { + type: DataTypes.TEXT, + allowNull: true, + }, + channel: { + type: DataTypes.TEXT, + allowNull: true, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + previewText: { + type: DataTypes.TEXT, + allowNull: true, + }, + isDefault: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + marketing_templates.associate = (db) => { + db.marketing_templates.belongsTo(db.users, { + as: 'createdBy', + }); + + db.marketing_templates.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return marketing_templates; +}; diff --git a/backend/src/index.js b/backend/src/index.js index 54a8483..28fbc28 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -38,6 +38,7 @@ const app_settingsRoutes = require('./routes/app_settings'); const api_keysRoutes = require('./routes/api_keys'); const studentInquiriesRoutes = require('./routes/student_inquiries'); +const schoolMarketingRoutes = require('./routes/school_marketing'); const getBaseUrl = (url) => { @@ -115,6 +116,7 @@ app.use('/api/app_settings', passport.authenticate('jwt', {session: false}), app app.use('/api/api_keys', passport.authenticate('jwt', {session: false}), api_keysRoutes); app.use('/api/student-inquiries', studentInquiriesRoutes); +app.use('/api/school-marketing', schoolMarketingRoutes); app.use( '/api/openai', diff --git a/backend/src/routes/school_marketing.js b/backend/src/routes/school_marketing.js new file mode 100644 index 0000000..bd67f50 --- /dev/null +++ b/backend/src/routes/school_marketing.js @@ -0,0 +1,609 @@ +const express = require('express'); +const passport = require('passport'); +const { Op } = require('sequelize'); + +const db = require('../db/models'); +const Helpers = require('../helpers'); + +const router = express.Router(); +const wrapAsync = Helpers.wrapAsync; + +const DEFAULT_TEMPLATES = [ + { + name: 'ترحيب واتساب لأولياء الأمور', + category: 'واتساب', + channel: 'واتساب', + content: 'مرحبًا بكم في {{schoolName}}. يسعدنا استقبالكم وتعريفكم ببرامجنا التعليمية. هل يناسبكم موعد زيارة هذا الأسبوع؟', + previewText: 'رسالة أول تواصل سريعة ومحترفة.', + isDefault: true, + }, + { + name: 'إعلان التسجيل المبكر', + category: 'إعلان', + channel: 'إنستغرام', + content: 'ابدأ مستقبل طفلك مع {{schoolName}}. التسجيل المبكر متاح الآن مع مزايا خاصة ومقاعد محدودة.', + previewText: 'نص إعلان قصير للحملات المدفوعة.', + isDefault: true, + }, + { + name: 'سكربت فيديو تعريفي', + category: 'فيديو', + channel: 'فيديو', + content: 'المشهد 1: لقطات للمدرسة. المشهد 2: الأنشطة. المشهد 3: رسالة ثقة للأهالي. المشهد 4: دعوة لحجز زيارة.', + previewText: 'هيكل أولي لفيديو قصير.', + isDefault: true, + }, +]; + +const CAMPAIGN_STATUSES = ['draft', 'ready', 'scheduled', 'published', 'archived']; +const SCHEDULE_STATUSES = ['draft', 'scheduled', 'published', 'cancelled']; +const REFERRAL_STATUSES = ['new', 'qualified', 'rewarded', 'cancelled']; + +function makeBadRequest(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function makeNotFound(message) { + const error = new Error(message); + error.code = 404; + return error; +} + +function cleanText(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +} + +function normalizeNullableText(value) { + const cleaned = cleanText(value); + return cleaned || null; +} + +function normalizeDate(value, fieldName, required = false) { + const cleaned = cleanText(value); + + if (!cleaned) { + if (required) { + throw makeBadRequest(`${fieldName} is required`); + } + + return null; + } + + const parsed = new Date(cleaned); + if (Number.isNaN(parsed.getTime())) { + throw makeBadRequest(`${fieldName} is invalid`); + } + + return parsed; +} + +function normalizeAssetNames(value) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => cleanText(item)) + .filter(Boolean) + .slice(0, 50); +} + +function buildCampaignPayload(data = {}) { + const campaignName = cleanText(data.campaignName); + if (!campaignName) { + throw makeBadRequest('Campaign name is required'); + } + + const status = cleanText(data.status) || 'draft'; + if (!CAMPAIGN_STATUSES.includes(status)) { + throw makeBadRequest('Campaign status is invalid'); + } + + return { + campaignName, + campaignType: cleanText(data.campaignType) || 'تسجيل الطلاب', + objective: normalizeNullableText(data.objective), + audience: normalizeNullableText(data.audience), + gradeFocus: normalizeNullableText(data.gradeFocus), + platform: normalizeNullableText(data.platform), + tone: normalizeNullableText(data.tone), + offer: normalizeNullableText(data.offer), + callToAction: normalizeNullableText(data.callToAction), + publishingDate: normalizeDate(data.publishingDate, 'Publishing date'), + notes: normalizeNullableText(data.notes), + generatedContent: normalizeNullableText(data.generatedContent), + weeklyPlan: normalizeNullableText(data.weeklyPlan), + designerNotes: normalizeNullableText(data.designerNotes), + videoScript: normalizeNullableText(data.videoScript), + messageTemplate: normalizeNullableText(data.messageTemplate), + assetNames: normalizeAssetNames(data.assetNames), + status, + isAutoGenerated: Boolean(data.isAutoGenerated), + lastGeneratedAt: data.generatedContent || data.weeklyPlan || data.videoScript ? new Date() : null, + }; +} + +function buildSchedulePayload(data = {}) { + const title = cleanText(data.title); + if (!title) { + throw makeBadRequest('Schedule title is required'); + } + + const status = cleanText(data.status) || 'scheduled'; + if (!SCHEDULE_STATUSES.includes(status)) { + throw makeBadRequest('Schedule status is invalid'); + } + + return { + title, + channel: normalizeNullableText(data.channel), + publishAt: normalizeDate(data.publishAt, 'Publish date', true), + type: normalizeNullableText(data.type), + status, + notes: normalizeNullableText(data.notes), + payload: typeof data.payload === 'object' && data.payload !== null ? data.payload : {}, + campaignId: cleanText(data.campaignId) || null, + }; +} + +function buildTemplatePayload(data = {}) { + const name = cleanText(data.name); + const content = cleanText(data.content); + + if (!name) { + throw makeBadRequest('Template name is required'); + } + + if (!content) { + throw makeBadRequest('Template content is required'); + } + + return { + name, + category: normalizeNullableText(data.category), + channel: normalizeNullableText(data.channel), + content, + previewText: normalizeNullableText(data.previewText), + isDefault: Boolean(data.isDefault), + }; +} + +function buildReferralPayload(data = {}) { + const referrerName = cleanText(data.referrerName); + const studentName = cleanText(data.studentName); + const status = cleanText(data.status) || 'new'; + + if (!referrerName) { + throw makeBadRequest('Referrer name is required'); + } + + if (!studentName) { + throw makeBadRequest('Student name is required'); + } + + if (!REFERRAL_STATUSES.includes(status)) { + throw makeBadRequest('Referral status is invalid'); + } + + return { + referrerName, + referrerPhone: normalizeNullableText(data.referrerPhone), + studentName, + studentGrade: normalizeNullableText(data.studentGrade), + discountAmount: data.discountAmount !== undefined && data.discountAmount !== null && `${data.discountAmount}` !== '' + ? Number(data.discountAmount) + : null, + rewardNotes: normalizeNullableText(data.rewardNotes), + status, + notes: normalizeNullableText(data.notes), + inquiryId: cleanText(data.inquiryId) || null, + }; +} + +async function ensureDefaultTemplates(currentUserId) { + const count = await db.marketing_templates.count(); + if (count > 0) { + return; + } + + const now = new Date(); + await db.marketing_templates.bulkCreate( + DEFAULT_TEMPLATES.map((item, index) => ({ + ...item, + createdById: currentUserId || null, + updatedById: currentUserId || null, + createdAt: new Date(now.getTime() + index * 1000), + updatedAt: new Date(now.getTime() + index * 1000), + })), + ); +} + +router.use(passport.authenticate('jwt', { session: false })); + +router.get( + '/workspace', + wrapAsync(async (req, res) => { + await ensureDefaultTemplates(req.currentUser?.id || null); + + const [ + summary, + inquiries, + campaigns, + schedules, + referrals, + templates, + latestDraft, + ] = await Promise.all([ + db.student_inquiries.count().then(async (total) => ({ + total, + newLeads: await db.student_inquiries.count({ where: { status: 'new' } }), + scheduled: await db.student_inquiries.count({ where: { status: 'demo_scheduled' } }), + enrolled: await db.student_inquiries.count({ where: { status: 'enrolled' } }), + referrals: await db.marketing_referrals.count(), + campaigns: await db.marketing_campaigns.count(), + scheduledPosts: await db.marketing_schedules.count({ where: { status: 'scheduled' } }), + })), + db.student_inquiries.findAll({ order: [['createdAt', 'DESC']], limit: 100 }), + db.marketing_campaigns.findAll({ order: [['updatedAt', 'DESC']], limit: 20 }), + db.marketing_schedules.findAll({ order: [['publishAt', 'ASC']], limit: 20 }), + db.marketing_referrals.findAll({ order: [['createdAt', 'DESC']], limit: 20 }), + db.marketing_templates.findAll({ order: [['isDefault', 'DESC'], ['updatedAt', 'DESC']], limit: 20 }), + db.marketing_campaigns.findOne({ + where: { status: 'draft' }, + order: [['updatedAt', 'DESC']], + }), + ]); + + res.status(200).send({ + summary, + inquiries, + campaigns, + schedules, + referrals, + templates, + latestDraft, + }); + }), +); + +router.get( + '/campaigns', + wrapAsync(async (req, res) => { + const status = cleanText(req.query.status); + const q = cleanText(req.query.q); + const where = {}; + + if (status) { + if (!CAMPAIGN_STATUSES.includes(status)) { + throw makeBadRequest('Campaign status filter is invalid'); + } + where.status = status; + } + + if (q) { + where[Op.or] = [ + { campaignName: { [Op.iLike]: `%${q}%` } }, + { campaignType: { [Op.iLike]: `%${q}%` } }, + { platform: { [Op.iLike]: `%${q}%` } }, + ]; + } + + const rows = await db.marketing_campaigns.findAll({ + where, + order: [['updatedAt', 'DESC']], + limit: 50, + }); + + res.status(200).send({ rows, count: rows.length }); + }), +); + +router.post( + '/campaigns', + wrapAsync(async (req, res) => { + const payload = buildCampaignPayload(req.body.data || {}); + const campaign = await db.marketing_campaigns.create({ + ...payload, + createdById: req.currentUser?.id || null, + updatedById: req.currentUser?.id || null, + }); + + res.status(200).send(campaign); + }), +); + +router.post( + '/campaigns/save-draft', + wrapAsync(async (req, res) => { + const payload = buildCampaignPayload({ ...(req.body.data || {}), status: 'draft' }); + const campaignId = cleanText(req.body.data?.id); + + let campaign; + if (campaignId) { + campaign = await db.marketing_campaigns.findByPk(campaignId); + if (!campaign) { + throw makeNotFound('Campaign not found'); + } + + await campaign.update({ + ...payload, + updatedById: req.currentUser?.id || null, + }); + } else { + campaign = await db.marketing_campaigns.create({ + ...payload, + createdById: req.currentUser?.id || null, + updatedById: req.currentUser?.id || null, + }); + } + + res.status(200).send(campaign); + }), +); + +router.put( + '/campaigns/:id', + wrapAsync(async (req, res) => { + const campaign = await db.marketing_campaigns.findByPk(req.params.id); + if (!campaign) { + throw makeNotFound('Campaign not found'); + } + + const payload = buildCampaignPayload(req.body.data || {}); + await campaign.update({ + ...payload, + updatedById: req.currentUser?.id || null, + }); + + res.status(200).send(campaign); + }), +); + +router.delete( + '/campaigns/:id', + wrapAsync(async (req, res) => { + const campaign = await db.marketing_campaigns.findByPk(req.params.id); + if (!campaign) { + throw makeNotFound('Campaign not found'); + } + + const relatedSchedules = await db.marketing_schedules.findAll({ + where: { campaignId: campaign.id }, + }); + + await Promise.all(relatedSchedules.map(async (schedule) => { + await schedule.update({ updatedById: req.currentUser?.id || null }); + await schedule.destroy(); + })); + + await campaign.update({ updatedById: req.currentUser?.id || null }); + await campaign.destroy(); + + res.status(200).send(true); + }), +); + +router.get( + '/schedules', + wrapAsync(async (req, res) => { + const rows = await db.marketing_schedules.findAll({ + order: [['publishAt', 'ASC']], + limit: 50, + }); + + res.status(200).send({ rows, count: rows.length }); + }), +); + +router.post( + '/schedules', + wrapAsync(async (req, res) => { + const payload = buildSchedulePayload(req.body.data || {}); + const schedule = await db.marketing_schedules.create({ + ...payload, + createdById: req.currentUser?.id || null, + updatedById: req.currentUser?.id || null, + }); + + if (payload.campaignId) { + const campaign = await db.marketing_campaigns.findByPk(payload.campaignId); + if (campaign) { + await campaign.update({ + status: 'scheduled', + publishingDate: payload.publishAt, + updatedById: req.currentUser?.id || null, + }); + } + } + + res.status(200).send(schedule); + }), +); + +router.put( + '/schedules/:id', + wrapAsync(async (req, res) => { + const schedule = await db.marketing_schedules.findByPk(req.params.id); + if (!schedule) { + throw makeNotFound('Schedule not found'); + } + + const payload = buildSchedulePayload(req.body.data || {}); + await schedule.update({ + ...payload, + updatedById: req.currentUser?.id || null, + }); + + if (payload.campaignId) { + const campaign = await db.marketing_campaigns.findByPk(payload.campaignId); + if (campaign) { + let nextStatus = 'scheduled'; + + if (payload.status === 'published') { + nextStatus = 'published'; + } + + if (payload.status === 'cancelled') { + nextStatus = 'ready'; + } + + await campaign.update({ + status: nextStatus, + publishingDate: payload.publishAt, + updatedById: req.currentUser?.id || null, + }); + } + } + + res.status(200).send(schedule); + }), +); + +router.delete( + '/schedules/:id', + wrapAsync(async (req, res) => { + const schedule = await db.marketing_schedules.findByPk(req.params.id); + if (!schedule) { + throw makeNotFound('Schedule not found'); + } + + await schedule.update({ updatedById: req.currentUser?.id || null }); + await schedule.destroy(); + res.status(200).send(true); + }), +); + +router.get( + '/templates', + wrapAsync(async (req, res) => { + await ensureDefaultTemplates(req.currentUser?.id || null); + const rows = await db.marketing_templates.findAll({ + order: [['isDefault', 'DESC'], ['updatedAt', 'DESC']], + limit: 50, + }); + + res.status(200).send({ rows, count: rows.length }); + }), +); + +router.post( + '/templates', + wrapAsync(async (req, res) => { + const payload = buildTemplatePayload(req.body.data || {}); + const template = await db.marketing_templates.create({ + ...payload, + createdById: req.currentUser?.id || null, + updatedById: req.currentUser?.id || null, + }); + + res.status(200).send(template); + }), +); + +router.put( + '/templates/:id', + wrapAsync(async (req, res) => { + const template = await db.marketing_templates.findByPk(req.params.id); + if (!template) { + throw makeNotFound('Template not found'); + } + + const payload = buildTemplatePayload(req.body.data || {}); + await template.update({ + ...payload, + updatedById: req.currentUser?.id || null, + }); + + res.status(200).send(template); + }), +); + +router.delete( + '/templates/:id', + wrapAsync(async (req, res) => { + const template = await db.marketing_templates.findByPk(req.params.id); + if (!template) { + throw makeNotFound('Template not found'); + } + + await template.update({ updatedById: req.currentUser?.id || null }); + await template.destroy(); + res.status(200).send(true); + }), +); + +router.get( + '/referrals', + wrapAsync(async (req, res) => { + const rows = await db.marketing_referrals.findAll({ + include: [ + { + model: db.student_inquiries, + as: 'inquiry', + required: false, + }, + ], + order: [['createdAt', 'DESC']], + limit: 50, + }); + + res.status(200).send({ rows, count: rows.length }); + }), +); + +router.post( + '/referrals', + wrapAsync(async (req, res) => { + const payload = buildReferralPayload(req.body.data || {}); + const referral = await db.marketing_referrals.create({ + ...payload, + createdById: req.currentUser?.id || null, + updatedById: req.currentUser?.id || null, + }); + + res.status(200).send(referral); + }), +); + +router.put( + '/referrals/:id', + wrapAsync(async (req, res) => { + const referral = await db.marketing_referrals.findByPk(req.params.id); + if (!referral) { + throw makeNotFound('Referral not found'); + } + + const payload = buildReferralPayload(req.body.data || {}); + await referral.update({ + ...payload, + updatedById: req.currentUser?.id || null, + }); + + res.status(200).send(referral); + }), +); + +router.delete( + '/referrals/:id', + wrapAsync(async (req, res) => { + const referral = await db.marketing_referrals.findByPk(req.params.id); + if (!referral) { + throw makeNotFound('Referral not found'); + } + + await referral.update({ updatedById: req.currentUser?.id || null }); + await referral.destroy(); + + res.status(200).send(true); + }), +); + +router.use('/', Helpers.commonErrorHandler); + +module.exports = router; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index eab5f82..4fec477 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -5,12 +5,12 @@ const menuAside: MenuAsideItem[] = [ { href: '/dashboard', icon: icon.mdiViewDashboardOutline, - label: 'Dashboard', + label: 'لوحة التحكم', }, { href: '/school-marketing', - label: 'التسويق المدرسي', + label: 'استوديو التسويق المدرسي', icon: icon.mdiBullhornOutline, }, diff --git a/frontend/src/pages/school-marketing.tsx b/frontend/src/pages/school-marketing.tsx index f9a328c..0896912 100644 --- a/frontend/src/pages/school-marketing.tsx +++ b/frontend/src/pages/school-marketing.tsx @@ -1,10 +1,16 @@ import { + mdiAccountGroupOutline, mdiBullhornOutline, mdiCalendarClock, + mdiChartBar, mdiCheckCircleOutline, - mdiMagnify, - mdiMessageTextOutline, + mdiContentSaveOutline, + mdiDownloadOutline, + mdiImageOutline, + mdiLightbulbOutline, mdiOpenInNew, + mdiPaletteOutline, + mdiRobotOutline, mdiSchoolOutline, mdiVideoOutline, mdiWhatsapp, @@ -20,7 +26,8 @@ import SectionMain from '../components/SectionMain'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import { getPageTitle } from '../config'; import LayoutAuthenticated from '../layouts/Authenticated'; -import { useAppSelector } from '../stores/hooks'; +import { aiResponse } from '../stores/openAiSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; type Inquiry = { id: string; @@ -50,16 +57,181 @@ type Summary = { scheduled: number; enrolled: number; referrals: number; + campaigns?: number; + scheduledPosts?: number; }; -const statusOptions = [ - { value: 'all', label: 'كل الحالات' }, - { value: 'new', label: 'جديد' }, - { value: 'contacted', label: 'تم التواصل' }, - { value: 'demo_scheduled', label: 'عرض مجدول' }, - { value: 'application_started', label: 'بدأ التسجيل' }, - { value: 'enrolled', label: 'تم القبول' }, - { value: 'lost', label: 'غير مهتم' }, +type SchoolProfile = { + schoolName: string; + tagline: string; + vision: string; + mission: string; + logoUrl: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + fontFamily: string; + whatsappNumber: string; + phoneNumber: string; + city: string; +}; + +type CampaignForm = { + campaignName: string; + campaignType: string; + objective: string; + audience: string; + gradeFocus: string; + platform: string; + tone: string; + offer: string; + callToAction: string; + publishingDate: string; + notes: string; +}; + +type ScheduleItem = { + id: string; + title: string; + channel: string; + publishAt: string; + type: string; + status: string; + campaignId?: string | null; + notes?: string | null; + payload?: Record | null; +}; + +type CampaignRecord = CampaignForm & { + id: string; + generatedContent?: string | null; + weeklyPlan?: string | null; + designerNotes?: string | null; + videoScript?: string | null; + messageTemplate?: string | null; + assetNames?: string[] | null; + status: string; + isAutoGenerated?: boolean | null; + createdAt: string; + updatedAt: string; +}; + +type TemplateRecord = { + id: string; + name: string; + category?: string | null; + channel?: string | null; + content: string; + previewText?: string | null; + isDefault?: boolean; +}; + +type ReferralRecord = { + id: string; + referrerName: string; + referrerPhone?: string | null; + studentName: string; + studentGrade?: string | null; + discountAmount?: string | number | null; + rewardNotes?: string | null; + status: string; + notes?: string | null; + inquiryId?: string | null; + createdAt: string; +}; + +type ReferralForm = { + referrerName: string; + referrerPhone: string; + studentName: string; + studentGrade: string; + discountAmount: string; + rewardNotes: string; + status: string; + notes: string; + inquiryId: string; +}; + +type SettingRow = { + id: string; + key: string; + value: string; +}; + +type TabId = 'overview' | 'campaigns' | 'students' | 'insights' | 'settings'; + +const emptySummary: Summary = { + total: 0, + newLeads: 0, + scheduled: 0, + enrolled: 0, + referrals: 0, + campaigns: 0, + scheduledPosts: 0, +}; + +const defaultSchoolProfile: SchoolProfile = { + schoolName: 'مدرسة الأفق الحديثة', + tagline: 'تعليم عصري يصنع مستقبلًا واثقًا', + vision: 'بناء تجربة تعليمية جاذبة ترفع التسجيل وتحسن صورة المدرسة.', + mission: 'توحيد المحتوى والحملات والتواصل مع أولياء الأمور داخل لوحة عربية سهلة.', + logoUrl: '', + primaryColor: '#7C3AED', + secondaryColor: '#0EA5E9', + accentColor: '#14B8A6', + fontFamily: 'Tajawal, Inter, sans-serif', + whatsappNumber: '', + phoneNumber: '', + city: 'الرياض', +}; + +const defaultCampaign: CampaignForm = { + campaignName: 'حملة التسجيل المبكر', + campaignType: 'تسجيل الطلاب', + objective: 'زيادة طلبات التسجيل للأسبوع القادم', + audience: 'أولياء أمور الطلاب في المرحلة الابتدائية والمتوسطة', + gradeFocus: 'المرحلة الابتدائية', + platform: 'واتساب + إنستغرام', + tone: 'ودود وموثوق', + offer: 'خصم تسجيل مبكر ومقابلة تعريفية', + callToAction: 'احجز زيارة المدرسة الآن', + publishingDate: '', + notes: 'التركيز على الأمان والأنشطة والنتائج الأكاديمية.', +}; + +const defaultReferralForm: ReferralForm = { + referrerName: '', + referrerPhone: '', + studentName: '', + studentGrade: '', + discountAmount: '', + rewardNotes: 'خصم تسجيل أو مزية تعريفية', + status: 'new', + notes: '', + inquiryId: '', +}; + +const schoolSettingKeys: Record = { + schoolName: 'school_marketing.school_name', + tagline: 'school_marketing.tagline', + vision: 'school_marketing.vision', + mission: 'school_marketing.mission', + logoUrl: 'school_marketing.logo_url', + primaryColor: 'school_marketing.primary_color', + secondaryColor: 'school_marketing.secondary_color', + accentColor: 'school_marketing.accent_color', + fontFamily: 'school_marketing.font_family', + whatsappNumber: 'school_marketing.whatsapp_number', + phoneNumber: 'school_marketing.phone_number', + city: 'school_marketing.city', +}; + +const tabItems: Array<{ id: TabId; label: string; icon: string }> = [ + { id: 'overview', label: 'نظرة عامة', icon: mdiChartBar }, + { id: 'campaigns', label: 'منشئ الحملات', icon: mdiBullhornOutline }, + { id: 'students', label: 'الطلاب والإحالات', icon: mdiAccountGroupOutline }, + { id: 'insights', label: 'تحليل وذكاء اصطناعي', icon: mdiRobotOutline }, + { id: 'settings', label: 'إعدادات المدرسة', icon: mdiSchoolOutline }, ]; const statusLabels: Record = { @@ -67,7 +239,7 @@ const statusLabels: Record = { contacted: 'تم التواصل', demo_scheduled: 'عرض مجدول', application_started: 'بدأ التسجيل', - enrolled: 'تم القبول', + enrolled: 'تم التسجيل', lost: 'غير مهتم', }; @@ -80,13 +252,21 @@ const referralLabels: Record = { other: 'أخرى', }; -const emptySummary: Summary = { - total: 0, - newLeads: 0, - scheduled: 0, - enrolled: 0, - referrals: 0, -}; +const leadStatusOptions = [ + { value: 'new', label: 'جديد' }, + { value: 'contacted', label: 'تم التواصل' }, + { value: 'demo_scheduled', label: 'عرض مجدول' }, + { value: 'application_started', label: 'بدأ التسجيل' }, + { value: 'enrolled', label: 'تم التسجيل' }, + { value: 'lost', label: 'غير مهتم' }, +]; + +const scheduleStatusOptions = [ + { value: 'draft', label: 'مسودة' }, + { value: 'scheduled', label: 'مجدول' }, + { value: 'published', label: 'تم النشر' }, + { value: 'cancelled', label: 'ملغي' }, +]; function formatDate(value?: string | null) { if (!value) { @@ -104,6 +284,37 @@ function formatDate(value?: string | null) { }); } +function getAiText(payload: any) { + const output = payload?.data?.output || payload?.output; + + if (!Array.isArray(output)) { + return ''; + } + + return output + .flatMap((item: any) => (Array.isArray(item?.content) ? item.content : [])) + .filter((item: any) => item?.type === 'output_text' && typeof item?.text === 'string') + .map((item: any) => item.text) + .join('\n') + .trim(); +} + +function downloadText(filename: string, content: string) { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = filename; + link.click(); + window.URL.revokeObjectURL(link.href); +} + +function nextDate(hoursAhead = 24) { + const date = new Date(Date.now() + hoursAhead * 60 * 60 * 1000); + const pad = (value: number) => value.toString().padStart(2, '0'); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + function toDateTimeLocal(value?: string | null) { if (!value) { return ''; @@ -114,464 +325,1652 @@ function toDateTimeLocal(value?: string | null) { return ''; } - const pad = (number: number) => number.toString().padStart(2, '0'); - + const pad = (item: number) => item.toString().padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } +function escapeCsvCell(value: unknown) { + const normalized = `${value ?? ''}`.replace(/"/g, '""'); + return `"${normalized}"`; +} + +function downloadCsv(filename: string, rows: Array>) { + if (!rows.length) { + downloadText(filename, ''); + return; + } + + const headers = Object.keys(rows[0]); + const content = [ + headers.join(','), + ...rows.map((row) => headers.map((header) => escapeCsvCell(row[header])).join(',')), + ].join('\n'); + + const blob = new Blob([content], { type: 'text/csv;charset=utf-8' }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = filename; + link.click(); + window.URL.revokeObjectURL(link.href); +} + export default function SchoolMarketingPage() { + const dispatch = useAppDispatch(); const { currentUser } = useAppSelector((state) => state.auth); + const { isAskingResponse, errorMessage: aiErrorMessage } = useAppSelector((state) => state.openAi); + + const [activeTab, setActiveTab] = React.useState('overview'); const [summary, setSummary] = React.useState(emptySummary); const [inquiries, setInquiries] = React.useState([]); - const [selectedId, setSelectedId] = React.useState(''); - const [selectedInquiry, setSelectedInquiry] = React.useState(null); - const [statusFilter, setStatusFilter] = React.useState('all'); - const [search, setSearch] = React.useState(''); - const [loading, setLoading] = React.useState(true); - const [detailLoading, setDetailLoading] = React.useState(false); - const [saving, setSaving] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(''); - const [saveMessage, setSaveMessage] = React.useState(''); - const [formState, setFormState] = React.useState({ - status: 'new', - meetingAt: '', - demoVideoUrl: '', - counselorNotes: '', - }); + const [workspaceLoading, setWorkspaceLoading] = React.useState(true); + const [workspaceError, setWorkspaceError] = React.useState(''); - const loadSummary = React.useCallback(async () => { - const response = await axios.get('/student-inquiries/summary'); - setSummary(response.data || emptySummary); + const [schoolProfile, setSchoolProfile] = React.useState(defaultSchoolProfile); + const [settingRows, setSettingRows] = React.useState>({}); + const [settingsMessage, setSettingsMessage] = React.useState(''); + const [isSavingSettings, setIsSavingSettings] = React.useState(false); + + const [campaignId, setCampaignId] = React.useState(''); + const [campaign, setCampaign] = React.useState(defaultCampaign); + const [savedCampaigns, setSavedCampaigns] = React.useState([]); + const [generatedContent, setGeneratedContent] = React.useState(''); + const [weeklyPlan, setWeeklyPlan] = React.useState(''); + const [designerNotes, setDesignerNotes] = React.useState(''); + const [videoScript, setVideoScript] = React.useState(''); + const [messageTemplate, setMessageTemplate] = React.useState('مرحبًا، يسعدنا مشاركتكم تفاصيل التسجيل والزيارة المدرسية.'); + const [templates, setTemplates] = React.useState([]); + const [templateName, setTemplateName] = React.useState('قالب واتساب مخصص'); + const [templateCategory, setTemplateCategory] = React.useState('واتساب'); + const [scheduledItems, setScheduledItems] = React.useState([]); + const [assetNames, setAssetNames] = React.useState([]); + const [referrals, setReferrals] = React.useState([]); + const [referralForm, setReferralForm] = React.useState(defaultReferralForm); + const [selectedInquiryId, setSelectedInquiryId] = React.useState(''); + const [inquiryDraft, setInquiryDraft] = React.useState>({}); + const [scheduleDrafts, setScheduleDrafts] = React.useState>({}); + const [copiedMessage, setCopiedMessage] = React.useState(''); + const [isSavingCampaign, setIsSavingCampaign] = React.useState(false); + const [isSavingTemplate, setIsSavingTemplate] = React.useState(false); + const [isSavingReferral, setIsSavingReferral] = React.useState(false); + const [isSavingInquiry, setIsSavingInquiry] = React.useState(false); + const [updatingScheduleId, setUpdatingScheduleId] = React.useState(''); + const [deletingCampaignId, setDeletingCampaignId] = React.useState(''); + const [deletingReferralId, setDeletingReferralId] = React.useState(''); + const [workspaceHydrated, setWorkspaceHydrated] = React.useState(false); + + const hydrateCampaignState = React.useCallback((record?: Partial | null) => { + if (!record) { + return; + } + + setCampaignId(record.id || ''); + setCampaign({ + campaignName: record.campaignName || defaultCampaign.campaignName, + campaignType: record.campaignType || defaultCampaign.campaignType, + objective: record.objective || defaultCampaign.objective, + audience: record.audience || defaultCampaign.audience, + gradeFocus: record.gradeFocus || defaultCampaign.gradeFocus, + platform: record.platform || defaultCampaign.platform, + tone: record.tone || defaultCampaign.tone, + offer: record.offer || defaultCampaign.offer, + callToAction: record.callToAction || defaultCampaign.callToAction, + publishingDate: record.publishingDate ? `${record.publishingDate}`.slice(0, 16) : '', + notes: record.notes || defaultCampaign.notes, + }); + setGeneratedContent(record.generatedContent || ''); + setWeeklyPlan(record.weeklyPlan || ''); + setDesignerNotes(record.designerNotes || ''); + setVideoScript(record.videoScript || ''); + setMessageTemplate(record.messageTemplate || ''); + setAssetNames(Array.isArray(record.assetNames) ? record.assetNames : []); }, []); - const loadInquiries = React.useCallback(async () => { - setLoading(true); - setErrorMessage(''); + const hydrateInquiryState = React.useCallback((record?: Partial | null) => { + if (!record?.id) { + return; + } + + setSelectedInquiryId(record.id); + setInquiryDraft({ + status: record.status || 'new', + meetingAt: toDateTimeLocal(record.meetingAt), + demoVideoUrl: record.demoVideoUrl || '', + counselorNotes: record.counselorNotes || record.notes || '', + lastContactedAt: toDateTimeLocal(record.lastContactedAt), + }); + }, []); + + const loadWorkspace = React.useCallback(async () => { + setWorkspaceLoading(true); + setWorkspaceError(''); try { - const response = await axios.get('/student-inquiries', { + const response = await axios.get('/school-marketing/workspace'); + const payload = response.data || {}; + + setSummary(payload.summary || emptySummary); + setInquiries(Array.isArray(payload.inquiries) ? payload.inquiries : []); + setSavedCampaigns(Array.isArray(payload.campaigns) ? payload.campaigns : []); + setScheduledItems(Array.isArray(payload.schedules) ? payload.schedules : []); + setReferrals(Array.isArray(payload.referrals) ? payload.referrals : []); + setTemplates(Array.isArray(payload.templates) ? payload.templates : []); + + if (payload.latestDraft) { + hydrateCampaignState(payload.latestDraft); + } + } catch (error: any) { + setWorkspaceError(error?.response?.data || error?.message || 'تعذر تحميل بيانات النظام'); + } finally { + setWorkspaceLoading(false); + setWorkspaceHydrated(true); + } + }, [hydrateCampaignState]); + + const loadSchoolProfile = React.useCallback(async () => { + try { + const response = await axios.get('/app_settings', { params: { - status: statusFilter, - q: search, + key: 'school_marketing.', + limit: 100, + page: 0, }, }); - const rows = Array.isArray(response.data?.rows) ? response.data.rows : []; - setInquiries(rows); - if (!rows.length) { - setSelectedId(''); - setSelectedInquiry(null); - return; + const rows = Array.isArray(response.data?.rows) ? response.data.rows : []; + const nextRows: Record = {}; + const nextProfile: SchoolProfile = { ...defaultSchoolProfile }; + + rows.forEach((row: SettingRow) => { + nextRows[row.key] = row; + + const entry = Object.entries(schoolSettingKeys).find(([, key]) => key === row.key); + if (entry) { + const profileKey = entry[0] as keyof SchoolProfile; + nextProfile[profileKey] = row.value || defaultSchoolProfile[profileKey]; + } + }); + + setSettingRows(nextRows); + setSchoolProfile(nextProfile); + } catch (error) { + console.error('Failed to load school profile settings:', error); + } + }, []); + + const saveCampaignDraft = React.useCallback(async (options?: { silent?: boolean; autoGenerated?: boolean }) => { + try { + if (!options?.silent) { + setIsSavingCampaign(true); } - const nextId = rows.some((item: Inquiry) => item.id === selectedId) ? selectedId : rows[0].id; - setSelectedId(nextId); - } catch (error: any) { - setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل الطلبات'); - } finally { - setLoading(false); - } - }, [search, selectedId, statusFilter]); + const response = await axios.post('/school-marketing/campaigns/save-draft', { + data: { + id: campaignId || undefined, + ...campaign, + generatedContent, + weeklyPlan, + designerNotes, + videoScript, + messageTemplate, + assetNames, + isAutoGenerated: Boolean(options?.autoGenerated), + }, + }); - const loadInquiryDetails = React.useCallback(async (id: string) => { - if (!id) { + const savedRecord = response.data as CampaignRecord; + setCampaignId(savedRecord.id); + setSavedCampaigns((current) => { + const next = [savedRecord, ...current.filter((item) => item.id !== savedRecord.id)]; + return next.slice(0, 20); + }); + + return savedRecord; + } catch (error) { + console.error('Failed to save campaign draft:', error); + throw error; + } finally { + if (!options?.silent) { + setIsSavingCampaign(false); + } + } + }, [assetNames, campaign, campaignId, designerNotes, generatedContent, messageTemplate, videoScript, weeklyPlan]); + + const saveTemplate = React.useCallback(async () => { + try { + setIsSavingTemplate(true); + const response = await axios.post('/school-marketing/templates', { + data: { + name: templateName, + category: templateCategory, + channel: templateCategory, + content: messageTemplate, + previewText: messageTemplate.slice(0, 140), + }, + }); + + const savedTemplate = response.data as TemplateRecord; + setTemplates((current) => [savedTemplate, ...current.filter((item) => item.id !== savedTemplate.id)].slice(0, 20)); + setCopiedMessage('تم حفظ القالب داخل النظام.'); + window.setTimeout(() => setCopiedMessage(''), 2500); + } catch (error) { + console.error('Failed to save template:', error); + setCopiedMessage('تعذر حفظ القالب في الوقت الحالي.'); + window.setTimeout(() => setCopiedMessage(''), 2500); + } finally { + setIsSavingTemplate(false); + } + }, [messageTemplate, templateCategory, templateName]); + + const saveReferral = React.useCallback(async () => { + try { + setIsSavingReferral(true); + const response = await axios.post('/school-marketing/referrals', { + data: { + ...referralForm, + discountAmount: referralForm.discountAmount || null, + inquiryId: referralForm.inquiryId || null, + }, + }); + + const savedReferral = response.data as ReferralRecord; + setReferrals((current) => [savedReferral, ...current.filter((item) => item.id !== savedReferral.id)].slice(0, 20)); + setReferralForm(defaultReferralForm); + await loadWorkspace(); + } catch (error) { + console.error('Failed to save referral:', error); + setWorkspaceError('تعذر حفظ الإحالة الجديدة.'); + } finally { + setIsSavingReferral(false); + } + }, [loadWorkspace, referralForm]); + + React.useEffect(() => { + loadWorkspace(); + loadSchoolProfile(); + }, [loadWorkspace, loadSchoolProfile]); + + React.useEffect(() => { + if (!inquiries.length) { + setSelectedInquiryId(''); + setInquiryDraft({}); return; } - setDetailLoading(true); - setErrorMessage(''); + const selectedInquiry = inquiries.find((item) => item.id === selectedInquiryId) || inquiries[0]; + hydrateInquiryState(selectedInquiry); + }, [hydrateInquiryState, inquiries, selectedInquiryId]); - try { - const response = await axios.get(`/student-inquiries/${id}`); - const inquiry = response.data as Inquiry; - setSelectedInquiry(inquiry); - setFormState({ - status: inquiry.status, - meetingAt: toDateTimeLocal(inquiry.meetingAt), - demoVideoUrl: inquiry.demoVideoUrl || '', - counselorNotes: inquiry.counselorNotes || '', - }); - } catch (error: any) { - setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل التفاصيل'); - } finally { - setDetailLoading(false); + React.useEffect(() => { + const nextDrafts = scheduledItems.reduce>((accumulator, item) => { + accumulator[item.id] = { + publishAt: toDateTimeLocal(item.publishAt), + status: item.status || 'scheduled', + }; + return accumulator; + }, {}); + + setScheduleDrafts(nextDrafts); + }, [scheduledItems]); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const cachedCampaign = window.localStorage.getItem('schoolMarketingCampaignDraft'); + const cachedGenerated = window.localStorage.getItem('schoolMarketingGeneratedContent'); + const cachedWeeklyPlan = window.localStorage.getItem('schoolMarketingWeeklyPlan'); + const cachedDesignerNotes = window.localStorage.getItem('schoolMarketingDesignerNotes'); + const cachedVideoScript = window.localStorage.getItem('schoolMarketingVideoScript'); + const cachedTemplate = window.localStorage.getItem('schoolMarketingMessageTemplate'); + const cachedSchedule = window.localStorage.getItem('schoolMarketingSchedule'); + + if (cachedCampaign) { + setCampaign({ ...defaultCampaign, ...JSON.parse(cachedCampaign) }); + } + if (cachedGenerated) { + setGeneratedContent(cachedGenerated); + } + if (cachedWeeklyPlan) { + setWeeklyPlan(cachedWeeklyPlan); + } + if (cachedDesignerNotes) { + setDesignerNotes(cachedDesignerNotes); + } + if (cachedVideoScript) { + setVideoScript(cachedVideoScript); + } + if (cachedTemplate) { + setMessageTemplate(cachedTemplate); + } + if (cachedSchedule) { + setScheduledItems(JSON.parse(cachedSchedule)); } }, []); React.useEffect(() => { - const timeoutId = window.setTimeout(() => { - loadSummary().catch((error: any) => { - setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل الملخص'); - }); - loadInquiries().catch((error: any) => { - setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل الطلبات'); - }); - }, 250); - - return () => window.clearTimeout(timeoutId); - }, [loadInquiries, loadSummary]); - - React.useEffect(() => { - if (!selectedId) { + if (typeof window === 'undefined') { return; } - loadInquiryDetails(selectedId).catch((error: any) => { - setErrorMessage(error?.response?.data || error?.message || 'تعذر تحميل التفاصيل'); - }); - }, [loadInquiryDetails, selectedId]); + window.localStorage.setItem('schoolMarketingCampaignDraft', JSON.stringify(campaign)); + }, [campaign]); - const selectedWhatsappLink = React.useMemo(() => { - if (!selectedInquiry?.whatsappNumber) { - return ''; + React.useEffect(() => { + if (typeof window === 'undefined') { + return; } - return `https://wa.me/${selectedInquiry.whatsappNumber.replace(/[^\d]/g, '')}`; - }, [selectedInquiry?.whatsappNumber]); + window.localStorage.setItem('schoolMarketingGeneratedContent', generatedContent); + window.localStorage.setItem('schoolMarketingWeeklyPlan', weeklyPlan); + window.localStorage.setItem('schoolMarketingDesignerNotes', designerNotes); + window.localStorage.setItem('schoolMarketingVideoScript', videoScript); + window.localStorage.setItem('schoolMarketingMessageTemplate', messageTemplate); + window.localStorage.setItem('schoolMarketingSchedule', JSON.stringify(scheduledItems)); + }, [designerNotes, generatedContent, messageTemplate, scheduledItems, videoScript, weeklyPlan]); - const handleSave = async () => { + React.useEffect(() => { + if (!workspaceHydrated) { + return; + } + + const timeout = window.setTimeout(() => { + saveCampaignDraft({ silent: true }).catch((error) => { + console.error('Campaign autosave failed:', error); + }); + }, 1200); + + return () => window.clearTimeout(timeout); + }, [assetNames, campaign, designerNotes, generatedContent, messageTemplate, saveCampaignDraft, videoScript, weeklyPlan, workspaceHydrated]); + + const referralBreakdown = React.useMemo(() => { + const counts: Record = {}; + + inquiries.forEach((item) => { + counts[item.referralSource] = (counts[item.referralSource] || 0) + 1; + }); + + return Object.entries(counts) + .map(([source, count]) => ({ + source, + label: referralLabels[source] || source, + count, + })) + .sort((first, second) => second.count - first.count); + }, [inquiries]); + + const upcomingMeetings = React.useMemo( + () => inquiries.filter((item) => item.meetingAt).sort((first, second) => { + const firstDate = new Date(first.meetingAt || '').getTime(); + const secondDate = new Date(second.meetingAt || '').getTime(); + return firstDate - secondDate; + }).slice(0, 5), + [inquiries], + ); + + const conversionRate = summary.total ? Math.round((summary.enrolled / summary.total) * 100) : 0; + const responseRate = summary.total ? Math.round(((summary.total - summary.newLeads) / summary.total) * 100) : 0; + + const handleCampaignChange = (field: keyof CampaignForm, value: string) => { + setCampaign((current) => ({ ...current, [field]: value })); + }; + + const handleReferralChange = (field: keyof ReferralForm, value: string) => { + setReferralForm((current) => ({ ...current, [field]: value })); + }; + + const applyTemplate = (template: TemplateRecord) => { + setTemplateName(template.name || 'قالب محفوظ'); + setTemplateCategory(template.category || template.channel || 'واتساب'); + setMessageTemplate(template.content || ''); + }; + + const handleSchoolProfileChange = (field: keyof SchoolProfile, value: string) => { + setSchoolProfile((current) => ({ ...current, [field]: value })); + setSettingsMessage(''); + }; + + const saveSchoolProfile = async () => { + setIsSavingSettings(true); + setSettingsMessage(''); + + try { + const operations = (Object.keys(schoolSettingKeys) as Array).map(async (profileKey) => { + const key = schoolSettingKeys[profileKey]; + const value = schoolProfile[profileKey] || ''; + const existingRow = settingRows[key]; + const payload = { + key, + value, + value_type: 'string', + description: 'School marketing setting', + is_secret: false, + }; + + if (existingRow?.id) { + await axios.put(`/app_settings/${existingRow.id}`, { + id: existingRow.id, + data: payload, + }); + return; + } + + await axios.post('/app_settings', { + data: payload, + }); + }); + + await Promise.all(operations); + setSettingsMessage('تم حفظ بيانات المدرسة بنجاح.'); + await loadSchoolProfile(); + } catch (error: any) { + console.error('Failed to save school profile settings:', error); + setSettingsMessage(error?.response?.data || error?.message || 'تعذر حفظ إعدادات المدرسة'); + } finally { + setIsSavingSettings(false); + } + }; + + const generateWithAi = async (mode: 'campaign' | 'weekly' | 'automation') => { + const baseContext = ` +اسم المدرسة: ${schoolProfile.schoolName} +المدينة: ${schoolProfile.city} +الشعار التسويقي: ${schoolProfile.tagline} +رؤية المدرسة: ${schoolProfile.vision} +رسالة المدرسة: ${schoolProfile.mission} +نوع الحملة: ${campaign.campaignType} +اسم الحملة: ${campaign.campaignName} +الهدف: ${campaign.objective} +الجمهور: ${campaign.audience} +المرحلة المستهدفة: ${campaign.gradeFocus} +القنوات: ${campaign.platform} +النبرة: ${campaign.tone} +العرض: ${campaign.offer} +الدعوة للإجراء: ${campaign.callToAction} +ملاحظات إضافية: ${campaign.notes} +إحصاءات النظام: إجمالي الطلبات ${summary.total} - التسجيلات ${summary.enrolled} - الإحالات ${summary.referrals} + `.trim(); + + let userPrompt = ''; + + if (mode === 'campaign') { + userPrompt = `اعتمد على البيانات التالية واكتب بالعربية الفصحى العملية: \n${baseContext}\n\nأعد لي الأقسام التالية بوضوح:\n1) منشور قصير للسوشيال ميديا\n2) إعلان أطول\n3) رسالة واتساب مختصرة\n4) سكربت فيديو من 4 مشاهد\n5) ملاحظات للمصمم (ألوان وعناصر بصرية وعنوان رئيسي)\nاجعل كل قسم بعنوان واضح.`; + } + + if (mode === 'weekly') { + userPrompt = `بناءً على البيانات التالية أنشئ خطة تسويق مدرسية أسبوعية لمدة 7 أيام باللغة العربية تشمل: الهدف اليومي، نوع المحتوى، النص المختصر، القناة، الوقت المقترح، ومؤشر القياس.\n\n${baseContext}`; + } + + if (mode === 'automation') { + userPrompt = `أنت نظام أتمتة تسويق مدرسي. انشئ مخرجات جاهزة بالعربية اعتمادًا على المعطيات التالية:\n${baseContext}\n\nالمطلوب:\n- أفضل فكرة منشور\n- أفضل قالب تصميم\n- أفضل فكرة فيديو\n- أفضل وقت نشر\n- توصيتان لتحسين التحويل من أولياء الأمور إلى تسجيلات فعلية`; + } + + const response = await dispatch( + aiResponse({ + input: [ + { + role: 'system', + content: 'أنت مدير تسويق مدرسي عربي وخبير محتوى وإعلانات ورسائل واتساب وسيناريوهات فيديو. اكتب بالعربية الواضحة وبتنسيق سهل التنفيذ.', + }, + { + role: 'user', + content: userPrompt, + }, + ], + options: { poll_interval: 5, poll_timeout: 300 }, + }), + ).unwrap(); + + const text = getAiText(response); + + if (mode === 'campaign') { + setGeneratedContent(text); + setMessageTemplate(text.includes('رسالة واتساب') ? text : messageTemplate); + setVideoScript(text); + setDesignerNotes(text); + } + + if (mode === 'weekly') { + setWeeklyPlan(text); + } + + if (mode === 'automation') { + setWeeklyPlan(text); + setGeneratedContent(text); + setDesignerNotes(text); + setVideoScript(text); + } + + if (mode !== 'weekly') { + window.setTimeout(() => { + saveCampaignDraft({ silent: true, autoGenerated: true }).catch((error) => { + console.error('Failed to persist AI campaign output:', error); + }); + }, 0); + } + }; + + const handleScheduleCampaign = async () => { + try { + const savedDraft = await saveCampaignDraft(); + const response = await axios.post('/school-marketing/schedules', { + data: { + title: campaign.campaignName, + channel: campaign.platform, + publishAt: campaign.publishingDate || nextDate(24), + type: campaign.campaignType, + status: 'scheduled', + campaignId: savedDraft?.id || campaignId || null, + payload: { + campaign, + generatedContent, + messageTemplate, + schoolProfile, + }, + }, + }); + + const item = response.data as ScheduleItem; + setScheduledItems((current) => [item, ...current.filter((entry) => entry.id !== item.id)].slice(0, 20)); + setSummary((current) => ({ + ...current, + scheduledPosts: (current.scheduledPosts || 0) + 1, + })); + } catch (error) { + console.error('Failed to schedule campaign:', error); + setWorkspaceError('تعذر جدولة الحملة في الوقت الحالي.'); + } + }; + + const removeScheduledItem = async (id: string) => { + try { + await axios.delete(`/school-marketing/schedules/${id}`); + setScheduledItems((current) => current.filter((item) => item.id !== id)); + await loadWorkspace(); + } catch (error) { + console.error('Failed to delete scheduled item:', error); + setWorkspaceError('تعذر حذف العنصر المجدول.'); + } + }; + + const updateScheduleField = (id: string, field: 'publishAt' | 'status', value: string) => { + setScheduleDrafts((current) => ({ + ...current, + [id]: { + publishAt: current[id]?.publishAt || '', + status: current[id]?.status || 'scheduled', + [field]: value, + }, + })); + }; + + const saveScheduledItem = async (item: ScheduleItem) => { + try { + const draft = scheduleDrafts[item.id]; + setUpdatingScheduleId(item.id); + const response = await axios.put(`/school-marketing/schedules/${item.id}`, { + data: { + title: item.title, + channel: item.channel, + publishAt: draft?.publishAt || toDateTimeLocal(item.publishAt), + type: item.type, + status: draft?.status || item.status, + notes: item.notes || null, + campaignId: item.campaignId || null, + payload: item.payload || {}, + }, + }); + + const updated = response.data as ScheduleItem; + setScheduledItems((current) => current.map((entry) => (entry.id === updated.id ? updated : entry))); + await loadWorkspace(); + } catch (error) { + console.error('Failed to update scheduled item:', error); + setWorkspaceError('تعذر تحديث الجدولة الحالية.'); + } finally { + setUpdatingScheduleId(''); + } + }; + + const deleteCampaign = async (id: string) => { + try { + setDeletingCampaignId(id); + await axios.delete(`/school-marketing/campaigns/${id}`); + setSavedCampaigns((current) => current.filter((item) => item.id !== id)); + setScheduledItems((current) => current.filter((item) => item.campaignId !== id)); + + if (campaignId === id) { + setCampaignId(''); + setCampaign(defaultCampaign); + setGeneratedContent(''); + setWeeklyPlan(''); + setDesignerNotes(''); + setVideoScript(''); + setMessageTemplate('مرحبًا، يسعدنا مشاركتكم تفاصيل التسجيل والزيارة المدرسية.'); + setAssetNames([]); + } + + await loadWorkspace(); + } catch (error) { + console.error('Failed to delete campaign:', error); + setWorkspaceError('تعذر حذف الحملة الحالية.'); + } finally { + setDeletingCampaignId(''); + } + }; + + const handleInquiryChange = (field: 'status' | 'meetingAt' | 'demoVideoUrl' | 'counselorNotes' | 'lastContactedAt', value: string) => { + setInquiryDraft((current) => ({ + ...current, + [field]: value, + })); + }; + + const saveInquiry = async () => { + if (!selectedInquiryId) { + return; + } + + try { + setIsSavingInquiry(true); + await axios.put(`/student_inquiries/${selectedInquiryId}`, { + data: { + status: inquiryDraft.status || 'new', + meetingAt: inquiryDraft.meetingAt || null, + demoVideoUrl: inquiryDraft.demoVideoUrl || null, + counselorNotes: inquiryDraft.counselorNotes || '', + lastContactedAt: inquiryDraft.lastContactedAt || null, + }, + }); + + await loadWorkspace(); + } catch (error) { + console.error('Failed to save inquiry:', error); + setWorkspaceError('تعذر حفظ تحديثات الطالب/ولي الأمر.'); + } finally { + setIsSavingInquiry(false); + } + }; + + const useInquiryForReferral = () => { + const selectedInquiry = inquiries.find((item) => item.id === selectedInquiryId); if (!selectedInquiry) { return; } - setSaving(true); - setSaveMessage(''); - setErrorMessage(''); + setReferralForm((current) => ({ + ...current, + inquiryId: selectedInquiry.id, + studentName: selectedInquiry.studentName, + studentGrade: selectedInquiry.gradeLevel, + referrerPhone: current.referrerPhone || selectedInquiry.whatsappNumber, + })); + }; + const deleteReferral = async (id: string) => { try { - await axios.put(`/student-inquiries/${selectedInquiry.id}`, { - data: { - status: formState.status, - meetingAt: formState.meetingAt, - demoVideoUrl: formState.demoVideoUrl, - counselorNotes: formState.counselorNotes, - }, - }); - - setSaveMessage('تم حفظ المتابعة بنجاح'); - await Promise.all([loadSummary(), loadInquiries(), loadInquiryDetails(selectedInquiry.id)]); - } catch (error: any) { - setErrorMessage(error?.response?.data || error?.message || 'تعذر حفظ التحديثات'); + setDeletingReferralId(id); + await axios.delete(`/school-marketing/referrals/${id}`); + setReferrals((current) => current.filter((item) => item.id !== id)); + await loadWorkspace(); + } catch (error) { + console.error('Failed to delete referral:', error); + setWorkspaceError('تعذر حذف الإحالة الحالية.'); } finally { - setSaving(false); + setDeletingReferralId(''); } }; - const stats = [ - { - label: 'كل الطلبات', - value: summary.total, - accent: 'from-[#8B5CF6] to-[#D946EF]', - icon: mdiBullhornOutline, - }, - { - label: 'طلبات جديدة', - value: summary.newLeads, - accent: 'from-[#0EA5E9] to-[#14B8A6]', - icon: mdiSchoolOutline, - }, - { - label: 'عروض مجدولة', - value: summary.scheduled, - accent: 'from-[#F97316] to-[#FACC15]', - icon: mdiCalendarClock, - }, - { - label: 'إحالات', - value: summary.referrals, - accent: 'from-[#10B981] to-[#22C55E]', - icon: mdiCheckCircleOutline, - }, - ]; + const exportStudentsCsv = () => { + const rows = inquiries.map((item) => ({ + leadCode: item.leadCode, + studentName: item.studentName, + guardianName: item.guardianName, + gradeLevel: item.gradeLevel, + whatsappNumber: item.whatsappNumber, + status: statusLabels[item.status] || item.status, + referral: item.referralName || referralLabels[item.referralSource] || '', + createdAt: formatDate(item.createdAt), + })); + + downloadCsv('students-export.csv', rows); + }; + + const handleAssetsSelected = (event: React.ChangeEvent) => { + const names = Array.from(event.target.files || []).map((file) => file.name); + setAssetNames(names); + }; + + const copyWhatsappMessage = async () => { + try { + await navigator.clipboard.writeText(messageTemplate); + setCopiedMessage('تم نسخ الرسالة بنجاح.'); + window.setTimeout(() => setCopiedMessage(''), 2500); + } catch (error) { + console.error('Failed to copy WhatsApp message:', error); + setCopiedMessage('تعذر نسخ الرسالة من المتصفح الحالي.'); + } + }; + + const whatsappLaunchLink = React.useMemo(() => { + const raw = schoolProfile.whatsappNumber || inquiries[0]?.whatsappNumber || ''; + const cleaned = raw.replace(/[^\d+]/g, ''); + const text = encodeURIComponent(messageTemplate); + + if (!cleaned) { + return ''; + } + + return `https://wa.me/${cleaned}?text=${text}`; + }, [inquiries, messageTemplate, schoolProfile.whatsappNumber]); + + const analyticsRecommendations = React.useMemo(() => { + const topReferral = referralBreakdown[0]?.label || 'القنوات الحالية'; + const lines = [ + `أفضل مصدر حالي للتسجيلات الأولية: ${topReferral}.`, + `معدل التحويل الحالي من الطلبات إلى التسجيلات: ${conversionRate}% تقريبًا.`, + responseRate < 60 + ? 'يوصى برفع سرعة المتابعة مع أولياء الأمور خلال أول 30 دقيقة من وصول الطلب.' + : 'الاستجابة الأولية جيدة؛ ركز الآن على تحسين جودة الرسائل والزيارات المجدولة.', + summary.referrals < Math.max(2, Math.ceil(summary.total * 0.2)) + ? 'أنشئ عرض إحالة بسيط لأولياء الأمور الحاليين لرفع الإحالات.' + : 'استمر في تنشيط برنامج الإحالات لأنه يحقق أثرًا واضحًا.', + ]; + + return lines; + }, [conversionRate, referralBreakdown, responseRate, summary.referrals, summary.total]); + + const topStudentsList = inquiries.slice(0, 8); + const selectedInquiry = inquiries.find((item) => item.id === selectedInquiryId) || null; return ( <> - {getPageTitle('School Marketing')} + {getPageTitle('استوديو التسويق المدرسي')} + -
- -
- {currentUser?.firstName ? `مرحباً ${currentUser.firstName}` : 'متابعة يومية للطلاب المحتملين'} -
+
+ + {''} - -
-
-
- - متابعة واتساب + إحالات + مواعيد عرض + +
+
+
+ + نظام محلي لإدارة التسويق المدرسي بالذكاء الاصطناعي
-
-

من أول رسالة إلى القبول النهائي

-

- هذه أول نسخة عملية لمسار القبول: استلام الطلب من الصفحة العامة، تجميع الإحالات، جدولة العرض، - وتوثيق المتابعة في مكان واحد. -

+

{schoolProfile.schoolName}

+

{schoolProfile.tagline}

+

+ منصة مكتبية عربية موحدة لإدارة الحملات، متابعة التسجيلات، بناء الرسائل والإعلانات، وتحويل البيانات إلى قرارات أسرع. +

+
+
+
إجمالي الطلبات
+
{summary.total}
+
+
+
معدل التحويل
+
{conversionRate}%
+
+
+
الإحالات النشطة
+
{summary.referrals}
+
-
-
-
الطلاب المحتملون اليوم
-
{summary.newLeads}
+ +
+
+
مدير الجلسة
+
+ {currentUser?.firstName ? `${currentUser.firstName} ${currentUser.lastName || ''}`.trim() : 'فريق القبول والتسويق'} +
+
مدينة التشغيل: {schoolProfile.city}
-
-
المسجلون فعلياً
-
{summary.enrolled}
-
-
- استخدم البحث والتصفية على اليسار، ثم حدّث حالة الطالب وأضف رابط الفيديو أو موعد العرض من لوحة التفاصيل. +
+ generateWithAi('automation')} + /> +
-
- {stats.map((item) => ( - -
-
-
{item.label}
-
{item.value}
-
-
- -
-
-
+
+ {tabItems.map((tab) => ( + ))}
-
- -
-
-
- - setSearch(event.target.value)} - placeholder="اسم الطالب أو ولي الأمر أو رقم واتساب" - /> - -
-
- - - -
-
+ {workspaceError ? ( + +
{workspaceError}
+
+ ) : null} - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - - {loading ? ( -
- جاري تحميل الطلبات... -
- ) : null} - - {!loading && !inquiries.length ? ( -
- لا توجد طلبات تطابق الفلتر الحالي. جرّب إزالة البحث أو اطلب تسجيل طالب جديد من الصفحة الرئيسية. -
- ) : null} - -
- {inquiries.map((item) => { - const active = item.id === selectedId; - return ( - - ); - })} + )) :
لا توجد قنوات مسجلة بعد.
} +
+ + + +
+

المواعيد القادمة

+

زيارات ومكالمات وعروض تعريفية مجدولة.

+
+
+ {upcomingMeetings.length ? upcomingMeetings.map((item) => ( +
+
{item.studentName}
+
{item.guardianName}
+
{formatDate(item.meetingAt)}
+
+ )) :
لم يتم تحديد مواعيد بعد.
} +
+
- +
+ )} - - {!selectedInquiry || detailLoading ? ( -
- {detailLoading ? 'جاري تحميل التفاصيل...' : 'اختر طالباً من القائمة لعرض بطاقة المتابعة'} -
- ) : ( -
-
+ {activeTab === 'campaigns' && ( +
+
+ +
-
- - {selectedInquiry.leadCode} -
-

{selectedInquiry.studentName}

-

ولي الأمر: {selectedInquiry.guardianName}

-
-
- { - if (selectedWhatsappLink) { - window.open(selectedWhatsappLink, '_blank', 'noopener,noreferrer'); - } - }} - /> - { - if (formState.demoVideoUrl) { - window.open(formState.demoVideoUrl, '_blank', 'noopener,noreferrer'); - } - }} - disabled={!formState.demoVideoUrl} - /> -
-
- -
-
-
رقم واتساب
-
{selectedInquiry.whatsappNumber}
-
-
-
المرحلة الدراسية
-
{selectedInquiry.gradeLevel}
-
-
-
القسم / البرنامج
-
{selectedInquiry.interestTrack || 'لم يحدد بعد'}
-
-
-
مصدر الإحالة
-
- {referralLabels[selectedInquiry.referralSource] || 'أخرى'} - {selectedInquiry.referralName ? ` — ${selectedInquiry.referralName}` : ''} -
-
-
- -
-
-
أفضل وقت للتواصل
-
- {selectedInquiry.preferredContactTime || 'غير محدد'} -
-
-
-
آخر تواصل
-
- {formatDate(selectedInquiry.lastContactedAt)} -
-
-
- -
-
ملاحظات ولي الأمر
-
- {selectedInquiry.notes || 'لا توجد ملاحظات مضافة من النموذج.'} -
-
- -
- - - - - - setFormState((current) => ({ ...current, meetingAt: event.target.value }))} - /> - -
- - - setFormState((current) => ({ ...current, demoVideoUrl: event.target.value }))} - placeholder="https://..." - /> - - - -