commit 1a9ddce26bfb92a524c6e770e7b348b9ff308eaa Author: kang Date: Sat Apr 25 21:52:01 2026 +0800 init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20bc7ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# bootstrap-gitea-no-git +node_modules/ +.next/ diff --git a/.memory/worklog.json b/.memory/worklog.json new file mode 100644 index 0000000..046955d --- /dev/null +++ b/.memory/worklog.json @@ -0,0 +1,3 @@ +{ + "entries": [] +} diff --git a/.project.json b/.project.json new file mode 100644 index 0000000..2f013df --- /dev/null +++ b/.project.json @@ -0,0 +1,32 @@ +{ + "name": "Sales CRM 仪表板", + "description": "v0 生成的销售 CRM 系统,含仪表盘/联系人/商机/任务/集成/设置,支持中英文切换", + "status": "active", + "kind": "app", + "created": "2026-04-05", + "ports": [ + { + "port": 4440, + "label": "dev", + "fixed": true + } + ], + "stack": [ + "Next.js 15", + "React 19", + "shadcn/ui", + "Tailwind", + "Recharts" + ], + "urls": [ + { + "url": "http://localhost:4440", + "type": "app", + "label": "local" + } + ], + "worklog": { + "path": ".memory/worklog.json", + "auto": true + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ed99ce1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Sales CRM 仪表板 Agent Rules + +## Must Read First + +- `.project.json` 是机器真源:公网链接、快捷登录、凭证引用都以它为准 +- `RULES.md` 是人工规则和部署事实:启动命令、平台、域名、注意事项都写这里 +- 不允许编造不存在的域名、账号、密码;未知就保持空白并明确标记待补充 + +## Deployment Metadata Contract + +- 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json` +- `urls[]` 推荐显式写 `type`:`app`、`backend`、`docs`、`admin`、`repo` +- 项目专属的网页登录信息,如果允许放进仓库,就写 `.project.json.quick_login` +- 不能直接入库的敏感登录,不要伪造 `quick_login`,改为写 `.project.json.credentials` 引用 +- 数据库密码、API Key、服务器 root 密码,不属于 `quick_login` + +## Completion Gate + +- 部署完成后,不允许在 `.project.json` 缺少最新公网链接的状态下结束任务 +- 部署完成后,必须同步更新 `RULES.md` 的部署事实 +- 如果只更新了代码但没回写部署元数据,这个任务不算完成 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ed99ce1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Sales CRM 仪表板 Agent Rules + +## Must Read First + +- `.project.json` 是机器真源:公网链接、快捷登录、凭证引用都以它为准 +- `RULES.md` 是人工规则和部署事实:启动命令、平台、域名、注意事项都写这里 +- 不允许编造不存在的域名、账号、密码;未知就保持空白并明确标记待补充 + +## Deployment Metadata Contract + +- 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json` +- `urls[]` 推荐显式写 `type`:`app`、`backend`、`docs`、`admin`、`repo` +- 项目专属的网页登录信息,如果允许放进仓库,就写 `.project.json.quick_login` +- 不能直接入库的敏感登录,不要伪造 `quick_login`,改为写 `.project.json.credentials` 引用 +- 数据库密码、API Key、服务器 root 密码,不属于 `quick_login` + +## Completion Gate + +- 部署完成后,不允许在 `.project.json` 缺少最新公网链接的状态下结束任务 +- 部署完成后,必须同步更新 `RULES.md` 的部署事实 +- 如果只更新了代码但没回写部署元数据,这个任务不算完成 diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..682eabf --- /dev/null +++ b/RULES.md @@ -0,0 +1,37 @@ +# Sales CRM 仪表板 + +## 启动 +- `dev` — 端口 4440 + +## 部署事实 +- 平台:待定 +- 发布状态:已部署 +- 主站 / 前端:http://localhost:4440 +- API / 后端:待定 +- 文档 / 解析:待定 +- 管理后台:待定 +- 代码仓:待定 + +## 快捷登录 +- 登录地址:待补充 +- 用户名:待补充 +- 密码:待补充 +- 说明:这里只写项目专属网页登录;数据库密码、API Key、服务器 root 密码不要写这里 + +## 元数据回写清单 +- 新增或变更公网地址后,必须同步更新 `.project.json.urls` +- 如果有网页后台登录: + - 可直接入库:写 `.project.json.quick_login` + - 不应入库:写 `.project.json.credentials` 引用 +- 部署完成后,`RULES.md` 和 `.project.json` 必须同一次任务一起更新 + +## 环境变量 +- 待补充 + +## 规则 +- 不允许编造不存在的部署域名、账号、密码 +- 没有公网地址时,`.project.json.urls` 保持空数组 +- 任何部署或域名变化,都要先改元数据,再视为任务完成 + +## 注意事项 +- 待补充 diff --git a/app/contacts/loading.tsx b/app/contacts/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/contacts/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/contacts/page.tsx b/app/contacts/page.tsx new file mode 100644 index 0000000..be153ac --- /dev/null +++ b/app/contacts/page.tsx @@ -0,0 +1,442 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Topbar } from "@/components/topbar" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Plus, Search, Filter, MoreHorizontal, Eye, Edit, Trash2, Mail, Phone, Building } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useTranslation } from "@/lib/i18n" + +type Contact = { + id: string + name: string + email: string + phone: string + company: string + position: string + status: "Active" | "Inactive" | "Lead" | "Customer" + avatar: string + lastContact: string + createdAt: string + notes: string +} + +const initialContacts: Contact[] = [ + { + id: "CONTACT-001", + name: "Sarah Wilson", + email: "sarah.wilson@techcorp.com", + phone: "+1 (555) 123-4567", + company: "TechCorp Inc.", + position: "CTO", + status: "Customer", + avatar: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face", + lastContact: "2024-01-15", + createdAt: "2023-12-01", + notes: "Key decision maker for enterprise software purchases", + }, + { + id: "CONTACT-002", + name: "Michael Chen", + email: "m.chen@startupxyz.com", + phone: "+1 (555) 234-5678", + company: "StartupXYZ", + position: "CEO", + status: "Lead", + avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face", + lastContact: "2024-01-14", + createdAt: "2023-12-15", + notes: "Interested in marketing automation solutions", + }, + { + id: "CONTACT-003", + name: "Emily Rodriguez", + email: "emily.r@globalsolutions.com", + phone: "+1 (555) 345-6789", + company: "Global Solutions", + position: "IT Director", + status: "Active", + avatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face", + lastContact: "2024-01-12", + createdAt: "2023-11-20", + notes: "Evaluating cloud migration options", + }, + { + id: "CONTACT-004", + name: "David Park", + email: "david.park@retailchain.com", + phone: "+1 (555) 456-7890", + company: "Retail Chain Co.", + position: "Operations Manager", + status: "Customer", + avatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", + lastContact: "2024-01-08", + createdAt: "2023-10-15", + notes: "Recently implemented CRM system", + }, +] + +const getStatusColor = (status: Contact["status"]) => { + const colors = { + Active: "bg-blue-100 text-blue-800", + Inactive: "bg-gray-100 text-gray-800", + Lead: "bg-yellow-100 text-yellow-800", + Customer: "bg-green-100 text-green-800", + } + return colors[status] +} + +export default function ContactsPage() { + const { t } = useTranslation() + const [contacts, setContacts] = useState(initialContacts) + const [searchTerm, setSearchTerm] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingContact, setEditingContact] = useState(null) + + const filteredContacts = contacts.filter((contact) => { + const matchesSearch = + contact.name.toLowerCase().includes(searchTerm.toLowerCase()) || + contact.email.toLowerCase().includes(searchTerm.toLowerCase()) || + contact.company.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesStatus = statusFilter === "all" || contact.status === statusFilter + return matchesSearch && matchesStatus + }) + + const handleAddContact = (contactData: Partial) => { + const newContact: Contact = { + id: `CONTACT-${String(contacts.length + 1).padStart(3, "0")}`, + name: contactData.name || "", + email: contactData.email || "", + phone: contactData.phone || "", + company: contactData.company || "", + position: contactData.position || "", + status: contactData.status || "Lead", + avatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", + lastContact: new Date().toISOString().split("T")[0], + createdAt: new Date().toISOString().split("T")[0], + notes: contactData.notes || "", + } + setContacts([...contacts, newContact]) + setIsDialogOpen(false) + } + + const handleEditContact = (contactData: Partial) => { + if (editingContact) { + setContacts( + contacts.map((contact) => (contact.id === editingContact.id ? { ...contact, ...contactData } : contact)), + ) + setEditingContact(null) + setIsDialogOpen(false) + } + } + + const handleDeleteContact = (contactId: string) => { + setContacts(contacts.filter((contact) => contact.id !== contactId)) + } + + return ( +
+ + +
+
+
+

