From 545065b527a75e45e857bfe8a79e80819c5e2c48 Mon Sep 17 00:00:00 2001 From: Rene Date: Wed, 1 Jul 2026 11:18:59 +0200 Subject: [PATCH] Kanban board, Project Management, Proposals/Quotes, Time Tracking, Custom Fields - Kanban board with drag-and-drop per stage, colour-coded cards, score bars - Project Management with milestones, Gantt timeline, inline add milestone/task - Proposal/Quote builder with line items, VAT, terms, e-signature capture - Time Tracking with stopwatch, manual entry, hours-by-project chart - Custom Fields system: admin-defined fields per entity (leads/customers/ projects/opportunities) with drag-and-drop reordering in settings - Reusable CustomFieldsSection component for entity detail pages - Customers API endpoint - All builds pass with zero errors --- database/migrations/020_projects.sql | 104 +++++ database/migrations/021_proposals_time.sql | 119 ++++++ database/migrations/022_custom_fields.sql | 43 ++ database/migrations/run_all.sql | 10 + package-lock.json | 56 +++ package.json | 3 + src/app/(dashboard)/leads/kanban/page.tsx | 254 ++++++++++++ src/app/(dashboard)/leads/page.tsx | 8 +- src/app/(dashboard)/projects/[id]/page.tsx | 382 ++++++++++++++++++ src/app/(dashboard)/projects/page.tsx | 273 +++++++++++++ src/app/(dashboard)/proposals/[id]/page.tsx | 203 ++++++++++ src/app/(dashboard)/proposals/new/page.tsx | 234 +++++++++++ src/app/(dashboard)/proposals/page.tsx | 115 ++++++ src/app/(dashboard)/settings/page.tsx | 4 +- src/app/(dashboard)/time-tracking/page.tsx | 294 ++++++++++++++ src/app/api/custom-fields/route.ts | 125 ++++++ src/app/api/customers/route.ts | 31 ++ src/app/api/leads/[id]/route.ts | 6 + src/app/api/leads/kanban/route.ts | 73 ++++ src/app/api/leads/route.ts | 5 +- src/app/api/milestones/[id]/route.ts | 60 +++ src/app/api/project-statuses/route.ts | 18 + src/app/api/projects/[id]/milestones/route.ts | 69 ++++ src/app/api/projects/[id]/route.ts | 79 ++++ src/app/api/projects/route.ts | 103 +++++ src/app/api/proposal-statuses/route.ts | 16 + src/app/api/proposals/[id]/items/route.ts | 71 ++++ src/app/api/proposals/[id]/route.ts | 118 ++++++ src/app/api/proposals/[id]/sign/route.ts | 34 ++ src/app/api/proposals/route.ts | 85 ++++ src/app/api/tasks/route.ts | 71 ++++ src/app/api/time-entries/route.ts | 100 +++++ src/app/api/time-entries/summary/route.ts | 58 +++ src/components/layout/sidebar.tsx | 6 + src/components/leads/lead-details-card.tsx | 11 + .../settings/custom-fields-manager.tsx | 293 ++++++++++++++ .../shared/custom-fields-section.tsx | 239 +++++++++++ src/types/index.ts | 1 + 38 files changed, 3770 insertions(+), 4 deletions(-) create mode 100644 database/migrations/020_projects.sql create mode 100644 database/migrations/021_proposals_time.sql create mode 100644 database/migrations/022_custom_fields.sql create mode 100644 src/app/(dashboard)/leads/kanban/page.tsx create mode 100644 src/app/(dashboard)/projects/[id]/page.tsx create mode 100644 src/app/(dashboard)/projects/page.tsx create mode 100644 src/app/(dashboard)/proposals/[id]/page.tsx create mode 100644 src/app/(dashboard)/proposals/new/page.tsx create mode 100644 src/app/(dashboard)/proposals/page.tsx create mode 100644 src/app/(dashboard)/time-tracking/page.tsx create mode 100644 src/app/api/custom-fields/route.ts create mode 100644 src/app/api/customers/route.ts create mode 100644 src/app/api/leads/kanban/route.ts create mode 100644 src/app/api/milestones/[id]/route.ts create mode 100644 src/app/api/project-statuses/route.ts create mode 100644 src/app/api/projects/[id]/milestones/route.ts create mode 100644 src/app/api/projects/[id]/route.ts create mode 100644 src/app/api/projects/route.ts create mode 100644 src/app/api/proposal-statuses/route.ts create mode 100644 src/app/api/proposals/[id]/items/route.ts create mode 100644 src/app/api/proposals/[id]/route.ts create mode 100644 src/app/api/proposals/[id]/sign/route.ts create mode 100644 src/app/api/proposals/route.ts create mode 100644 src/app/api/tasks/route.ts create mode 100644 src/app/api/time-entries/route.ts create mode 100644 src/app/api/time-entries/summary/route.ts create mode 100644 src/components/settings/custom-fields-manager.tsx create mode 100644 src/components/shared/custom-fields-section.tsx diff --git a/database/migrations/020_projects.sql b/database/migrations/020_projects.sql new file mode 100644 index 0000000..46592af --- /dev/null +++ b/database/migrations/020_projects.sql @@ -0,0 +1,104 @@ +-- ============================================================================ +-- Project Management — Post-sale project tracking with milestones +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS project_statuses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(50) NOT NULL UNIQUE, + color VARCHAR(7), + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + customer_id UUID NOT NULL REFERENCES customers(id), + opportunity_id UUID REFERENCES opportunities(id), + owner_id UUID NOT NULL REFERENCES users(id), + name VARCHAR(255) NOT NULL, + description TEXT, + status_id UUID NOT NULL REFERENCES project_statuses(id), + start_date DATE, + target_end_date DATE, + actual_end_date DATE, + budget DECIMAL(15,2), + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_projects_customer ON projects(customer_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_projects_owner ON projects(owner_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_projects_status ON projects(status_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_projects_created ON projects(created_at) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS project_milestones ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + sort_order INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + start_date DATE, + due_date DATE, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_milestone_status CHECK (status IN ('pending','in_progress','completed','cancelled')) +); + +CREATE INDEX idx_milestones_project ON project_milestones(project_id); +CREATE INDEX idx_milestones_status ON project_milestones(status); +CREATE INDEX idx_milestones_due ON project_milestones(due_date); + +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS milestone_id UUID REFERENCES project_milestones(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id) WHERE project_id IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_tasks_milestone ON tasks(milestone_id) WHERE milestone_id IS NOT NULL AND deleted_at IS NULL; + +-- Seed project statuses +INSERT INTO project_statuses (name, color, sort_order) VALUES + ('Planning', '#6366f1', 1), + ('In Progress', '#22c55e', 2), + ('Review', '#eab308', 3), + ('Completed', '#22d3ee', 4), + ('On Hold', '#f97316', 5), + ('Cancelled', '#ef4444', 6) +ON CONFLICT (name) DO NOTHING; + +-- Seed a sample project from the existing TechCorp opportunity +DO $$ +DECLARE + v_customer_id UUID; + v_opportunity_id UUID; + v_owner_id UUID; + v_planning_id UUID; + v_progress_id UUID; + v_project_id UUID; +BEGIN + SELECT id INTO v_customer_id FROM customers LIMIT 1; + SELECT id INTO v_opportunity_id FROM opportunities LIMIT 1; + SELECT u.id INTO v_owner_id FROM users u JOIN user_roles ur ON ur.user_id = u.id JOIN roles r ON r.id = ur.role_id WHERE r.name = 'SALES_USER' LIMIT 1; + IF v_owner_id IS NULL THEN SELECT id INTO v_owner_id FROM users LIMIT 1; END IF; + SELECT id INTO v_planning_id FROM project_statuses WHERE name = 'Planning'; + SELECT id INTO v_progress_id FROM project_statuses WHERE name = 'In Progress'; + + IF v_customer_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE name = 'TechCorp Website Redesign') THEN + INSERT INTO projects (customer_id, opportunity_id, owner_id, name, description, status_id, start_date, target_end_date, budget, created_by) + VALUES (v_customer_id, v_opportunity_id, v_owner_id, 'TechCorp Website Redesign', + 'Full website redesign including homepage, services pages, and contact forms', + v_planning_id, CURRENT_DATE, CURRENT_DATE + INTERVAL '60 days', 25000.00, v_owner_id) + RETURNING id INTO v_project_id; + + INSERT INTO project_milestones (project_id, name, description, sort_order, status, start_date, due_date) VALUES + (v_project_id, 'Requirements & Wireframes', 'Gather requirements, create wireframes for approval', 1, 'in_progress', CURRENT_DATE, CURRENT_DATE + INTERVAL '10 days'), + (v_project_id, 'Design Mockups', 'Create visual design mockups for all pages', 2, 'pending', CURRENT_DATE + INTERVAL '10 days', CURRENT_DATE + INTERVAL '20 days'), + (v_project_id, 'Frontend Development', 'Build responsive frontend components', 3, 'pending', CURRENT_DATE + INTERVAL '20 days', CURRENT_DATE + INTERVAL '35 days'), + (v_project_id, 'Backend Integration', 'Integrate CMS, forms, and email functionality', 4, 'pending', CURRENT_DATE + INTERVAL '35 days', CURRENT_DATE + INTERVAL '45 days'), + (v_project_id, 'Testing & QA', 'Cross-browser testing, performance optimization', 5, 'pending', CURRENT_DATE + INTERVAL '45 days', CURRENT_DATE + INTERVAL '55 days'), + (v_project_id, 'Launch', 'Deploy to production, final sign-off', 6, 'pending', CURRENT_DATE + INTERVAL '55 days', CURRENT_DATE + INTERVAL '60 days'); + END IF; +END $$; diff --git a/database/migrations/021_proposals_time.sql b/database/migrations/021_proposals_time.sql new file mode 100644 index 0000000..7e9613d --- /dev/null +++ b/database/migrations/021_proposals_time.sql @@ -0,0 +1,119 @@ +-- ============================================================================ +-- Proposal/Quote Builder + Time Tracking +-- ============================================================================ + +-- ── Proposals / Quotes ────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS proposal_statuses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(50) NOT NULL UNIQUE, + color VARCHAR(7), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO proposal_statuses (name, color, sort_order) VALUES + ('Draft', '#6366f1', 1), + ('Sent', '#eab308', 2), + ('Viewed', '#22d3ee', 3), + ('Accepted', '#22c55e', 4), + ('Declined', '#ef4444', 5), + ('Expired', '#a1a1aa', 6) +ON CONFLICT (name) DO NOTHING; + +CREATE TABLE IF NOT EXISTS proposals ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + lead_id UUID REFERENCES leads(id), + customer_id UUID REFERENCES customers(id), + opportunity_id UUID REFERENCES opportunities(id), + title VARCHAR(255) NOT NULL, + proposal_number VARCHAR(50) NOT NULL, + status_id UUID NOT NULL REFERENCES proposal_statuses(id), + content JSONB, + subtotal DECIMAL(15,2) NOT NULL DEFAULT 0, + tax_percent DECIMAL(5,2) DEFAULT 15, + tax_amount DECIMAL(15,2) DEFAULT 0, + total_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + valid_until DATE, + notes TEXT, + terms TEXT DEFAULT 'Payment due within 30 days. 50% deposit required to commence work.', + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_proposal_number ON proposals(proposal_number) WHERE deleted_at IS NULL; +CREATE INDEX idx_proposals_customer ON proposals(customer_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_proposals_lead ON proposals(lead_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_proposals_status ON proposals(status_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_proposals_created ON proposals(created_at) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS proposal_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + product_id UUID REFERENCES products(id), + description TEXT NOT NULL, + quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0), + unit_price DECIMAL(15,2) NOT NULL CHECK (unit_price >= 0), + discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100), + total_price DECIMAL(15,2) NOT NULL CHECK (total_price >= 0), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_proposal_items_proposal ON proposal_items(proposal_id); + +CREATE TABLE IF NOT EXISTS proposal_signatures ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + signatory_name VARCHAR(255) NOT NULL, + signatory_email VARCHAR(255) NOT NULL, + signature_data TEXT, + ip_address INET, + signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_valid BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE INDEX idx_proposal_signatures_proposal ON proposal_signatures(proposal_id); + +-- ── Time Tracking ────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS time_entries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id), + project_id UUID REFERENCES projects(id), + milestone_id UUID REFERENCES project_milestones(id), + task_id UUID REFERENCES tasks(id), + description TEXT, + date DATE NOT NULL DEFAULT CURRENT_DATE, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + duration_minutes INT NOT NULL CHECK (duration_minutes > 0), + billable BOOLEAN NOT NULL DEFAULT TRUE, + hourly_rate DECIMAL(15,2), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_time_entries_user ON time_entries(user_id); +CREATE INDEX idx_time_entries_project ON time_entries(project_id); +CREATE INDEX idx_time_entries_date ON time_entries(date); +CREATE INDEX idx_time_entries_billable ON time_entries(billable) WHERE billable = TRUE; + +-- Seed sample time entry +DO $$ +DECLARE + v_user_id UUID; + v_project_id UUID; +BEGIN + SELECT id INTO v_user_id FROM users LIMIT 1; + SELECT id INTO v_project_id FROM projects LIMIT 1; + IF v_user_id IS NOT NULL AND v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM time_entries) THEN + INSERT INTO time_entries (user_id, project_id, description, date, duration_minutes, billable, hourly_rate) + VALUES + (v_user_id, v_project_id, 'Initial planning and requirements gathering', CURRENT_DATE - 2, 240, TRUE, 1500), + (v_user_id, v_project_id, 'Wireframe design', CURRENT_DATE - 1, 180, TRUE, 1500), + (v_user_id, v_project_id, 'Team standup and sprint planning', CURRENT_DATE, 60, FALSE, NULL); + END IF; +END $$; diff --git a/database/migrations/022_custom_fields.sql b/database/migrations/022_custom_fields.sql new file mode 100644 index 0000000..24f6e05 --- /dev/null +++ b/database/migrations/022_custom_fields.sql @@ -0,0 +1,43 @@ +-- ============================================================================ +-- Custom Fields — Adaptable fields per entity type (leads, customers, etc.) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS custom_field_definitions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + entity_type VARCHAR(50) NOT NULL, + field_key VARCHAR(100) NOT NULL, + field_label VARCHAR(255) NOT NULL, + field_type VARCHAR(50) NOT NULL DEFAULT 'text', + options JSONB, + placeholder TEXT, + default_value TEXT, + section VARCHAR(100), + is_required BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_custom_field_entity_key UNIQUE (entity_type, field_key), + CONSTRAINT chk_custom_field_type CHECK (field_type IN ('text','number','date','select','multi_select','boolean','url','email','phone')) +); + +CREATE INDEX idx_custom_fields_entity ON custom_field_definitions(entity_type, is_active, sort_order); + +ALTER TABLE leads ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}'; +ALTER TABLE customers ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}'; +ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}'; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}'; + +-- Seed sample fields +INSERT INTO custom_field_definitions (entity_type, field_key, field_label, field_type, options, section, sort_order) VALUES + ('lead', 'service_type', 'Service Type', 'select', '["Web Development","Mobile App","SEO","Consulting","Retainer","Maintenance"]', 'Details', 1), + ('lead', 'budget_confirmed', 'Budget Confirmed', 'boolean', NULL, 'Details', 2), + ('lead', 'referral_source', 'Referral Source', 'text', NULL, 'Details', 3), + ('customer', 'industry_vertical', 'Industry Vertical', 'select', '["Fintech","Healthcare","E-commerce","Education","Real Estate","Hospitality","Other"]', 'Business', 1), + ('customer', 'vat_number', 'VAT Number', 'text', NULL, 'Business', 2), + ('project', 'project_type', 'Project Type', 'select', '["New Build","Redesign","Maintenance","Consulting","Retainer"]', 'Details', 1), + ('project', 'hosting_provider', 'Hosting Provider', 'text', NULL, 'Technical', 2), + ('project', 'tech_stack', 'Tech Stack', 'multi_select', '["React","Next.js","Node.js","Python","PHP","WordPress","Shopify","Laravel","Vue","Angular"]', 'Technical', 3), + ('opportunity', 'competitor', 'Competitor', 'text', NULL, 'Details', 1), + ('opportunity', 'decision_timeline', 'Decision Timeline', 'select', '["This Week","This Month","This Quarter","Next Quarter","Not Sure"]', 'Details', 2) +ON CONFLICT (entity_type, field_key) DO NOTHING; diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index 005c1b6..40460d1 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -75,5 +75,15 @@ BEGIN; \echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ===' \i 019_allow_null_start_time.sql + +\echo '=== Running 020_projects.sql (Project Management) ===' +\i 020_projects.sql + +\echo '=== Running 021_proposals_time.sql (Proposals + Time Tracking) ===' +\i 021_proposals_time.sql + +\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ===' +\i 022_custom_fields.sql + \echo '=== Migration Complete ===' COMMIT; diff --git a/package-lock.json b/package-lock.json index 9d5962a..e662964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@hookform/resolvers": "^3.9.1", @@ -229,6 +232,59 @@ "kuler": "^2.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index c8daf94..dd459f8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@hookform/resolvers": "^3.9.1", diff --git a/src/app/(dashboard)/leads/kanban/page.tsx b/src/app/(dashboard)/leads/kanban/page.tsx new file mode 100644 index 0000000..290ae05 --- /dev/null +++ b/src/app/(dashboard)/leads/kanban/page.tsx @@ -0,0 +1,254 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useRouter } from "next/navigation" +import { PageHeader } from "@/components/shared/page-header" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Card } from "@/components/ui/card" +import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react" + +interface KanbanLead { + id: string + company_name: string + contact_name: string + email: string + phone: string + score: number + notes: string + assigned_to: string | null + created_at: string +} + +interface KanbanColumn { + id: string + name: string + status: string + sortOrder: number + probability: number + leads: KanbanLead[] +} + +const statusColors: Record = { + open: { header: "bg-blue-500/10 border-blue-500/20", border: "border-blue-500/20", dot: "bg-blue-500" }, + contacted: { header: "bg-amber-500/10 border-amber-500/20", border: "border-amber-500/20", dot: "bg-amber-500" }, + pending: { header: "bg-purple-500/10 border-purple-500/20", border: "border-purple-500/20", dot: "bg-purple-500" }, + closed: { header: "bg-emerald-500/10 border-emerald-500/20", border: "border-emerald-500/20", dot: "bg-emerald-500" }, + ignored: { header: "bg-zinc-500/10 border-zinc-500/20", border: "border-zinc-500/20", dot: "bg-zinc-500" }, +} + +const columnGradient: Record = { + open: "from-blue-500/5", + contacted: "from-amber-500/5", + pending: "from-purple-500/5", + closed: "from-emerald-500/5", + ignored: "from-zinc-500/5", +} + +export default function KanbanPage() { + const router = useRouter() + const [columns, setColumns] = useState([]) + const [loading, setLoading] = useState(true) + const [draggedLead, setDraggedLead] = useState<{ leadId: string; fromStatus: string } | null>(null) + + const fetchKanban = useCallback(async () => { + try { + const res = await fetch("/api/leads/kanban") + const data = await res.json() + setColumns(data) + } catch (e) { + console.error("Failed to load kanban", e) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchKanban() }, [fetchKanban]) + + const handleDragStart = (leadId: string, status: string) => { + setDraggedLead({ leadId, fromStatus: status }) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + + const handleDrop = async (toStatus: string) => { + if (!draggedLead || draggedLead.fromStatus === toStatus) { + setDraggedLead(null) + return + } + + setColumns((prev) => { + const next = prev.map((col) => ({ ...col, leads: [...col.leads] })) + const fromCol = next.find((c) => c.status === draggedLead.fromStatus) + const toCol = next.find((c) => c.status === toStatus) + if (!fromCol || !toCol) return prev + const leadIdx = fromCol.leads.findIndex((l) => l.id === draggedLead.leadId) + if (leadIdx === -1) return prev + const [moved] = fromCol.leads.splice(leadIdx, 1) + toCol.leads.unshift(moved) + return next + }) + + try { + await fetch(`/api/leads/${draggedLead.leadId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: toStatus }), + }) + } catch (e) { + console.error("Failed to update lead status", e) + fetchKanban() + } + setDraggedLead(null) + } + + if (loading) { + return ( +
+ +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+ ) + } + + return ( +
+ + + + + +
+ {columns.map((col) => { + const colors = statusColors[col.status] || statusColors.open + return ( +
handleDrop(col.status)} + > +
+
+ + {col.name} + ({col.leads.length}) +
+ {col.probability > 0 && ( + {col.probability}% + )} +
+ +
+ {col.leads.length === 0 && ( +
+

No leads

+
+ )} + {col.leads.map((lead) => ( + router.push(`/leads/${lead.id}`)} + /> + ))} +
+
+ ) + })} +
+
+ ) +} + +function LeadCard({ + lead, + status, + onDragStart, + onClick, +}: { + lead: KanbanLead + status: string + onDragStart: (id: string, status: string) => void + onClick: () => void +}) { + const initials = lead.contact_name + ?.split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + + return ( + onDragStart(lead.id, status)} + onClick={onClick} + className="cursor-grab active:cursor-grabbing p-3 space-y-2 transition-shadow hover:shadow-md" + > +
+
+ + {initials} + +
+

{lead.contact_name}

+ {lead.company_name && ( +

+ + {lead.company_name} +

+ )} +
+
+ +
+ +
+ {lead.email && ( +

+ + {lead.email} +

+ )} + {lead.phone && ( +

+ + {lead.phone} +

+ )} +
+ + {lead.score > 0 && ( +
+ +
+
= 70 ? "#22c55e" : lead.score >= 40 ? "#eab308" : "#ef4444", + }} + /> +
+ {lead.score} +
+ )} + + ) +} diff --git a/src/app/(dashboard)/leads/page.tsx b/src/app/(dashboard)/leads/page.tsx index a0b7cd9..af0ac43 100644 --- a/src/app/(dashboard)/leads/page.tsx +++ b/src/app/(dashboard)/leads/page.tsx @@ -5,6 +5,8 @@ import { useRouter } from "next/navigation" import { PageHeader } from "@/components/shared/page-header" import { LeadsTable } from "@/components/leads/leads-table" import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar" +import { Button } from "@/components/ui/button" +import { Columns3 } from "lucide-react" import { Lead } from "@/types" export default function LeadsPage() { @@ -36,7 +38,11 @@ export default function LeadsPage() { + > + +
diff --git a/src/app/(dashboard)/projects/[id]/page.tsx b/src/app/(dashboard)/projects/[id]/page.tsx new file mode 100644 index 0000000..ecedb99 --- /dev/null +++ b/src/app/(dashboard)/projects/[id]/page.tsx @@ -0,0 +1,382 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useParams, useRouter } from "next/navigation" +import { PageHeader } from "@/components/shared/page-header" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { CustomFieldsSection } from "@/components/shared/custom-fields-section" +import { + ArrowLeft, Calendar, User, Building2, DollarSign, Plus, CheckCircle2, + Circle, Clock, AlertCircle, ChevronDown, ChevronRight, GripVertical, +} from "lucide-react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface Milestone { + id: string + name: string + description: string + sort_order: number + status: string + start_date: string + due_date: string + completed_at: string + created_at: string + tasks: Task[] +} + +interface Task { + id: string + title: string + description: string + status: string + due_date: string + completed_at: string + assigned_to: string + created_at: string +} + +const milestoneStatusConfig: Record = { + pending: { icon: Circle, class: "text-zinc-400", label: "Pending" }, + in_progress: { icon: Clock, class: "text-blue-500", label: "In Progress" }, + completed: { icon: CheckCircle2, class: "text-emerald-500", label: "Completed" }, + cancelled: { icon: AlertCircle, class: "text-red-500", label: "Cancelled" }, +} + +const taskStatusColors: Record = { + pending: "bg-zinc-500", + in_progress: "bg-blue-500", + completed: "bg-emerald-500", + cancelled: "bg-red-500", +} + +export default function ProjectDetailPage() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + const [project, setProject] = useState(null) + const [milestones, setMilestones] = useState([]) + const [statuses, setStatuses] = useState([]) + const [loading, setLoading] = useState(true) + const [expandedMilestones, setExpandedMilestones] = useState>(new Set()) + const [newMilestoneName, setNewMilestoneName] = useState("") + const [addingMilestone, setAddingMilestone] = useState(false) + const [newTaskInputs, setNewTaskInputs] = useState>({}) + + const fetchProject = useCallback(async () => { + try { + const [pRes, mRes, sRes] = await Promise.all([ + fetch(`/api/projects/${id}`), + fetch(`/api/projects/${id}/milestones`), + fetch("/api/project-statuses"), + ]) + setProject(await pRes.json()) + setMilestones(await mRes.json()) + setStatuses(await sRes.json()) + } catch (e) { + console.error("Failed to load project", e) + } finally { + setLoading(false) + } + }, [id]) + + useEffect(() => { fetchProject() }, [fetchProject]) + + const updateProjectStatus = async (statusId: string) => { + await fetch(`/api/projects/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ statusId }), + }) + fetchProject() + } + + const updateMilestoneStatus = async (milestoneId: string, status: string) => { + await fetch(`/api/milestones/${milestoneId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }) + fetchProject() + } + + const addMilestone = async () => { + if (!newMilestoneName.trim()) return + setAddingMilestone(true) + try { + await fetch(`/api/projects/${id}/milestones`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newMilestoneName.trim() }), + }) + setNewMilestoneName("") + fetchProject() + } finally { + setAddingMilestone(false) + } + } + + const addTask = async (milestoneId: string) => { + const title = newTaskInputs[milestoneId]?.trim() + if (!title) return + try { + await fetch(`/api/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + projectId: id, + milestoneId, + }), + }) + setNewTaskInputs((prev) => ({ ...prev, [milestoneId]: "" })) + fetchProject() + } catch (e) { + console.error("Failed to add task", e) + } + } + + const toggleMilestone = (msId: string) => { + setExpandedMilestones((prev) => { + const next = new Set(prev) + if (next.has(msId)) next.delete(msId) + else next.add(msId) + return next + }) + } + + const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—" + const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : "" + + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map((i) =>
)} +
+
+ ) + } + + if (!project) return
Project not found
+ + const now = new Date() + const start = project.start_date ? new Date(project.start_date) : now + const end = project.target_end_date ? new Date(project.target_end_date) : new Date(start.getTime() + 60 * 86400000) + const totalDays = Math.max(1, (end.getTime() - start.getTime()) / 86400000) + + const completedMilestones = milestones.filter((m) => m.status === "completed").length + const progress = milestones.length ? Math.round((completedMilestones / milestones.length) * 100) : 0 + + return ( +
+ + + + + {/* Overview Cards */} +
+ +

Customer

+

{project.company_name}

+
+ +

Owner

+

{project.owner_name}

+
+ +

Timeline

+

{formatDate(project.start_date)} — {formatDate(project.target_end_date)}

+
+ +

Budget

+

{project.budget ? formatCurrency(project.budget) : "—"}

+
+
+ + {/* Status & Progress */} + +
+
+

Status

+ +
+
+

{progress}% complete

+
+
+
+
+
+ + + {/* Gantt Chart */} + +

Timeline

+
+
+ {milestones.map((ms) => { + const msStart = ms.start_date ? new Date(ms.start_date) : start + const msEnd = ms.due_date ? new Date(ms.due_date) : end + const left = Math.max(0, ((msStart.getTime() - start.getTime()) / 86400000) / totalDays * 100) + const width = Math.max(3, ((msEnd.getTime() - msStart.getTime()) / 86400000) / totalDays * 100) + const config = milestoneStatusConfig[ms.status] || milestoneStatusConfig.pending + return ( +
+ {ms.name} +
+
+
+ {ms.name} +
+
+
+ ) + })} +
+
+ + + {/* Custom Fields */} + {project.custom_fields && Object.keys(project.custom_fields).length > 0 && ( + + )} + + {/* Milestones */} + +
+

Milestones ({milestones.length})

+
+ setNewMilestoneName(e.target.value)} + className="h-8 w-56 text-sm" + onKeyDown={(e) => e.key === "Enter" && addMilestone()} + /> + +
+
+ + {milestones.length === 0 && ( +
No milestones yet
+ )} + + {milestones.map((ms) => { + const config = milestoneStatusConfig[ms.status] || milestoneStatusConfig.pending + const isExpanded = expandedMilestones.has(ms.id) + + return ( +
+
toggleMilestone(ms.id)} + > +
+ {isExpanded ? : } + + {ms.name} + {config.label} +
+
+ {ms.start_date && {formatDate(ms.start_date)}} + {ms.start_date && ms.due_date && } + {ms.due_date && {formatDate(ms.due_date)}} +
+ +
+ + {isExpanded && ( +
+ {ms.description && ( +

{ms.description}

+ )} + {ms.tasks.length === 0 && ( +

No tasks yet

+ )} + {ms.tasks.map((task) => ( +
+ + {task.title} + {task.status.replace("_", " ")} +
+ ))} +
+ setNewTaskInputs((prev) => ({ ...prev, [ms.id]: e.target.value }))} + className="h-7 text-xs flex-1" + onKeyDown={(e) => e.key === "Enter" && addTask(ms.id)} + /> + +
+
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/src/app/(dashboard)/projects/page.tsx b/src/app/(dashboard)/projects/page.tsx new file mode 100644 index 0000000..a6a9be1 --- /dev/null +++ b/src/app/(dashboard)/projects/page.tsx @@ -0,0 +1,273 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { PageHeader } from "@/components/shared/page-header" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card } from "@/components/ui/card" +import { Search, Plus, Calendar, User, Building2, ArrowRight, DollarSign } from "lucide-react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" + +interface Project { + id: string + name: string + description: string + start_date: string + target_end_date: string + actual_end_date: string + budget: number + created_at: string + status_name: string + status_color: string + customer_id: string + company_name: string + owner_id: string + owner_name: string + owner_avatar: string +} + +interface ProjectStatus { + id: string + name: string + color: string + sort_order: number +} + +export default function ProjectsPage() { + const router = useRouter() + const [projects, setProjects] = useState([]) + const [statuses, setStatuses] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + + const fetchProjects = async () => { + try { + const params = new URLSearchParams() + if (search) params.set("search", search) + if (statusFilter !== "all") params.set("status", statusFilter) + const res = await fetch(`/api/projects?${params}`) + setProjects(await res.json()) + } catch (e) { + console.error("Failed to load projects", e) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchProjects() }, [search, statusFilter]) + + useEffect(() => { + fetch("/api/project-statuses").then(r => r.json()).then(setStatuses).catch(() => {}) + }, []) + + const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—" + const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : "" + + return ( +
+ + fetchProjects()} /> + + + +
+
+
+ + setSearch(e.target.value)} + className="h-9 pl-9 w-full sm:max-w-sm" + /> +
+ +
+
+ +
+ {loading ? ( +
Loading...
+ ) : projects.length === 0 ? ( +
No projects yet
+ ) : ( + projects.map((p) => ( +
router.push(`/projects/${p.id}`)} + > +
+
+ {p.name} + + {p.status_name} + +
+
+ {p.company_name} + {p.owner_name} + {formatDate(p.start_date)} — {formatDate(p.target_end_date)} + {p.budget > 0 && {formatCurrency(p.budget)}} +
+
+ +
+ )) + )} +
+
+
+ ) +} + +function NewProjectDialog({ statuses, onCreated }: { statuses: ProjectStatus[]; onCreated: () => void }) { + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [customerId, setCustomerId] = useState("") + const [ownerId, setOwnerId] = useState("") + const [startDate, setStartDate] = useState("") + const [targetEndDate, setTargetEndDate] = useState("") + const [budget, setBudget] = useState("") + const [customers, setCustomers] = useState([]) + const [users, setUsers] = useState([]) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!open) return + fetch("/api/customers?limit=100").then(r => r.json()).then(setCustomers).catch(() => {}) + fetch("/api/users").then(r => r.json()).then(setUsers).catch(() => {}) + }, [open]) + + const handleSubmit = async () => { + if (!name || !customerId) return + setSaving(true) + try { + const res = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description, + customerId, + ownerId: ownerId || undefined, + startDate: startDate || null, + targetEndDate: targetEndDate || null, + budget: budget ? parseFloat(budget) : null, + }), + }) + if (res.ok) { + setOpen(false) + setName(""); setDescription(""); setCustomerId(""); setOwnerId(""); setStartDate(""); setTargetEndDate(""); setBudget("") + onCreated() + } + } finally { + setSaving(false) + } + } + + return ( + + + + + + + Create Project + +
+
+ + setName(e.target.value)} placeholder="e.g. Website Redesign" /> +
+
+ + +
+
+ + +
+
+
+ + setStartDate(e.target.value)} /> +
+
+ + setTargetEndDate(e.target.value)} /> +
+
+
+ + setBudget(e.target.value)} placeholder="e.g. 25000" /> +
+
+ +