← 返回部落格
·8 min 閱讀·專案紀錄

為接案者打造輕量 CRM:Next.js + NestJS 全端開發紀錄

記錄開發一套面向自由工作者的客戶關係管理系統的過程,涵蓋認證設計、Pipeline 看板、活動追蹤、以及從需求分析到部署的完整全端開發經驗。

CRMNext.jsNestJSPostgreSQL全端開發
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

為什麼需要自己的 CRM

Salesforce 太大、HubSpot 太多功能用不到、Excel 又太簡陋。作為自由工作者或小型工作室,你需要的其實很簡單:

  • 記錄客戶聯絡資訊
  • 追蹤每個案子的進度(從洽談到結案)
  • 留下每次溝通的紀錄
  • 看一眼就知道目前有多少案子在跑

市面上的 CRM 通常是為銷售團隊設計的,對一人或小團隊來說太 heavy。於是決定自己做一個。

功能規劃

核心模組

1. 客戶管理

  • 基本資料(姓名、公司、Email、電話、地址)
  • 分類(個人/企業/政府機關)
  • 來源標記(推薦/網路/活動)
  • 自訂標籤(支援多色標記)
  • 社群帳號(LINE、Facebook、Instagram)

2. 案件追蹤(Deal Pipeline)

  • 案件名稱、金額、預估成案日
  • 階段管理:潛在 → 聯繫中 → 提案 → 議價 → 成交/失敗
  • 每個階段自動帶入預設成案機率
  • 看板式拖拉介面

3. 活動紀錄

  • 類型:通話、Email、會議、筆記
  • 關聯到特定客戶或案件
  • 時間軸展示

4. Dashboard

  • 本月成交金額
  • 各階段案件數
  • 客戶來源分析
  • 近期活動摘要

技術選型

| 層級 | 技術 | 原因 | |------|------|------| | Frontend | Next.js 15 (App Router) | SSR + 良好的 DX | | UI | shadcn/ui + Tailwind CSS | 快速建立一致的介面 | | 拖拉 | @dnd-kit | 輕量且無障礙支援好 | | 圖表 | Recharts | React 生態最成熟的圖表庫 | | Backend | NestJS 11 | 模組化架構、適合 API | | ORM | Prisma 6 | 型別安全、migration 管理 | | DB | PostgreSQL | 可靠、功能豐富 | | Auth | JWT (Access + Refresh Token) | 無狀態認證 | | 部署 | Docker Compose | 一鍵啟動 |

認證設計

雙 Token 機制

  • Access Token:15 分鐘過期,存在記憶體
  • Refresh Token:7 天過期,存在 httpOnly cookie

為什麼不用單一 token?

短效的 access token 降低了 token 洩漏的風險。即使被截獲,15 分鐘後就失效。Refresh token 只在需要換新 access token 時使用,減少暴露機會。

// NestJS Auth Service
async login(email: string, password: string) {
  const user = await this.validateCredentials(email, password);

  const accessToken = this.jwtService.sign(
    { sub: user.id, email: user.email },
    { expiresIn: '15m' }
  );

  const refreshToken = this.jwtService.sign(
    { sub: user.id },
    { expiresIn: '7d', secret: this.refreshSecret }
  );

  return { accessToken, refreshToken };
}

async refresh(refreshToken: string) {
  const payload = this.jwtService.verify(refreshToken, {
    secret: this.refreshSecret,
  });

  // 發新的 token pair
  return this.generateTokens(payload.sub);
}

前端 Token 管理

// 自動在 access token 過期前刷新
async function fetchWithAuth(url: string, options?: RequestInit) {
  let token = getAccessToken();

  if (isTokenExpired(token)) {
    token = await refreshAccessToken();
  }

  return fetch(url, {
    ...options,
    headers: {
      ...options?.headers,
      Authorization: `Bearer ${token}`,
    },
  });
}

資料模型設計

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  clients   Client[]
  tags      Tag[]
  deals     Deal[]
}

model Client {
  id         Int       @id @default(autoincrement())
  userId     Int
  name       String
  company    String?
  email      String?
  phone      String?
  category   Category  @default(PERSONAL)
  source     String?
  deletedAt  DateTime? // 軟刪除
  tags       ClientTag[]
  deals      Deal[]
  activities Activity[]
  user       User      @relation(fields: [userId], references: [id])
}

model Deal {
  id              Int       @id @default(autoincrement())
  userId          Int
  clientId        Int
  title           String
  value           Int       // 案件金額
  stage           DealStage @default(LEAD)
  probability     Int       // 成案機率 0-100
  expectedClose   DateTime?
  activities      Activity[]
  client          Client    @relation(fields: [clientId], references: [id])
  user            User      @relation(fields: [userId], references: [id])
}

