هذا ليس منشورًا "نحن أذكى من Monday.com". إنه منشور "كان لدينا احتياجات محددة جدًا، وكومة كنا نعرفها بالفعل، وأسبوع نهاية أسبوع." إليك القصة الكاملة لبناء لوحة kanban CRM مخصصة باستخدام Astro و Supabase وجرعة صحية من الازدراء تجاه SaaS bloat.

جدول المحتويات

داخل لوحة CRM Kanban الخاصة بنا: لماذا أعدنا بناء Monday.com في Astro + Supabase

لماذا توقف Monday.com عن العمل لنا

دعني أكون محددًا بشأن إحباطاتنا، لأن الشكاوى الغامضة حول أدوات SaaS غير مفيدة.

المشكلة 1: كان نموذج البيانات يقاومنا. يفكر Monday.com في "اللوحات" و "العناصر" بـ "الأعمدة". تفكر وكالتنا في الصفقات والجهات الاتصال والمشاريع — ثلاث كيانات مختلفة مع علاقات بينها. احتجنا إلى صفقة للإشارة إلى جهة اتصال، ومشروع للإشارة إلى صفقة. يمكن لـ Monday.com فعل هذا نوعًا ما باستخدام الأعمدة المرتبطة، لكنها محرجة. في كل مرة ينشئ شخص صفقة جديدة، يجب عليه ربطها يدويًا بجهة الاتصال الصحيحة. الناس ينسون. تصبح البيانات فوضوية.

المشكلة 2: لم يتمكن عرض kanban من فعل ما أردناه. احتجنا إلى رؤية الصفقات مجمعة حسب المرحلة والمشفرة بالألوان حسب المصدر (إحالة، عضوي، استقطاب). يسمح عرض kanban في Monday.com بالتجميع حسب عمود حالة واحد فقط. هذا كل شيء. لا يمكنك طبقة بعد بُعد بصري ثانٍ دون اختراقها باستخدام اتفاقيات التسمية.

المشكلة 3: السرعة. هذا شيء شخصي، لكن Monday.com كان يشعر ببطء لما كنا نفعله. انقر على صفقة، انتظر لوحة جانبية للتحميل، قم بالتمرير عبر الحقول التي لا نستخدمها، ابحث عن الملاحظة التي نحتاجها. كل تفاعل كان لديه كمية كافية من الكمون لتشعر بالاحتكاك.

المشكلة 4: مسار التكلفة. عند 48 دولارًا شهريًا لثلاثة أشخاص، لا يكون مكلفًا. لكننا كنا نراقب عضو فريق رابع، وتقفز تسعير Monday.com إلى 60 دولارًا شهريًا لخطة Pro مع 5 مقاعد (لا يمكنك شراء 4). هذا 720 دولارًا سنويًا لأداة كنا نشتكي منها بنشاط.

نقطة التحول

المشغل الفعلي كان محرجًا بشكل مملّ. أرسل عميل محتمل بريدًا إلينا، وردّ عليه عضوا فريق لأن أيًا منهما لم يتمكن من معرفة من قام بـ "المطالبة" باللقاء من Monday.com. نظام الإخطارات لم يسطه بوضوح كافٍ، وحلنا الضعيف بإضافة نفسك إلى عمود "الأشخاص" لم يكن موثوقًا. هذا عندما فتحت VS Code بدلاً من Monday.com.

ما الذي احتجنا إليه فعلاً

قبل كتابة أي كود، أمضينا حوالي ساعة في تحديد بالضبط ما احتاجته CRM الخاصة بنا للقيام به. ليس ما يكون جميلًا. ما كان ضروريًا بالفعل.

إليك القائمة:

  1. لوحة kanban مع أعمدة لمراحل الصفقة: Lead → Contacted → Proposal → Negotiation → Won → Lost
  2. بطاقات الصفقة تعرض: اسم جهة الاتصال، قيمة الصفقة، علامة المصدر (ملونة)، عضو الفريق المعين، الأيام في المرحلة الحالية
  3. السحب والإفلات بين الأعمدة مع الإصرار الفوري
  4. عرض تفاصيل الصفقة مع ملاحظات (markdown)، معلومات جهة الاتصال، وسجل نشاط بسيط
  5. المزامنة في الوقت الفعلي بحيث يرى شخصان ينظران إلى اللوحة نفس الحالة
  6. قاعدة بيانات للاتصالات مع معلومات أساسية (الاسم والبريد الإلكتروني والشركة والملاحظات)
  7. مصادقة بسيطة — فقط فريقنا، لا يوجد وصول عام