{t("contacts.title")}

+

{t("contacts.description")}

+
+ + + + + { + setIsDialogOpen(false) + setEditingContact(null) + }} + /> + +
+ +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ + + + {t("contacts.gridView")} + {t("contacts.listView")} + + + +
+ {filteredContacts.map((contact) => ( + + +
+
+ + + + {contact.name.split(" ").map((n) => n[0]).join("")} + + +
+ {contact.name} + {contact.position} +
+
+ + + + + + {t("contacts.actions")} + + + {t("contacts.viewDetails")} + + { + setEditingContact(contact) + setIsDialogOpen(true) + }} + > + + {t("contacts.editContact")} + + + handleDeleteContact(contact.id)}> + + {t("contacts.deleteContact")} + + + +
+
+ +
+ {contact.status} + + {new Date(contact.lastContact).toLocaleDateString()} + +
+
+
+ + {contact.company} +
+
+ + {contact.email} +
+
+ + {contact.phone} +
+
+
+
+ ))} +
+
+ + + + +
+ {filteredContacts.map((contact) => ( +
+
+
+ + + + {contact.name.split(" ").map((n) => n[0]).join("")} + + +
+

{contact.name}

+

+ {contact.position} at {contact.company} +

+
+
+ {contact.email} + {contact.phone} +
+ {contact.status} +
+ {new Date(contact.lastContact).toLocaleDateString()} +
+
+ + + + + + {t("contacts.actions")} + + + {t("contacts.viewDetails")} + + { + setEditingContact(contact) + setIsDialogOpen(true) + }} + > + + {t("contacts.editContact")} + + + handleDeleteContact(contact.id)}> + + {t("contacts.deleteContact")} + + + +
+
+ ))} +
+
+
+
+
+
+
+ ) +} + +function ContactDialog({ + contact, + onSave, + onCancel, +}: { + contact: Contact | null + onSave: (data: Partial) => void + onCancel: () => void +}) { + const { t } = useTranslation() + const [formData, setFormData] = useState({ + name: contact?.name || "", + email: contact?.email || "", + phone: contact?.phone || "", + company: contact?.company || "", + position: contact?.position || "", + status: contact?.status || "Lead", + notes: contact?.notes || "", + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave(formData) + } + + return ( + + + {contact ? t("contacts.editTitle") : t("contacts.addTitle")} + + {contact ? t("contacts.editDesc") : t("contacts.addDesc")} + + +
+
+ + setFormData({ ...formData, name: e.target.value })} required /> +
+
+ + setFormData({ ...formData, email: e.target.value })} required /> +
+
+ + setFormData({ ...formData, phone: e.target.value })} /> +
+
+
+ + setFormData({ ...formData, company: e.target.value })} required /> +
+
+ + setFormData({ ...formData, position: e.target.value })} /> +
+
+
+ + +
+ + + + +
+
+ ) +} diff --git a/app/deals/loading.tsx b/app/deals/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/deals/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/deals/page.tsx b/app/deals/page.tsx new file mode 100644 index 0000000..2023b24 --- /dev/null +++ b/app/deals/page.tsx @@ -0,0 +1,292 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Topbar } from "@/components/topbar" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Plus, Search, Filter, MoreHorizontal, Eye, Edit, Trash2 } from "lucide-react" +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useTranslation } from "@/lib/i18n" + +type Deal = { + id: string; dealName: string; client: string + stage: "Lead" | "Qualified" | "Proposal" | "Negotiation" | "Closed Won" | "Closed Lost" + value: number; probability: number; owner: string; ownerAvatar: string + expectedClose: string; description: string; createdAt: string +} + +const initialDeals: Deal[] = [ + { id: "DEAL-001", dealName: "Enterprise Software License", client: "TechCorp Inc.", stage: "Negotiation", value: 45000, probability: 75, owner: "Jane Doe", ownerAvatar: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face", expectedClose: "2024-02-15", description: "Large enterprise software licensing deal for 500+ users", createdAt: "2023-12-01" }, + { id: "DEAL-002", dealName: "Marketing Automation Setup", client: "StartupXYZ", stage: "Proposal", value: 12500, probability: 60, owner: "Mike Roberts", ownerAvatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", expectedClose: "2024-01-30", description: "Complete marketing automation platform implementation", createdAt: "2023-12-15" }, + { id: "DEAL-003", dealName: "Cloud Migration Project", client: "Global Solutions", stage: "Qualified", value: 78000, probability: 40, owner: "Sarah Johnson", ownerAvatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face", expectedClose: "2024-03-01", description: "Full cloud infrastructure migration and optimization", createdAt: "2023-11-20" }, + { id: "DEAL-004", dealName: "CRM Implementation", client: "Retail Chain Co.", stage: "Closed Won", value: 25000, probability: 100, owner: "Alex Lee", ownerAvatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face", expectedClose: "2024-01-15", description: "Custom CRM implementation for retail operations", createdAt: "2023-10-15" }, +] + +const getStageColor = (stage: Deal["stage"]) => { + const colors = { Lead: "bg-gray-100 text-gray-800", Qualified: "bg-blue-100 text-blue-800", Proposal: "bg-yellow-100 text-yellow-800", Negotiation: "bg-orange-100 text-orange-800", "Closed Won": "bg-green-100 text-green-800", "Closed Lost": "bg-red-100 text-red-800" } + return colors[stage] +} + +export default function DealsPage() { + const { t } = useTranslation() + const [deals, setDeals] = useState(initialDeals) + const [searchTerm, setSearchTerm] = useState("") + const [stageFilter, setStageFilter] = useState("all") + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingDeal, setEditingDeal] = useState(null) + + const filteredDeals = deals.filter((deal) => { + const matchesSearch = deal.dealName.toLowerCase().includes(searchTerm.toLowerCase()) || deal.client.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesStage = stageFilter === "all" || deal.stage === stageFilter + return matchesSearch && matchesStage + }) + + const handleAddDeal = (dealData: Partial) => { + const newDeal: Deal = { + id: `DEAL-${String(deals.length + 1).padStart(3, "0")}`, dealName: dealData.dealName || "", client: dealData.client || "", + stage: dealData.stage || "Lead", value: dealData.value || 0, probability: dealData.probability || 0, + owner: dealData.owner || "Current User", ownerAvatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", + expectedClose: dealData.expectedClose || "", description: dealData.description || "", createdAt: new Date().toISOString().split("T")[0], + } + setDeals([...deals, newDeal]); setIsDialogOpen(false) + } + + const handleEditDeal = (dealData: Partial) => { + if (editingDeal) { + setDeals(deals.map((deal) => (deal.id === editingDeal.id ? { ...deal, ...dealData } : deal))) + setEditingDeal(null); setIsDialogOpen(false) + } + } + + const handleDeleteDeal = (dealId: string) => { setDeals(deals.filter((deal) => deal.id !== dealId)) } + + return ( +
+ +
+
+
+

{t("deals.title")}

+

{t("deals.description")}

+
+ + + + + { setIsDialogOpen(false); setEditingDeal(null) }} /> + +
+ +
+
+ + setSearchTerm(e.target.value)} /> +
+ +
+ + + + {t("deals.gridView")} + {t("deals.listView")} + + + +
+ {filteredDeals.map((deal) => ( + + +
+
+ {deal.dealName} + {deal.client} +
+ + + + + + {t("deals.actions")} + {t("deals.viewDetails")} + { setEditingDeal(deal); setIsDialogOpen(true) }}> + {t("deals.editDeal")} + + + handleDeleteDeal(deal.id)}> + {t("deals.deleteDeal")} + + + +
+
+ +
+ {deal.stage} + {deal.probability}% +
+
${deal.value.toLocaleString()}
+
+ + + {deal.owner.split(" ").map((n) => n[0]).join("")} + + {deal.owner} +
+
+ {t("deals.expectedClose")} {new Date(deal.expectedClose).toLocaleDateString()} +
+
+
+ ))} +
+
+ + + + +
+ {filteredDeals.map((deal) => ( +
+
+
+
+

{deal.dealName}

+

{deal.client}

+
+ {deal.stage} +
${deal.value.toLocaleString()}
+
+ + + {deal.owner.split(" ").map((n) => n[0]).join("")} + + {deal.owner} +
+
{new Date(deal.expectedClose).toLocaleDateString()}
+
+ + + + + + {t("deals.actions")} + {t("deals.viewDetails")} + { setEditingDeal(deal); setIsDialogOpen(true) }}> + {t("deals.editDeal")} + + + handleDeleteDeal(deal.id)}> + {t("deals.deleteDeal")} + + + +
+
+ ))} +
+
+
+
+
+
+
+ ) +} + +function DealDialog({ deal, onSave, onCancel }: { deal: Deal | null; onSave: (data: Partial) => void; onCancel: () => void }) { + const { t } = useTranslation() + const [formData, setFormData] = useState({ + dealName: deal?.dealName || "", client: deal?.client || "", stage: deal?.stage || "Lead", + value: deal?.value || 0, probability: deal?.probability || 0, expectedClose: deal?.expectedClose || "", description: deal?.description || "", + }) + + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSave(formData) } + + return ( + + + {deal ? t("deals.editTitle") : t("deals.addTitle")} + {deal ? t("deals.editDesc") : t("deals.addDesc")} + +
+
+ + setFormData({ ...formData, dealName: e.target.value })} required /> +
+
+ + setFormData({ ...formData, client: e.target.value })} required /> +
+
+
+ + +
+
+ + setFormData({ ...formData, probability: Number(e.target.value) })} /> +
+
+
+
+ + setFormData({ ...formData, value: Number(e.target.value) })} required /> +
+
+ + setFormData({ ...formData, expectedClose: e.target.value })} required /> +
+
+
+ +