enum DealStage {
  LEAD         // 潛在客戶 (10%)
  CONTACT      // 聯繫中 (25%)
  PROPOSAL     // 已提案 (50%)
  NEGOTIATION  // 議價中 (75%)
  WON          // 成交 (100%)
  LOST         // 失敗 (0%)
}

多租戶隔離

每個 query 都帶 userId 條件,確保使用者只看到自己的資料:

async findAllClients(userId: number) {
  return this.prisma.client.findMany({
    where: {
      userId,
      deletedAt: null, // 排除軟刪除
    },
    include: { tags: { include: { tag: true } } },
    orderBy: { createdAt: 'desc' },
  });
}

不用多資料庫或 schema 隔離——對這個規模的應用,WHERE userId = ? 足夠。

Pipeline 看板實作

前端:@dnd-kit 拖拉

'use client';
import { DndContext, closestCorners } from '@dnd-kit/core';

function PipelineBoard({ deals }: { deals: Deal[] }) {
  const stages = ['LEAD', 'CONTACT', 'PROPOSAL', 'NEGOTIATION', 'WON'];

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

    const dealId = active.id;
    const newStage = over.id; // column id = stage name

    // Optimistic update
    updateDealLocally(dealId, newStage);

    // 同步到後端
    await updateDealStage(dealId, newStage);
  };

  return (
    <DndContext collisionDetection={closestCorners} onDragEnd={handleDragEnd}>
      <div className="flex gap-4 overflow-x-auto">
        {stages.map(stage => (
          <StageColumn
            key={stage}
            stage={stage}
            deals={deals.filter(d => d.stage === stage)}
          />
        ))}
      </div>
    </DndContext>
  );
}

階段變更自動更新機率

// Backend: 拖到新階段時自動更新 probability
async updateStage(dealId: number, userId: number, newStage: DealStage) {
  const defaultProbabilities = {
    LEAD: 10,
    CONTACT: 25,
    PROPOSAL: 50,
    NEGOTIATION: 75,
    WON: 100,
    LOST: 0,
  };

  return this.prisma.deal.update({
    where: { id: dealId, userId },
    data: {
      stage: newStage,
      probability: defaultProbabilities[newStage],
    },
  });
}

軟刪除策略

客戶資料用軟刪除(設 deletedAt),案件用硬刪除。原因:

  • 客戶軟刪除:客戶可能之後又回來合作,保留歷史紀錄有價值
  • 案件硬刪除:失敗的案件保留意義不大,直接刪乾淨
async softDeleteClient(id: number, userId: number) {
  return this.prisma.client.update({
    where: { id, userId },
    data: { deletedAt: new Date() },
  });
}

async restoreClient(id: number, userId: number) {
  return this.prisma.client.update({
    where: { id, userId },
    data: { deletedAt: null },
  });
}

Docker Compose 一鍵開發

services:
  api:
    build: ./apps/api
    ports: ["4000:4000"]
    depends_on:
      db: { condition: service_healthy }
    environment:
      DATABASE_URL: postgresql://user:pass@db:5432/crm

  web:
    build: ./apps/web
    ports: ["3000:3000"]
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:4000

  db:
    image: postgres:17-alpine
    healthcheck:
      test: ["CMD", "pg_isready"]
    volumes:
      - pgdata:/var/lib/postgresql/data

  # Migration 服務:跑完就退出
  migration:
    build: ./apps/api
    command: npx prisma migrate deploy
    depends_on:
      db: { condition: service_healthy }

migration 服務是一個跑完就退出的容器——確保 schema 是最新的,然後自動停止。

開發過程的取捨

不做的功能

  • Email 整合:不自動讀信箱,太侵入且技術複雜度高
  • 行事曆同步:可以手動記活動,不需要即時同步 Google Calendar
  • 多人協作:目標是個人或極小團隊,不做權限管理
  • 行動 App:RWD 就夠了

先做 MVP 再迭代

Sprint 1-2 先做到「能登入、能建客戶、能追案件」就上線。Dashboard 和匯出功能是之後的事。

結語

這個專案最有價值的經驗是「克制」——知道哪些功能不做比知道要做什麼更重要。一個功能完整但肥大的 CRM 沒人想用,一個只做好三件事的 CRM 反而能成為日常工具。

對自由工作者來說,CRM 不需要複雜。你需要的是一個「打開就能用、關掉不會忘」的簡單系統。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →