CRM System Development (Web Interface)
CRM system in most cases — not "customer relationship management" in marketing sense, but specific tool: task queue, customer card, deal pipeline, communication history. Web interface development task — implement exactly the feature set specific business needs, without extra modules, settings clutter, and license limits.
What Includes in Typical CRM Interface
Minimal entity set for most B2B companies:
- Contacts — individuals with interaction history
- Companies — legal entities to which contacts linked
- Deals — potential and active sales with statuses
- Tasks — assignments with deadlines linked to deals/contacts
- Activities — calls, emails, meetings (communication log)
- Pipeline — visual Kanban or Pipeline with status columns
Additionally depending on specifics: price lists and commercial proposals, telephony integration, mailing module, manager reports.
Tech Stack
For CRM web interface optimal SPA or SSR app with reactive UI:
Backend:
- Laravel / Node.js (NestJS) as API
- PostgreSQL — main database
- Redis — cache and event queues (calls, notifications)
- WebSocket (Laravel Echo + Pusher / Socket.io) — real-time updates
Frontend:
- React + TypeScript
- React Query for server state
- Zustand or Redux Toolkit for global UI state
- React Hook Form + Zod for forms
- TanStack Table for tables with filtering/sorting
- @dnd-kit for drag-and-drop pipeline
Database Structure
CREATE TABLE contacts (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT REFERENCES companies(id),
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
source VARCHAR(64), -- where came from
responsible_id BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE deals (
id BIGSERIAL PRIMARY KEY,
contact_id BIGINT REFERENCES contacts(id),
company_id BIGINT REFERENCES companies(id),
title VARCHAR(255) NOT NULL,
amount DECIMAL(14,2),
currency CHAR(3) DEFAULT 'RUB',
stage_id BIGINT REFERENCES pipeline_stages(id),
responsible_id BIGINT REFERENCES users(id),
closed_at DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE pipeline_stages (
id BIGSERIAL PRIMARY KEY,
pipeline_id BIGINT REFERENCES pipelines(id),
name VARCHAR(128) NOT NULL,
sort_order INT DEFAULT 0,
is_won BOOLEAN DEFAULT FALSE,
is_lost BOOLEAN DEFAULT FALSE
);
CREATE TABLE activities (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(32) NOT NULL, -- 'contact', 'deal', 'company'
entity_id BIGINT NOT NULL,
type VARCHAR(32) NOT NULL, -- 'call', 'email', 'meeting', 'note'
body TEXT,
user_id BIGINT REFERENCES users(id),
happened_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON activities(entity_type, entity_id);
API Design
RESTful JSON API with resource-oriented structure:
GET /api/deals?stage_id=2&responsible_id=5&page=1&per_page=50
POST /api/deals
PATCH /api/deals/{id}
DELETE /api/deals/{id}
POST /api/deals/{id}/move # stage change
POST /api/activities # add activity to any entity
GET /api/contacts/{id}/timeline # interaction chronology
PATCH response example on stage change:
{
"id": 1042,
"stage_id": 4,
"stage": { "id": 4, "name": "Negotiation" },
"updated_at": "2025-03-15T14:22:00Z",
"activity": {
"id": 3891,
"type": "stage_change",
"body": "Stage changed: Qualification → Negotiation",
"user_id": 12
}
}
Pipeline: Drag-and-Drop Kanban
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
const Pipeline: React.FC<{ stages: Stage[]; deals: Deal[] }> = ({ stages, deals }) => {
const moveDeal = useMutation({
mutationFn: ({ dealId, stageId }: { dealId: number; stageId: number }) =>
api.patch(`/deals/${dealId}/move`, { stage_id: stageId }),
onMutate: async ({ dealId, stageId }) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: ['deals'] });
const prev = queryClient.getQueryData(['deals']);
queryClient.setQueryData(['deals'], (old: Deal[]) =>
old.map(d => d.id === dealId ? { ...d, stage_id: stageId } : d)
);
return { prev };
},
onError: (_, __, context) => {
queryClient.setQueryData(['deals'], context?.prev);
},
});
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
moveDeal.mutate({ dealId: Number(active.id), stageId: Number(over.id) });
};
return (
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<div className="flex gap-4 overflow-x-auto p-4">
{stages.map(stage => (
<KanbanColumn
key={stage.id}
stage={stage}
deals={deals.filter(d => d.stage_id === stage.id)}
/>
))}
</div>
</DndContext>
);
};
Optimistic update important — user sees card on new position instantly, without waiting for server response.
Activity Timeline
Interaction chronology — key CRM part. Implements as polymorphic feed:
type ActivityItem =
| { type: 'call'; duration: number; result: string }
| { type: 'email'; subject: string; direction: 'in' | 'out' }
| { type: 'note'; body: string }
| { type: 'stage_change'; from: string; to: string };
const ActivityFeed: React.FC<{ entityType: string; entityId: number }> = (props) => {
const { data } = useQuery({
queryKey: ['timeline', props.entityType, props.entityId],
queryFn: () => api.get(`/${props.entityType}s/${props.entityId}/timeline`),
});
return (
<div className="space-y-3">
<AddActivityForm entityType={props.entityType} entityId={props.entityId} />
{data?.items.map(item => (
<ActivityCard key={item.id} item={item} />
))}
</div>
);
};
Access Rights
CRM requires granular rights: manager sees only own clients, lead — all in own department. Implements via Policy classes:
// Laravel Policy
class DealPolicy {
public function viewAny(User $user): bool {
return $user->hasPermission('deals.view');
}
public function view(User $user, Deal $deal): bool {
if ($user->hasRole('admin')) return true;
if ($user->hasRole('team_lead')) {
return $deal->responsible->team_id === $user->team_id;
}
return $deal->responsible_id === $user->id;
}
public function update(User $user, Deal $deal): bool {
return $user->hasRole('admin') || $deal->responsible_id === $user->id;
}
}
Scope at Eloquent level:
class Deal extends Model {
public function scopeVisibleTo(Builder $query, User $user): Builder {
if ($user->hasRole('admin')) return $query;
if ($user->hasRole('team_lead')) {
return $query->whereHas('responsible', fn($q) =>
$q->where('team_id', $user->team_id)
);
}
return $query->where('responsible_id', $user->id);
}
}
Real-time Notifications
When deal assigned to other manager or task deadline approaching — notification arrives without page refresh:
// Frontend: channel subscription
import Echo from 'laravel-echo';
const echo = new Echo({ broadcaster: 'pusher', ... });
echo.private(`user.${currentUser.id}`).listen('DealAssigned', (event) => {
toast.info(`Deal assigned: ${event.deal.title}`);
queryClient.invalidateQueries({ queryKey: ['deals'] });
});
// Backend: event
class DealAssigned implements ShouldBroadcast {
public function broadcastOn(): PrivateChannel {
return new PrivateChannel('user.' . $this->deal->responsible_id);
}
}
Implementation Timeline
MVP with pipeline, contact/deal cards, tasks, basic analytics: 4–6 weeks. Adding integrations (telephony, email, messengers), advanced reports, roles, multilingual: plus 3–4 weeks. Mobile version (PWA or React Native): plus 3–4 weeks.
Most time spent not on code, but business logic: required fields, allowed stage transitions, data visibility, critical notifications.