هذا كل شيء. لا توجد مخططات Gantt. لا تتبع الوقت. لا محرك الأتمتة. لا 47 نوعًا مختلفًا من الأعمدة. فقط لوحة kanban مدعومة بقاعدة بيانات حقيقية بها علاقات حقيقية.

اختيار المكدس: Astro + Supabase

نحن متجر تطوير Astro، لذلك كان Astro نقطة البداية الواضحة. لكن يستحق شرح سبب أهميته بالفعل هنا، لأن سمعة Astro باعتبارها "مولد الموقع الثابت" تقلل من قيمتها بشكل كبير.

منذ Astro 4.x (والآن 5.x في 2025)، العرض من جانب الخادم مع المسارات حسب الطلب هو ميزة من الدرجة الأولى. يمكنك بناء تطبيقات ديناميكية كاملة. نستخدم وضع العرض الهجين في Astro: يتم عرض معظم الصفحات من جانب الخادم عند الطلب، لكن يمكننا لا تزال في وضع حر قبل عرض أشياء مثل صفحة تسجيل الدخول.

لوحة kanban التفاعلية نفسها، نستخدم جزيرة React. هذه هي ميزة Astro القاتلة للتطبيقات مثل هذه — يتم عرض غلاف التطبيق (nav، التخطيط، فحوصات المصادقة) من جانب الخادم مع صفر JS، وتركيب لوحة kanban كجزيرة تفاعلية واحدة بـ client:load.

كان Supabase هو خيار قاعدة البيانات لعدة أسباب:

الميزة لماذا كان الأمر مهمًا
Postgres تحت الغطاء قاعدة بيانات علائقية حقيقية، مفاتيح أجنبية حقيقية، استعلامات حقيقية
اشتراكات Realtime دعم WebSocket مدمج للتحديثات المباشرة
أمان مستوى الصف (RLS) قواعد المصادقة على مستوى قاعدة البيانات، وليس فقط مستوى التطبيق
مكتبة عميل JS واجهة برمجية نظيفة، دعم TypeScript جيد
الطبقة المجانية استخدامنا يناسب بشكل مريح خطة Supabase المجانية
خيار الاستضافة الذاتية إذا تجاوزنا الطبقة المجانية، يمكننا تشغيله بأنفسنا

اعتبرنا موجزًا عن خيارات أخرى:

الخيار لماذا مررنا
Firebase / Firestore NoSQL يجعل البيانات العلائقية محرجة. تم حرقها من قبل.
PlanetScale عظيم، لكن لا توجد realtime مدمجة. سيحتاج حل WebSocket منفصل.
Neon + Prisma مزيج صلب، لكن Supabase يعطينا المصادقة + realtime + DB في واحد.
البناء على Next.js نحن نعرف Next.js جيدًا (نبني معه بانتظام)، لكن بالنسبة لأداة داخلية، معمارية جزيرة Astro تعني أقل من JS من جانب العميل للأجزاء غير التفاعلية.

داخل لوحة CRM Kanban الخاصة بنا: لماذا أعدنا بناء Monday.com في Astro + Supabase - الهندسة المعمارية

تصميم قاعدة البيانات: الحفاظ على البساطة

المخطط له أربع جداول. هذا كل شيء.

-- Contacts: الأشخاص والشركات التي نتحدث إليها
create table contacts (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  email text,
  company text,
  phone text,
  notes text,
  created_at timestamptz default now()
);

-- Deals: عناصر خط الأنابيب على لوحة kanban الخاصة بنا
create table deals (
  id uuid default gen_random_uuid() primary key,
  contact_id uuid references contacts(id) on delete set null,
  title text not null,
  value integer, -- تخزين بالسنتات
  stage text not null default 'lead'
    check (stage in ('lead', 'contacted', 'proposal', 'negotiation', 'won', 'lost')),
  source text
    check (source in ('referral', 'organic', 'outbound', 'repeat', 'other')),
  assigned_to uuid references auth.users(id),
  position integer not null default 0, -- للطلب داخل العمود
  stage_entered_at timestamptz default now(),
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Activity log: سجل بسيط للإضافة فقط
create table activities (
  id uuid default gen_random_uuid() primary key,
  deal_id uuid references deals(id) on delete cascade,
  user_id uuid references auth.users(id),
  action text not null, -- 'stage_change', 'note', 'created', الخ.
  details jsonb,
  created_at timestamptz default now()
);

-- Deal notes: ملاحظات markdown المرفقة بالصفقات
create table deal_notes (
  id uuid default gen_random_uuid() primary key,
  deal_id uuid references deals(id) on delete cascade,
  user_id uuid references auth.users(id),
  content text not null,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

حقل stage_entered_at على الصفقات هو أحد قراراتي الصغيرة المفضلة. في كل مرة تنتقل صفقة إلى مرحلة جديدة، نحدث طابع زمني هذا. يسمح لنا بحساب "الأيام في المرحلة الحالية" دون الاستعلام عن سجل النشاط. بسيط وسريع ومفيد.

يتعامل حقل position مع الترتيب في أعمدة kanban. عندما تسحب بطاقة بين اثنتين آخرتين، نحسب قيمة موضع جديدة. نستخدم تباعد عدد صحيح (تزداد المواضع بمقدار 1000) لذا نادراً ما نحتاج إلى إعادة التوازن.

بناء لوحة Kanban

لوحة kanban هي مكون React مركب كجزيرة Astro. استخدمنا @dnd-kit/core للسحب والإفلات لأنها أكثر مكتبة DnD في نظام React البيئي يمكن الوصول إليها والمدعومة بشكل جيد اعتبارًا من 2025.

إليك البنية المبسطة:

// src/components/KanbanBoard.tsx
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { useState } from 'react';
import { KanbanColumn } from './KanbanColumn';
import { DealCard } from './DealCard';
import { useDeals } from '../hooks/useDeals';
import { useRealtimeDeals } from '../hooks/useRealtimeDeals';

const STAGES = ['lead', 'contacted', 'proposal', 'negotiation', 'won', 'lost'];

const SOURCE_COLORS: Record<string, string> = {
  referral: '#10b981',
  organic: '#3b82f6',
  outbound: '#f59e0b',
  repeat: '#8b5cf6',
  other: '#6b7280',
};

export function KanbanBoard() {
  const { deals, moveDeal } = useDeals();
  const [activeId, setActiveId] = useState<string | null>(null);

  // الاشتراك في التغييرات في الوقت الفعلي
  useRealtimeDeals();

  const handleDragEnd = async (event) => {
    const { active, over } = event;
    if (!over) return;

    const dealId = active.id;
    const newStage = over.data.current?.stage;
    const newPosition = over.data.current?.position;

    if (newStage) {
      await moveDeal(dealId, newStage, newPosition);
    }
    setActiveId(null);
  };

  return (
    <DndContext onDragStart={({ active }) => setActiveId(active.id)} onDragEnd={handleDragEnd}>
      <div className="kanban-grid">
        {STAGES.map((stage) => (
          <KanbanColumn
            key={stage}
            stage={stage}
            deals={deals.filter((d) => d.stage === stage)}
            sourceColors={SOURCE_COLORS}
          />
        ))}
      </div>
      <DragOverlay>
        {activeId ? <DealCard deal={deals.find((d) => d.id === activeId)} overlay /> : null}
      </DragOverlay>
    </DndContext>
  );
}

تقوم دالة moveDeal بتحديث متفائل — تحدّث الحالة المحلية على الفور، ثم ترسل التحديث إلى Supabase. إذا فشل تحديث قاعدة البيانات، فسيتم التراجع عنه. هذا يجعل اللوحة تشعر بالفورية.

const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
  // تحديث متفائل
  setDeals((prev) =>
    prev.map((d) =>
      d.id === dealId
        ? { ...d, stage: newStage, position: newPosition, stage_entered_at: new Date().toISOString() }
        : d
    )
  );

  const { error } = await supabase
    .from('deals')
    .update({
      stage: newStage,
      position: newPosition,
      stage_entered_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    })
    .eq('id', dealId);

  if (error) {
    // التراجع — إعادة جلب من الخادم
    await refreshDeals();
    toast.error('Failed to move deal');
  }

  // تسجيل النشاط
  await supabase.from('activities').insert({
    deal_id: dealId,
    user_id: currentUser.id,
    action: 'stage_change',
    details: { from: previousStage, to: newStage },
  });
};

صفحة Astro التي تستضيفها بسيطة جدًا:

---
// src/pages/board.astro
import Layout from '../layouts/App.astro';
import { KanbanBoard } from '../components/KanbanBoard';
import { getSession } from '../lib/auth';

const session = await getSession(Astro.request);
if (!session) return Astro.redirect('/login');
---

<Layout title="Deal Board">
  <KanbanBoard client:load />
</Layout>

هذا التوجيه client:load يقوم بالعمل الثقيل. التخطيط والملاح والعناصر الأخرى في الصفحة كلها HTML مُعاد من جانب الخادم. تركيب لوحة kanban نفسها على العميل. هذا يعني أن تحميل الصفحة الأولية سريع — يحصل المتصفح على HTML على الفور، وتبدأ لوحة kanban التفاعلية مباشرة بعد ذلك.

التحديثات في الوقت الفعلي مع Supabase Realtime

كانت هذه الميزة التي جعلت Supabase الفائز الواضح لهذا المشروع. عندما يحرك أحد أعضاء الفريق صفقة، يرى الأعضاء الآخرون في الفريق تتحرك في الوقت الفعلي. لا حاجة إلى تحديث.

// src/hooks/useRealtimeDeals.ts
import { useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { useDealsStore } from '../stores/deals';

export function useRealtimeDeals() {
  const { updateDealLocally, addDealLocally, removeDealLocally } = useDealsStore();

  useEffect(() => {
    const channel = supabase
      .channel('deals-changes')
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'deals' },
        (payload) => {
          switch (payload.eventType) {
            case 'UPDATE':
              updateDealLocally(payload.new);
              break;
            case 'INSERT':
              addDealLocally(payload.new);
              break;
            case 'DELETE':
              removeDealLocally(payload.old.id);
              break;
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);
}

حيث واحد: عندما تنقل صفقة، تحصل على التغيير الخاص بك مرة أخرى عبر اشتراك realtime. إذا لم تكن حذراً، فهذا يسبب خللاً بصريًا حيث تقفز البطاقة. نتعامل مع هذا بوضع علامة على التحديثات المتفائلة بطابع زمني وتجاهل أحداث realtime التي تطابق التغييرات المحلية الأخيرة. إنها بضعة أسطر إضافية من الكود لكنها تجعل UX تشعر بالصلابة.

المصادقة وأمان مستوى الصف

نظرًا لأن هذه أداة داخلية، فإن المصادقة بسيطة. نستخدم Supabase Auth مع البريد الإلكتروني وكلمة المرور. ثلاثة حسابات. لا يوجد تدفق للتسجيل — لقد أنشأنا الحسابات يدويًا في لوحة معلومات Supabase.

أمان مستوى الصف هو حيث تصبح الأمور مثيرة للاهتمام. على الرغم من أن هذه أداة داخلية، فإن RLS تعني أن قاعدة البيانات الخاصة بنا لن تسرب البيانات بالخطأ حتى لو أفسدنا كود التطبيق.

-- يمكن فقط للمستخدمين المصرح لهم رؤية الصفقات
alter table deals enable row level security;

create policy "المستخدمون المصرح لهم يمكنهم قراءة جميع الصفقات"
  on deals for select
  to authenticated
  using (true);

create policy "المستخدمون المصرح لهم يمكنهم إدراج الصفقات"
  on deals for insert
  to authenticated
  with check (true);

create policy "المستخدمون المصرح لهم يمكنهم تحديث الصفقات"
  on deals for update
  to authenticated
  using (true);

create policy "المستخدمون المصرح لهم يمكنهم حذف الصفقات"
  on deals for delete
  to authenticated
  using (true);

نعم، هذه السياسات تصريحية — أي مستخدم مصرح لهم يمكنه القيام بأي شيء. بالنسبة لفريق من ثلاثة أشخاص، لا بأس. إذا كبرنا إلى حجم حيث نحتاج إلى أذونات قائمة على الأدوار، فإن بنية الأساس RLS موجودة بالفعل. نحن فقط سننشر السياسات.

النشر وتكاليف الاستضافة

هنا الجزء الممتع. دعنا نتحدث عن المال.

الخدمة الخطة التكلفة الشهرية
Supabase الطبقة المجانية 0 دولار
Vercel (استضافة Astro SSR) خطة Pro (كان لدينا بالفعل) 0 دولار إضافي
النطاق نطاق فرعي للنطاق الموجود 0 دولار
الإجمالي 0 دولار/شهر

كنا بالفعل على خطة Pro الخاصة بـ Vercel لمشاريع العملاء، لذا نشر تطبيق SSR واحد آخر لا يكلفنا شيئًا إضافيًا. تعطينا الطبقة المجانية من Supabase 500 ميجابايت من تخزين قاعدة البيانات، و 50000 مستخدم نشط شهري (لدينا 3)، واتصالات realtime. نستخدم ربما 1% من سعة الطبقة المجانية.

قارن هذا بـ Monday.com:

Monday.com CRM مخصص لدينا
التكلفة الشهرية 48 دولارًا (3 مقاعد، Pro) 0 دولار
التكلفة السنوية 576 دولارًا 0 دولار
وقت البناء 0 ساعات ~20 ساعة
الصيانة 0 ساعات/شهر ~1 ساعة/شهر

بمعدل الساعة الداخلي لدينا، 20 ساعة من وقت التطوير تستحق أكثر بكثير من 576 دولارًا سنويًا. لكن هذه الرياضيات تفتقد النقطة. بنينا هذا جزئيًا لأننا أردنا ذلك، وجزئيًا لأنها أداة أفضل لسير العمل المحدد لدينا، وجزئيًا لأن تلك الساعات العشرين علمتنا أشياء استخدمناها لاحقًا في مشاريع العملاء. لقد طبقنا منذ ذلك الحين معمارية Astro + Supabase مماثلة لـ التطبيقات المدعومة بـ headless CMS التي نبنيها للعملاء.

ما سنفعله بشكل مختلف

لقد مر حوالي أربعة أشهر منذ شحنا v1. إليك ما سأغيره:

استخدم Zustand من اليوم الأول

بدأنا باستخدام useState و useContext المدمج في React لإدارة الحالة. بحلول الوقت الذي أضفنا فيه مزامنة realtime وتحديثات متفائلة ومنطق التراجع، كانت كود إدارة الحالة متشابكًا. هاجرنا إلى Zustand بعد أسبوعين. كان يجب أن نبدأ هناك.

أضف البحث في وقت أقرب

لم نبني البحث حتى الأسبوع الثالث، وتلك الأسبوع الثلاثة من المسح اليدوي للأعمدة بحثًا عن صفقة محددة كانت مزعجة. كان استعلام بسيط ilike على Supabase سيستغرق 30 دقيقة لتنفيذ.

اختصارات لوحة المفاتيح

لم نضيفها بعد، لكننا نريدها. اضغط على N لإنشاء صفقة جديدة، / للبحث، 1-6 للتصفية حسب المرحلة. أشياء صغيرة تضيف ما يصل عندما تكون في الأداة عدة مرات في اليوم.

طريقة عرض أفضل على الجوال

لوحة kanban تعمل على الجوال، تقنياً. لكن ستة أعمدة لا تناسب شاشة الهاتف. نحن بحاجة إلى عرض قائمة للجوال. لم نحدد الأولويات لأننا نتحقق نادراً من CRM على هواتفنا، لكن سيكون لطيفاً.

الأسئلة الشائعة

كم من الوقت استغرق بناء لوحة kanban CRM؟ استغرقت النسخة الأولى القابلة للاستخدام حوالي 20 ساعة موزعة على نهاية أسبوع وبعض المساء. هذا أعطانا لوحة kanban وتفاصيل الصفقات والسحب والإفلات والمصادقة الأساسية. ربما أمضينا 10 ساعات أخرى منذ ذلك الحين على تحسينات مثل البحث وأنماط الجوال الأفضل وإصلاح الأخطاء.

لماذا Astro بدلاً من Next.js لتطبيق ديناميكي؟ معمارية جزيرة Astro تعني أن الأجزاء غير التفاعلية من تطبيقنا (التخطيط والملاح والصفحات الثابتة) شحن صفر JavaScript. لوحة kanban نفسها هي جزيرة React يتم ترطيبها على التحميل. بالنسبة لأداة داخلية حيث تركيز السطح التفاعلي على مكون واحد، هذا مناسب رائع. نستخدم Next.js لمشاريع العملاء حيث التفاعل أكثر توزيعاً عبر الصفحات.

هل الطبقة المجانية من Supabase كافية حقاً لـ CRM؟ لفريق صغير، بالتأكيد. لدينا ربما 200 صفقة و 150 جهة اتصال وبضعة آلاف من إدخالات سجل النشاط. هذا كيلوبايتات من البيانات. تعطيك الطبقة المجانية من Supabase 500 ميجابايت من التخزين، والتي لن نصل إليها لسنوات. حد اتصالات realtime أيضًا كريم — تحصل على ما يصل إلى 200 اتصال متزامن في الخطة المجانية.

ماذا عن النسخ الاحتياطية؟ تتضمن Supabase نسخ احتياطية يومية في خطة Pro (25 دولارًا/شهر)، لكننا على الطبقة المجانية. نقوم بـ pg_dump أسبوعي عبر وظيفة cron على VPS بقيمة 5 دولارات/شهر كان لدينا بالفعل. ليس أنيقاً، لكنه يعمل. لدينا أيضًا نسخة نسخ مشروع Supabase يمكننا الاستعادة إليها إذا حدث شيء خاطئ.

هل يمكن لهذا النهج أن يعمل لفريق أكبر من 3 أشخاص؟ حتى ربما 10-15 شخصًا، أعتقد أن هذا سيعمل بشكل جيد مع سياسات RLS أكثر إحكاماً وبعض منطق واجهة المستخدم القائم على الأدوار. وراء ذلك، ستبدأ في رغبة ميزات مثل الأتمتة وسير العمل المخصص والإبلاغ الذي سيستغرق جهداً هندسياً جاداً. في هذه النقطة، أداة CRM مخصصة منطقية أكثر — فقط ربما ليس Monday.com.

كيف يؤدي المزامنة الفعلية؟ Supabase Realtime يستخدم WebSockets تحت الغطاء، وبالنسبة لحالتنا (3 مستخدمين متزامنين، تحديثات منخفضة التكرار)، فهو فوري بشكل أساسي. قمنا بقياس الكمون من البداية إلى النهاية من مستخدم واحد يسحب بطاقة إلى مستخدم آخر يرى التحديث: عادة ما يكون 80-150ms. هذا أسرع مما يمكننا إدراكه.

هل اعتبرت بدائل CRM مفتوحة المصدر مثل Twenty أو Folk؟ نظرنا إلى Twenty (CRM مفتوح المصدر الذي تم إطلاقه في 2024) وهو رائع. لكنها CRM كاملة بميزات أكثر بكثير مما احتجناه، واستضافة ذاتية تتطلب أكثر بنية أساسية. كان هدفنا بناء بالضبط ما احتجناه ولا شيء أكثر. إذا كان Twenty موجوداً عندما بدأنا وكان له وضع kanban أبسط، ربما كنا ذهبنا بهذا الطريق.

هل ستبني أدوات داخلية مخصصة للعملاء أيضاً؟ لقد فعلنا، في الواقع. جاء العديد من العملاء إلينا بعد تجاوز أدوات مثل Monday.com و Notion و Airtable لسير عمل محدد. نحن عادة نبني هذه باستخدام Astro أو Next.js في الواجهة الأمامية و Supabase أو headless CMS في الخلفية. إذا كان هذا يبدو وكأنه شيء تحتاجه، نحتاج إلى التحدث.