Kanban board, Project Management, Proposals/Quotes, Time Tracking, Custom Fields
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
- 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
This commit is contained in:
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Generated
+56
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, { header: string; border: string; dot: string }> = {
|
||||
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<string, string> = {
|
||||
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<KanbanColumn[]>([])
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Kanban Board" description="Drag and drop leads to update their stage" />
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex h-96 w-72 shrink-0 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Kanban Board"
|
||||
description="Drag and drop leads between stages to update their status"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/leads")}>
|
||||
Table View
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => router.push("/leads/new")}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New Lead
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-4" style={{ minHeight: "calc(100vh - 12rem)" }}>
|
||||
{columns.map((col) => {
|
||||
const colors = statusColors[col.status] || statusColors.open
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className="flex w-72 shrink-0 flex-col rounded-lg border bg-card"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(col.status)}
|
||||
>
|
||||
<div className={`flex items-center justify-between rounded-t-lg border-b px-3 py-2.5 ${colors.header}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${colors.dot}`} />
|
||||
<span className="text-sm font-semibold">{col.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({col.leads.length})</span>
|
||||
</div>
|
||||
{col.probability > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{col.probability}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-2">
|
||||
{col.leads.length === 0 && (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">No leads</p>
|
||||
</div>
|
||||
)}
|
||||
{col.leads.map((lead) => (
|
||||
<LeadCard
|
||||
key={lead.id}
|
||||
lead={lead}
|
||||
status={col.status}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => router.push(`/leads/${lead.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card
|
||||
draggable
|
||||
onDragStart={() => onDragStart(lead.id, status)}
|
||||
onClick={onClick}
|
||||
className="cursor-grab active:cursor-grabbing p-3 space-y-2 transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar className="h-7 w-7 shrink-0">
|
||||
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{lead.contact_name}</p>
|
||||
{lead.company_name && (
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground truncate">
|
||||
<Building2 className="h-3 w-3 shrink-0" />
|
||||
{lead.company_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{lead.email && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||
<Mail className="h-3 w-3 shrink-0" />
|
||||
{lead.email}
|
||||
</p>
|
||||
)}
|
||||
{lead.phone && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||
<Phone className="h-3 w-3 shrink-0" />
|
||||
{lead.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lead.score > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Hash className="h-3 w-3 text-muted-foreground/60" />
|
||||
<div className="flex h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="rounded-full transition-all"
|
||||
style={{
|
||||
width: `${lead.score}%`,
|
||||
backgroundColor:
|
||||
lead.score >= 70 ? "#22c55e" : lead.score >= 40 ? "#eab308" : "#ef4444",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">{lead.score}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<PageHeader
|
||||
title="Leads"
|
||||
description="Manage and track your sales leads"
|
||||
/>
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/leads/kanban")}>
|
||||
<Columns3 className="mr-1.5 h-4 w-4" /> Kanban
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4">
|
||||
|
||||
@@ -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<string, { icon: any; class: string; label: string }> = {
|
||||
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<string, string> = {
|
||||
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<any>(null)
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||
const [statuses, setStatuses] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedMilestones, setExpandedMilestones] = useState<Set<string>>(new Set())
|
||||
const [newMilestoneName, setNewMilestoneName] = useState("")
|
||||
const [addingMilestone, setAddingMilestone] = useState(false)
|
||||
const [newTaskInputs, setNewTaskInputs] = useState<Record<string, string>>({})
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="h-10 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => <div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={project.name} description={`Project · ${project.company_name}`}>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/projects")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Customer</p>
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium"><Building2 className="h-4 w-4 text-muted-foreground" />{project.company_name}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Owner</p>
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium"><User className="h-4 w-4 text-muted-foreground" />{project.owner_name}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Timeline</p>
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium"><Calendar className="h-4 w-4 text-muted-foreground" />{formatDate(project.start_date)} — {formatDate(project.target_end_date)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Budget</p>
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium"><DollarSign className="h-4 w-4 text-muted-foreground" />{project.budget ? formatCurrency(project.budget) : "—"}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Status & Progress */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-medium">Status</p>
|
||||
<Select value={project.status_id} onValueChange={updateProjectStatus}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">{progress}% complete</p>
|
||||
<div className="h-2 w-32 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gantt Chart */}
|
||||
<Card className="p-4">
|
||||
<p className="text-sm font-medium mb-3">Timeline</p>
|
||||
<div className="relative overflow-x-auto">
|
||||
<div className="min-w-[500px] space-y-2">
|
||||
{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 (
|
||||
<div key={ms.id} className="flex items-center gap-3">
|
||||
<span className="w-48 text-xs truncate shrink-0">{ms.name}</span>
|
||||
<div className="relative flex-1 h-6">
|
||||
<div className="absolute inset-0 rounded bg-muted/30" />
|
||||
<div
|
||||
className={`absolute top-1 h-4 rounded transition-all ${config.class.replace("text-", "bg-").replace("500", "500/80")}`}
|
||||
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: ms.status === "completed" ? "#22c55e" : ms.status === "in_progress" ? "#3b82f6" : ms.status === "cancelled" ? "#ef4444" : "#a1a1aa" }}
|
||||
>
|
||||
<span className="px-2 text-[10px] leading-4 text-white block truncate opacity-80">{ms.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{project.custom_fields && Object.keys(project.custom_fields).length > 0 && (
|
||||
<CustomFieldsSection
|
||||
entityType="project"
|
||||
customFields={project.custom_fields}
|
||||
entityId={project.id}
|
||||
apiEndpoint={`/api/projects/${project.id}`}
|
||||
title="Custom Fields"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Milestones */}
|
||||
<Card className="divide-y">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<p className="text-sm font-medium">Milestones ({milestones.length})</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New milestone name..."
|
||||
value={newMilestoneName}
|
||||
onChange={(e) => setNewMilestoneName(e.target.value)}
|
||||
className="h-8 w-56 text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && addMilestone()}
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={addMilestone} disabled={addingMilestone || !newMilestoneName.trim()}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{milestones.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">No milestones yet</div>
|
||||
)}
|
||||
|
||||
{milestones.map((ms) => {
|
||||
const config = milestoneStatusConfig[ms.status] || milestoneStatusConfig.pending
|
||||
const isExpanded = expandedMilestones.has(ms.id)
|
||||
|
||||
return (
|
||||
<div key={ms.id}>
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => toggleMilestone(ms.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<config.icon className={`h-4 w-4 ${config.class}`} />
|
||||
<span className="text-sm font-medium truncate">{ms.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5">{config.label}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
{ms.start_date && <span>{formatDate(ms.start_date)}</span>}
|
||||
{ms.start_date && ms.due_date && <span>→</span>}
|
||||
{ms.due_date && <span>{formatDate(ms.due_date)}</span>}
|
||||
</div>
|
||||
<Select
|
||||
value={ms.status}
|
||||
onValueChange={(v) => updateMilestoneStatus(ms.id, v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/20 px-4 py-3 space-y-2">
|
||||
{ms.description && (
|
||||
<p className="text-xs text-muted-foreground">{ms.description}</p>
|
||||
)}
|
||||
{ms.tasks.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No tasks yet</p>
|
||||
)}
|
||||
{ms.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-2 pl-2">
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${taskStatusColors[task.status] || "bg-zinc-400"}`} />
|
||||
<span className="text-xs flex-1">{task.title}</span>
|
||||
<span className="text-[10px] text-muted-foreground capitalize">{task.status.replace("_", " ")}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Input
|
||||
placeholder="Add task..."
|
||||
value={newTaskInputs[ms.id] || ""}
|
||||
onChange={(e) => setNewTaskInputs((prev) => ({ ...prev, [ms.id]: e.target.value }))}
|
||||
className="h-7 text-xs flex-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && addTask(ms.id)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => addTask(ms.id)}
|
||||
disabled={!newTaskInputs[ms.id]?.trim()}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" /> Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Project[]>([])
|
||||
const [statuses, setStatuses] = useState<ProjectStatus[]>([])
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Projects" description="Track post-sale project delivery">
|
||||
<NewProjectDialog statuses={statuses} onCreated={() => fetchProjects()} />
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-1 flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects or companies..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-9 w-full sm:max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-9 w-[160px]">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{statuses.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No projects yet</div>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center gap-4 px-6 py-4 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/projects/${p.id}`)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{p.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
style={{ borderColor: p.status_color + "40", color: p.status_color }}
|
||||
>
|
||||
{p.status_name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Building2 className="h-3.5 w-3.5" />{p.company_name}</span>
|
||||
<span className="flex items-center gap-1"><User className="h-3.5 w-3.5" />{p.owner_name}</span>
|
||||
<span className="flex items-center gap-1"><Calendar className="h-3.5 w-3.5" />{formatDate(p.start_date)} — {formatDate(p.target_end_date)}</span>
|
||||
{p.budget > 0 && <span className="flex items-center gap-1"><DollarSign className="h-3.5 w-3.5" />{formatCurrency(p.budget)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<any[]>([])
|
||||
const [users, setUsers] = useState<any[]>([])
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="mr-1.5 h-4 w-4" />New Project</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Project Name *</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Website Redesign" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Customer *</label>
|
||||
<select
|
||||
value={customerId}
|
||||
onChange={(e) => setCustomerId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Select customer...</option>
|
||||
{customers.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Project Owner</label>
|
||||
<select
|
||||
value={ownerId}
|
||||
onChange={(e) => setOwnerId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Select owner...</option>
|
||||
{users.map((u: any) => (
|
||||
<option key={u.id} value={u.id}>{u.first_name} {u.last_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Target End Date</label>
|
||||
<Input type="date" value={targetEndDate} onChange={(e) => setTargetEndDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Budget (ZAR)</label>
|
||||
<Input type="number" value={budget} onChange={(e) => setBudget(e.target.value)} placeholder="e.g. 25000" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm"
|
||||
placeholder="Project scope and notes..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving || !name || !customerId}>
|
||||
{saving ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"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 { ArrowLeft, FileText, CheckCircle2, Clock, User, Mail, Building2, Calendar } from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export default function ProposalDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [proposal, setProposal] = useState<any>(null)
|
||||
const [statuses, setStatuses] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [signName, setSignName] = useState("")
|
||||
const [signEmail, setSignEmail] = useState("")
|
||||
const [signing, setSigning] = useState(false)
|
||||
|
||||
const fetchProposal = useCallback(async () => {
|
||||
try {
|
||||
const [pRes, sRes] = await Promise.all([
|
||||
fetch(`/api/proposals/${id}`),
|
||||
fetch("/api/proposal-statuses"),
|
||||
])
|
||||
setProposal(await pRes.json())
|
||||
setStatuses(await sRes.json())
|
||||
} catch (e) {
|
||||
console.error("Failed to load proposal", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { fetchProposal() }, [fetchProposal])
|
||||
|
||||
const updateStatus = async (statusId: string) => {
|
||||
await fetch(`/api/proposals/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ statusId }),
|
||||
})
|
||||
fetchProposal()
|
||||
}
|
||||
|
||||
const handleSign = async () => {
|
||||
if (!signName || !signEmail) return
|
||||
setSigning(true)
|
||||
try {
|
||||
const res = await fetch(`/api/proposals/${id}/sign`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: signName, email: signEmail }),
|
||||
})
|
||||
if (res.ok) fetchProposal()
|
||||
} finally {
|
||||
setSigning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString(undefined, { minimumFractionDigits: 2 })}` : "R0.00"
|
||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString() : "—"
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
if (!proposal) return <div className="p-8 text-center text-muted-foreground">Proposal not found</div>
|
||||
|
||||
const canSign = proposal.status_name === "Sent" || proposal.status_name === "Viewed"
|
||||
const isAccepted = proposal.status_name === "Accepted"
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<PageHeader
|
||||
title={proposal.title}
|
||||
description={`${proposal.proposal_number} · Created by ${proposal.created_by_name}`}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/proposals")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Status bar */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Status</span>
|
||||
<Select value={proposal.status_id} onValueChange={updateStatus}>
|
||||
<SelectTrigger className="h-8 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((s: any) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Badge variant="outline" style={{ borderColor: proposal.status_color + "40", color: proposal.status_color }}>
|
||||
{proposal.status_name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span><Calendar className="h-3.5 w-3.5 inline mr-1" />Created {formatDate(proposal.created_at)}</span>
|
||||
{proposal.valid_until && <span>Valid until {formatDate(proposal.valid_until)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Client info */}
|
||||
{(proposal.lead_company || proposal.company_name) && (
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground mb-1">Client</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
{proposal.lead_company || proposal.company_name}
|
||||
</p>
|
||||
{proposal.lead_contact && <p className="text-sm text-muted-foreground ml-6">{proposal.lead_contact}</p>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Line Items */}
|
||||
<Card className="divide-y">
|
||||
<div className="px-4 py-3 font-medium text-sm">Services</div>
|
||||
{proposal.items?.length === 0 && (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">No items</div>
|
||||
)}
|
||||
{proposal.items?.map((item: any) => (
|
||||
<div key={item.id} className="flex items-center px-4 py-3 text-sm">
|
||||
<div className="flex-1">
|
||||
<p>{item.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.quantity} × {formatCurrency(item.unit_price)}
|
||||
{item.discount_percent > 0 && ` (${item.discount_percent}% off)`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium">{formatCurrency(item.total_price)}</p>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* Totals */}
|
||||
<Card className="p-4">
|
||||
<div className="ml-auto w-64 space-y-1 text-sm">
|
||||
<div className="flex justify-between"><span>Subtotal</span><span>{formatCurrency(proposal.subtotal)}</span></div>
|
||||
<div className="flex justify-between"><span>VAT ({proposal.tax_percent}%)</span><span>{formatCurrency(proposal.tax_amount)}</span></div>
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2"><span>Total</span><span>{formatCurrency(proposal.total_amount)}</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Notes & Terms */}
|
||||
{proposal.notes && (
|
||||
<Card className="p-4">
|
||||
<p className="text-sm font-medium mb-1">Notes</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{proposal.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
{proposal.terms && (
|
||||
<Card className="p-4">
|
||||
<p className="text-sm font-medium mb-1">Terms & Conditions</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{proposal.terms}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* E-Signature */}
|
||||
<Card className="p-6">
|
||||
<p className="text-sm font-medium mb-1">Digital Signature</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{isAccepted ? "This proposal has been signed and accepted." : "Sign below to accept this proposal."}
|
||||
</p>
|
||||
|
||||
{proposal.signatures?.length > 0 && (
|
||||
<div className="mb-4 space-y-2">
|
||||
{proposal.signatures.map((sig: any) => (
|
||||
<div key={sig.id} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="font-medium">{sig.signatory_name}</span>
|
||||
<span className="text-muted-foreground">({sig.signatory_email})</span>
|
||||
<span className="text-muted-foreground text-xs">— {new Date(sig.signed_at).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canSign && (
|
||||
<div className="space-y-3 max-w-sm">
|
||||
<Input placeholder="Full Name" value={signName} onChange={(e) => setSignName(e.target.value)} />
|
||||
<Input placeholder="Email Address" type="email" value={signEmail} onChange={(e) => setSignEmail(e.target.value)} />
|
||||
<Button onClick={handleSign} disabled={signing || !signName || !signEmail}>
|
||||
{signing ? "Signing..." : <><CheckCircle2 className="mr-1.5 h-4 w-4" /> Accept & Sign</>}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Plus, Trash2, ArrowLeft, FileText } from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface LineItem {
|
||||
id: string
|
||||
description: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
discountPercent: number
|
||||
totalPrice: number
|
||||
}
|
||||
|
||||
export default function NewProposalPage() {
|
||||
const router = useRouter()
|
||||
const [title, setTitle] = useState("")
|
||||
const [leadId, setLeadId] = useState("")
|
||||
const [customerId, setCustomerId] = useState("")
|
||||
const [validUntil, setValidUntil] = useState("")
|
||||
const [taxPercent, setTaxPercent] = useState(15)
|
||||
const [notes, setNotes] = useState("")
|
||||
const [terms, setTerms] = useState("Payment due within 30 days. 50% deposit required to commence work.")
|
||||
const [items, setItems] = useState<LineItem[]>([])
|
||||
const [leads, setLeads] = useState<any[]>([])
|
||||
const [customers, setCustomers] = useState<any[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/leads?limit=100").then((r) => r.json()).then(setLeads).catch(() => {})
|
||||
fetch("/api/customers?limit=100").then((r) => r.json()).then(setCustomers).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const addItem = () => {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), description: "", quantity: 1, unitPrice: 0, discountPercent: 0, totalPrice: 0 },
|
||||
])
|
||||
}
|
||||
|
||||
const updateItem = (id: string, field: keyof LineItem, value: string | number) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) return item
|
||||
const updated = { ...item, [field]: value }
|
||||
updated.totalPrice = updated.quantity * updated.unitPrice * (1 - updated.discountPercent / 100)
|
||||
return updated
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setItems((prev) => prev.filter((i) => i.id !== id))
|
||||
}
|
||||
|
||||
const subtotal = items.reduce((sum, i) => sum + i.totalPrice, 0)
|
||||
const taxAmount = subtotal * (taxPercent / 100)
|
||||
const total = subtotal + taxAmount
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/proposals", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
leadId: leadId || null,
|
||||
customerId: customerId || null,
|
||||
subtotal,
|
||||
taxPercent,
|
||||
taxAmount,
|
||||
totalAmount: total,
|
||||
validUntil: validUntil || null,
|
||||
notes: notes || null,
|
||||
terms: terms || null,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.id) throw new Error("No id returned")
|
||||
|
||||
for (const item of items) {
|
||||
await fetch(`/api/proposals/${data.id}/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
discountPercent: item.discountPercent,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
router.push(`/proposals/${data.id}`)
|
||||
} catch (e) {
|
||||
console.error("Failed to create proposal", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (n: number) => `R${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<PageHeader title="New Proposal" description="Create a professional quote for your client">
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/proposals")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Proposal Title *</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Website Redesign Quote" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Valid Until</label>
|
||||
<Input type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Lead (optional)</label>
|
||||
<select value={leadId} onChange={(e) => setLeadId(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
<option value="">Select lead...</option>
|
||||
{leads.map((l: any) => (
|
||||
<option key={l.id} value={l.id}>{l.contact_name || l.company_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Customer (optional)</label>
|
||||
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
<option value="">Select customer...</option>
|
||||
{customers.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.company_name || `${c.first_name} ${c.last_name}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">VAT %</label>
|
||||
<Input type="number" value={taxPercent} onChange={(e) => setTaxPercent(parseFloat(e.target.value) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Items */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium">Line Items</label>
|
||||
<Button size="sm" variant="outline" onClick={addItem}><Plus className="h-3.5 w-3.5 mr-1" /> Add Item</Button>
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No items yet. Click "Add Item" to add services or products.</p>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 mb-2 p-3 rounded-lg border bg-muted/20">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.id, "description", e.target.value)}
|
||||
placeholder="Service description..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-20">
|
||||
<label className="text-[10px] text-muted-foreground">Qty</label>
|
||||
<Input type="number" min="1" value={item.quantity} onChange={(e) => updateItem(item.id, "quantity", parseInt(e.target.value) || 1)} className="h-7 text-xs" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">Unit Price (ZAR)</label>
|
||||
<Input type="number" min="0" value={item.unitPrice} onChange={(e) => updateItem(item.id, "unitPrice", parseFloat(e.target.value) || 0)} className="h-7 text-xs" />
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="text-[10px] text-muted-foreground">Discount %</label>
|
||||
<Input type="number" min="0" max="100" value={item.discountPercent} onChange={(e) => updateItem(item.id, "discountPercent", parseFloat(e.target.value) || 0)} className="h-7 text-xs" />
|
||||
</div>
|
||||
<div className="w-28 text-right">
|
||||
<label className="text-[10px] text-muted-foreground">Total</label>
|
||||
<p className="text-sm font-medium pt-1">{formatCurrency(item.totalPrice)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 mt-5 shrink-0" onClick={() => removeItem(item.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="ml-auto w-64 space-y-1 text-sm">
|
||||
<div className="flex justify-between"><span>Subtotal</span><span>{formatCurrency(subtotal)}</span></div>
|
||||
<div className="flex justify-between"><span>VAT ({taxPercent}%)</span><span>{formatCurrency(taxAmount)}</span></div>
|
||||
<div className="flex justify-between text-base font-bold border-t pt-1"><span>Total</span><span>{formatCurrency(total)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes & Terms */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Notes</label>
|
||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="Additional notes for the client..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Terms & Conditions</label>
|
||||
<textarea value={terms} onChange={(e) => setTerms(e.target.value)} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => router.push("/proposals")}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving || !title}>
|
||||
{saving ? "Creating..." : <><FileText className="mr-1.5 h-4 w-4" /> Create Proposal</>}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"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, ArrowRight, FileText, Calendar, User } from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export default function ProposalsPage() {
|
||||
const router = useRouter()
|
||||
const [proposals, setProposals] = useState<any[]>([])
|
||||
const [statuses, setStatuses] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
fetch(`/api/proposals?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then(setProposals)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [search, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/proposal-statuses").then((r) => r.json()).then(setStatuses).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const formatCurrency = (n: number) => n ? `R${Number(n).toLocaleString()}` : "R0"
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Proposals & Quotes" description="Create and send professional quotes to clients">
|
||||
<Button size="sm" onClick={() => router.push("/proposals/new")}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New Proposal
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-1 flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search proposals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-9 w-full sm:max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-9 w-[160px]">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{statuses.map((s: any) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : proposals.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No proposals yet</div>
|
||||
) : (
|
||||
proposals.map((p: any) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center gap-4 px-6 py-4 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/proposals/${p.id}`)}
|
||||
>
|
||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{p.title}</span>
|
||||
<Badge variant="outline" className="shrink-0 text-xs" style={{ borderColor: p.status_color + "40", color: p.status_color }}>
|
||||
{p.status_name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><User className="h-3.5 w-3.5" />{p.created_by_name}</span>
|
||||
<span>{p.proposal_number}</span>
|
||||
<span className="font-medium">{formatCurrency(p.total_amount)}</span>
|
||||
{p.valid_until && (
|
||||
<span className="flex items-center gap-1"><Calendar className="h-3.5 w-3.5" />Valid until {new Date(p.valid_until).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { CompanySettingsForm } from "@/components/settings/company-settings-form
|
||||
import { UserPreferencesForm } from "@/components/settings/user-preferences-form"
|
||||
import { ThemeSettings } from "@/components/settings/theme-settings"
|
||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||
import { Building2, User, Palette, Bell } from "lucide-react"
|
||||
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
|
||||
import { Building2, User, Palette, Bell, ListChecks } from "lucide-react"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const tabs = [
|
||||
@@ -14,6 +15,7 @@ export default function SettingsPage() {
|
||||
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
|
||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Play, Square, Plus, Trash2, Clock, Briefcase, User, TrendingUp, DollarSign } from "lucide-react"
|
||||
|
||||
interface TimeEntry {
|
||||
id: string
|
||||
description: string
|
||||
date: string
|
||||
duration_minutes: number
|
||||
billable: boolean
|
||||
hourly_rate: number
|
||||
user_name: string
|
||||
project_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function TimeTrackingPage() {
|
||||
const [entries, setEntries] = useState<TimeEntry[]>([])
|
||||
const [summary, setSummary] = useState<any>(null)
|
||||
const [projects, setProjects] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
|
||||
// Manual entry form
|
||||
const [manualProject, setManualProject] = useState("")
|
||||
const [manualDesc, setManualDesc] = useState("")
|
||||
const [manualDate, setManualDate] = useState(new Date().toISOString().split("T")[0])
|
||||
const [manualHours, setManualHours] = useState("")
|
||||
const [manualMinutes, setManualMinutes] = useState("")
|
||||
const [manualBillable, setManualBillable] = useState(true)
|
||||
const [manualRate, setManualRate] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Timer
|
||||
const [timerRunning, setTimerRunning] = useState(false)
|
||||
const [timerSeconds, setTimerSeconds] = useState(0)
|
||||
const [timerProject, setTimerProject] = useState("")
|
||||
const [timerDesc, setTimerDesc] = useState("")
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const startTimeRef = useRef<Date | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [eRes, sRes, pRes] = await Promise.all([
|
||||
fetch("/api/time-entries"),
|
||||
fetch("/api/time-entries/summary"),
|
||||
fetch("/api/projects"),
|
||||
])
|
||||
setEntries(await eRes.json())
|
||||
setSummary(await sRes.json())
|
||||
setProjects(await pRes.json())
|
||||
} catch (e) {
|
||||
console.error("Failed to load time data", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const startTimer = () => {
|
||||
if (!timerDesc.trim()) return
|
||||
setTimerRunning(true)
|
||||
setTimerSeconds(0)
|
||||
startTimeRef.current = new Date()
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimerSeconds((prev) => prev + 1)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const stopTimer = async () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
setTimerRunning(false)
|
||||
|
||||
if (timerSeconds < 60) return
|
||||
|
||||
try {
|
||||
await fetch("/api/time-entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId: timerProject || null,
|
||||
description: timerDesc,
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
startTime: startTimeRef.current?.toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
durationMinutes: Math.round(timerSeconds / 60),
|
||||
billable: true,
|
||||
hourlyRate: null,
|
||||
}),
|
||||
})
|
||||
setTimerDesc("")
|
||||
setTimerProject("")
|
||||
setTimerSeconds(0)
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
console.error("Failed to save time entry", e)
|
||||
}
|
||||
}
|
||||
|
||||
const addManualEntry = async () => {
|
||||
const hours = parseInt(manualHours) || 0
|
||||
const mins = parseInt(manualMinutes) || 0
|
||||
const totalMins = hours * 60 + mins
|
||||
if (totalMins <= 0 || !manualDesc.trim()) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch("/api/time-entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId: manualProject || null,
|
||||
description: manualDesc,
|
||||
date: manualDate,
|
||||
durationMinutes: totalMins,
|
||||
billable: manualBillable,
|
||||
hourlyRate: manualRate ? parseFloat(manualRate) : null,
|
||||
}),
|
||||
})
|
||||
setManualDesc("")
|
||||
setManualHours("")
|
||||
setManualMinutes("")
|
||||
setManualRate("")
|
||||
setShowManual(false)
|
||||
fetchData()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEntry = async (id: string) => {
|
||||
await fetch(`/api/time-entries?id=${id}`, { method: "DELETE" })
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const formatDuration = (mins: number) => {
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
const formatTimer = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const s = secs % 60
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const formatCurrency = (n: number) => `R${Number(n).toLocaleString(undefined, { minimumFractionDigits: 2 })}`
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Time Tracking" description="Track billable hours" />
|
||||
<div className="grid gap-4 md:grid-cols-4">{[1, 2, 3, 4].map((i) => <div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const t = summary?.totals || { total_minutes: 0, billable_minutes: 0, total_revenue: 0, entry_count: 0 }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Time Tracking" description="Track billable hours per project" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Total Hours</p>
|
||||
<p className="text-2xl font-bold">{formatDuration(parseInt(t.total_minutes))}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Billable Hours</p>
|
||||
<p className="text-2xl font-bold">{formatDuration(parseInt(t.billable_minutes))}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Revenue</p>
|
||||
<p className="text-2xl font-bold">{formatCurrency(parseFloat(t.total_revenue))}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Entries</p>
|
||||
<p className="text-2xl font-bold">{t.entry_count}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<Card className="p-6">
|
||||
<p className="text-sm font-medium mb-3 flex items-center gap-2"><Clock className="h-4 w-4" /> Stopwatch</p>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input placeholder="What are you working on?" value={timerDesc} onChange={(e) => setTimerDesc(e.target.value)} className="h-9" disabled={timerRunning} />
|
||||
<select value={timerProject} onChange={(e) => setTimerProject(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm" disabled={timerRunning}>
|
||||
<option value="">No project</option>
|
||||
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-3xl font-mono font-bold tabular-nums px-4 py-1">{formatTimer(timerSeconds)}</div>
|
||||
{!timerRunning ? (
|
||||
<Button onClick={startTimer} disabled={!timerDesc.trim()}><Play className="mr-1.5 h-4 w-4" /> Start</Button>
|
||||
) : (
|
||||
<Button variant="destructive" onClick={stopTimer}><Square className="mr-1.5 h-4 w-4" /> Stop</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Manual Entry */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium">Manual Entry</p>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowManual(!showManual)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> {showManual ? "Cancel" : "Add Entry"}
|
||||
</Button>
|
||||
</div>
|
||||
{showManual && (
|
||||
<div className="space-y-3 p-3 rounded-lg border bg-muted/20">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input placeholder="Description *" value={manualDesc} onChange={(e) => setManualDesc(e.target.value)} className="h-8 text-sm" />
|
||||
<select value={manualProject} onChange={(e) => setManualProject(e.target.value)} className="flex h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-sm">
|
||||
<option value="">No project</option>
|
||||
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Input type="date" value={manualDate} onChange={(e) => setManualDate(e.target.value)} className="h-8 w-40 text-sm" />
|
||||
<Input type="number" placeholder="Hours" value={manualHours} onChange={(e) => setManualHours(e.target.value)} className="h-8 w-24 text-sm" min="0" />
|
||||
<Input type="number" placeholder="Minutes" value={manualMinutes} onChange={(e) => setManualMinutes(e.target.value)} className="h-8 w-24 text-sm" min="0" />
|
||||
<Input type="number" placeholder="Rate (ZAR/hr)" value={manualRate} onChange={(e) => setManualRate(e.target.value)} className="h-8 w-32 text-sm" min="0" />
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={manualBillable} onChange={(e) => setManualBillable(e.target.checked)} className="rounded" />
|
||||
Billable
|
||||
</label>
|
||||
</div>
|
||||
<Button size="sm" onClick={addManualEntry} disabled={saving || !manualDesc.trim()}>
|
||||
{saving ? "Saving..." : "Save Entry"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Per-project summary */}
|
||||
{summary?.perProject?.length > 0 && (
|
||||
<Card className="p-4">
|
||||
<p className="text-sm font-medium mb-3 flex items-center gap-2"><Briefcase className="h-4 w-4" /> Hours by Project</p>
|
||||
<div className="space-y-2">
|
||||
{summary.perProject.map((p: any) => (
|
||||
<div key={p.id} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-48 truncate">{p.name}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${Math.min(100, (parseInt(p.total_minutes) / parseInt(t.total_minutes)) * 100)}%` }} />
|
||||
</div>
|
||||
<span className="w-20 text-right">{formatDuration(parseInt(p.total_minutes))}</span>
|
||||
<span className="w-24 text-right text-muted-foreground">{formatCurrency(parseFloat(p.revenue))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Entries */}
|
||||
<Card className="divide-y">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<p className="text-sm font-medium">Recent Entries</p>
|
||||
</div>
|
||||
{entries.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">No entries yet</div>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-4 py-3 text-sm hover:bg-muted/30">
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${entry.billable ? "bg-emerald-500" : "bg-zinc-400"}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate">{entry.description || "No description"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entry.project_name && <>{entry.project_name} · </>}
|
||||
{entry.user_name} · {new Date(entry.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-medium">{formatDuration(entry.duration_minutes)}</span>
|
||||
{entry.hourly_rate > 0 && <span className="text-muted-foreground w-20 text-right">{formatCurrency(entry.duration_minutes * entry.hourly_rate / 60)}</span>}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => deleteEntry(entry.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-400" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const entityType = searchParams.get("entityType")
|
||||
|
||||
let sql = `SELECT id, entity_type, field_key, field_label, field_type, options, placeholder, default_value, section, is_required, sort_order, is_active, created_at
|
||||
FROM custom_field_definitions WHERE is_active = true`
|
||||
const params: any[] = []
|
||||
|
||||
if (entityType) {
|
||||
sql += ` AND entity_type = $1`
|
||||
params.push(entityType)
|
||||
}
|
||||
|
||||
sql += ` ORDER BY entity_type, sort_order`
|
||||
|
||||
const result = await query(sql, params)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Custom fields API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load custom fields" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
if (!body.entityType || !body.fieldKey || !body.fieldLabel) {
|
||||
return NextResponse.json({ error: "entityType, fieldKey, fieldLabel are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO custom_field_definitions (entity_type, field_key, field_label, field_type, options, placeholder, default_value, section, is_required, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
[
|
||||
body.entityType,
|
||||
body.fieldKey,
|
||||
body.fieldLabel,
|
||||
body.fieldType || "text",
|
||||
body.options ? JSON.stringify(body.options) : null,
|
||||
body.placeholder || null,
|
||||
body.defaultValue || null,
|
||||
body.section || null,
|
||||
body.isRequired || false,
|
||||
body.sortOrder || 0,
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error?.constraint === "uq_custom_field_entity_key") {
|
||||
return NextResponse.json({ error: "A field with this key already exists for this entity" }, { status: 409 })
|
||||
}
|
||||
console.error("Custom fields POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create custom field" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
if (!body.id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
fieldLabel: "field_label",
|
||||
fieldType: "field_type",
|
||||
options: "options",
|
||||
placeholder: "placeholder",
|
||||
defaultValue: "default_value",
|
||||
section: "section",
|
||||
isRequired: "is_required",
|
||||
sortOrder: "sort_order",
|
||||
isActive: "is_active",
|
||||
}
|
||||
|
||||
for (const [key, col] of Object.entries(fieldMap)) {
|
||||
if (body[key] !== undefined) {
|
||||
fields.push(`${col} = $${idx++}`)
|
||||
values.push(key === "options" && body[key] ? JSON.stringify(body[key]) : body[key])
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(body.id)
|
||||
|
||||
await query(`UPDATE custom_field_definitions SET ${fields.join(", ")} WHERE id = $${idx}`, values)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Custom fields PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update custom field" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
await getSessionUser()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||
await query("DELETE FROM custom_field_definitions WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Custom fields DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete custom field" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
|
||||
const result = await query(
|
||||
`SELECT c.id, c.customer_type,
|
||||
ic.first_name, ic.last_name,
|
||||
cc.company_name
|
||||
FROM customers c
|
||||
LEFT JOIN individual_customers ic ON ic.customer_id = c.id
|
||||
LEFT JOIN company_customers cc ON cc.customer_id = c.id
|
||||
WHERE c.deleted_at IS NULL
|
||||
ORDER BY COALESCE(cc.company_name, ic.last_name || ', ' || ic.first_name)
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
)
|
||||
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Customers API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load customers" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const result = await query(
|
||||
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
l.custom_fields,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
@@ -59,6 +60,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
customFields: r.custom_fields || {},
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
@@ -135,6 +137,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
fields.push(`score = $${idx++}`)
|
||||
values.push(score)
|
||||
}
|
||||
if (body.customFields !== undefined) {
|
||||
fields.push(`custom_fields = $${idx++}`)
|
||||
values.push(JSON.stringify(body.customFields))
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
function stageStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
const result = await query(
|
||||
`SELECT ls.id, ls.name, ls.sort_order, ls.probability,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', l.id,
|
||||
'company_name', l.company_name,
|
||||
'contact_name', l.contact_name,
|
||||
'email', l.email,
|
||||
'phone', l.phone,
|
||||
'score', l.score,
|
||||
'notes', l.notes,
|
||||
'assigned_to', l.assigned_to,
|
||||
'created_at', l.created_at
|
||||
)
|
||||
ORDER BY l.created_at DESC
|
||||
) FILTER (WHERE l.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS leads
|
||||
FROM lead_stages ls
|
||||
LEFT JOIN leads l ON l.stage_id = ls.id AND l.deleted_at IS NULL
|
||||
WHERE ls.is_active = true
|
||||
GROUP BY ls.id, ls.name, ls.sort_order, ls.probability
|
||||
ORDER BY ls.sort_order`
|
||||
)
|
||||
|
||||
const columns = result.rows.map((r: any) => {
|
||||
const status = stageStatus(r.name)
|
||||
let leads = r.leads || []
|
||||
if (!isAdmin) {
|
||||
leads = leads.filter((l: any) => l.assigned_to === user.id)
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status,
|
||||
sortOrder: r.sort_order,
|
||||
probability: r.probability,
|
||||
leads,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json(columns)
|
||||
} catch (error) {
|
||||
console.error("Kanban API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load kanban data" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -140,8 +140,8 @@ export async function POST(request: NextRequest) {
|
||||
const stageId = stageResult.rows[0]?.id || 1
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, custom_fields, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
body.companyName,
|
||||
@@ -152,6 +152,7 @@ export async function POST(request: NextRequest) {
|
||||
body.source || null,
|
||||
stageId,
|
||||
assignedUserId,
|
||||
body.customFields ? JSON.stringify(body.customFields) : '{}',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
|
||||
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
|
||||
if (body.status !== undefined) { fields.push(`status = $${idx++}`); values.push(body.status) }
|
||||
if (body.startDate !== undefined) { fields.push(`start_date = $${idx++}`); values.push(body.startDate) }
|
||||
if (body.dueDate !== undefined) { fields.push(`due_date = $${idx++}`); values.push(body.dueDate) }
|
||||
|
||||
if (body.status === "completed") {
|
||||
fields.push(`completed_at = NOW()`)
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
const sql = `UPDATE project_milestones SET ${fields.join(", ")} WHERE id = $${idx} RETURNING id`
|
||||
const result = await query(sql, values)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Milestone not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id })
|
||||
} catch (error) {
|
||||
console.error("Milestone PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update milestone" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
await query("DELETE FROM project_milestones WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Milestone DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete milestone" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
"SELECT id, name, color, sort_order FROM project_statuses WHERE is_active = true ORDER BY sort_order"
|
||||
)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Project statuses API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load statuses" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await query(
|
||||
`SELECT pm.id, pm.name, pm.description, pm.sort_order, pm.status, pm.start_date, pm.due_date, pm.completed_at, pm.created_at,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', t.id,
|
||||
'title', t.title,
|
||||
'description', t.description,
|
||||
'status', t.status,
|
||||
'due_date', t.due_date,
|
||||
'completed_at', t.completed_at,
|
||||
'assigned_to', t.assigned_to,
|
||||
'created_at', t.created_at
|
||||
)
|
||||
ORDER BY t.created_at DESC
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS tasks
|
||||
FROM project_milestones pm
|
||||
LEFT JOIN tasks t ON t.milestone_id = pm.id AND t.deleted_at IS NULL
|
||||
WHERE pm.project_id = $1
|
||||
GROUP BY pm.id, pm.name, pm.description, pm.sort_order, pm.status, pm.start_date, pm.due_date, pm.completed_at, pm.created_at
|
||||
ORDER BY pm.sort_order`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Milestones API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load milestones" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const maxOrder = await query(
|
||||
"SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM project_milestones WHERE project_id = $1",
|
||||
[id]
|
||||
)
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO project_milestones (project_id, name, description, sort_order, start_date, due_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[id, body.name, body.description || null, maxOrder.rows[0].next_order, body.startDate || null, body.dueDate || null]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Milestones POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create milestone" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await query(
|
||||
`SELECT p.id, p.name, p.description, p.start_date, p.target_end_date, p.actual_end_date, p.budget, p.custom_fields, p.created_at,
|
||||
ps.id AS status_id, ps.name AS status_name, ps.color AS status_color,
|
||||
c.id AS customer_id, c.company_name,
|
||||
u.id AS owner_id, u.first_name || ' ' || u.last_name AS owner_name, u.avatar_url AS owner_avatar,
|
||||
opp.id AS opportunity_id, opp.name AS opportunity_name
|
||||
FROM projects p
|
||||
JOIN project_statuses ps ON ps.id = p.status_id
|
||||
JOIN customers c ON c.id = p.customer_id
|
||||
JOIN users u ON u.id = p.owner_id
|
||||
LEFT JOIN opportunities opp ON opp.id = p.opportunity_id
|
||||
WHERE p.id = $1 AND p.deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(result.rows[0])
|
||||
} catch (error) {
|
||||
console.error("Project detail API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load project" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
|
||||
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
|
||||
if (body.statusId !== undefined) { fields.push(`status_id = $${idx++}`); values.push(body.statusId) }
|
||||
if (body.startDate !== undefined) { fields.push(`start_date = $${idx++}`); values.push(body.startDate) }
|
||||
if (body.targetEndDate !== undefined) { fields.push(`target_end_date = $${idx++}`); values.push(body.targetEndDate) }
|
||||
if (body.actualEndDate !== undefined) { fields.push(`actual_end_date = $${idx++}`); values.push(body.actualEndDate) }
|
||||
if (body.budget !== undefined) { fields.push(`budget = $${idx++}`); values.push(body.budget) }
|
||||
if (body.ownerId !== undefined) { fields.push(`owner_id = $${idx++}`); values.push(body.ownerId) }
|
||||
if (body.customFields !== undefined) { fields.push(`custom_fields = $${idx++}`); values.push(JSON.stringify(body.customFields)) }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
const sql = `UPDATE projects SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
|
||||
const result = await query(sql, values)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id })
|
||||
} catch (error) {
|
||||
console.error("Project PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update project" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search") || ""
|
||||
const statusId = searchParams.get("status") || ""
|
||||
|
||||
let sql = `SELECT p.id, p.name, p.description, p.start_date, p.target_end_date, p.actual_end_date, p.budget, p.created_at,
|
||||
ps.name AS status_name, ps.color AS status_color,
|
||||
c.id AS customer_id, c.company_name,
|
||||
u.id AS owner_id, u.first_name || ' ' || u.last_name AS owner_name, u.avatar_url AS owner_avatar
|
||||
FROM projects p
|
||||
JOIN project_statuses ps ON ps.id = p.status_id
|
||||
JOIN customers c ON c.id = p.customer_id
|
||||
JOIN users u ON u.id = p.owner_id
|
||||
WHERE p.deleted_at IS NULL`
|
||||
|
||||
const params: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (!isAdmin) {
|
||||
sql += ` AND (p.owner_id = $${idx} OR p.created_by = $${idx})`
|
||||
params.push(user.id)
|
||||
idx++
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sql += ` AND (p.name ILIKE $${idx} OR c.company_name ILIKE $${idx})`
|
||||
params.push(`%${search}%`)
|
||||
idx++
|
||||
}
|
||||
|
||||
if (statusId) {
|
||||
sql += ` AND p.status_id = $${idx}`
|
||||
params.push(statusId)
|
||||
idx++
|
||||
}
|
||||
|
||||
sql += ` ORDER BY p.created_at DESC`
|
||||
|
||||
const result = await query(sql, params)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Projects API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load projects" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const planningStatus = await query("SELECT id FROM project_statuses WHERE name = 'Planning'")
|
||||
const statusId = planningStatus.rows[0]?.id
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO projects (customer_id, owner_id, name, description, status_id, start_date, target_end_date, budget, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
||||
[
|
||||
body.customerId,
|
||||
body.ownerId || user.id,
|
||||
body.name,
|
||||
body.description || null,
|
||||
statusId,
|
||||
body.startDate || null,
|
||||
body.targetEndDate || null,
|
||||
body.budget || null,
|
||||
user.id,
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Projects POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create project" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
|
||||
|
||||
await query("UPDATE projects SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Projects DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete project" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query("SELECT id, name, color, sort_order FROM proposal_statuses ORDER BY sort_order")
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Proposal statuses API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load statuses" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const maxOrder = await query(
|
||||
"SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM proposal_items WHERE proposal_id = $1",
|
||||
[id]
|
||||
)
|
||||
|
||||
const quantity = body.quantity || 1
|
||||
const unitPrice = body.unitPrice || 0
|
||||
const discountPercent = body.discountPercent || 0
|
||||
const totalPrice = quantity * unitPrice * (1 - discountPercent / 100)
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO proposal_items (proposal_id, product_id, description, quantity, unit_price, discount_percent, total_price, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[id, body.productId || null, body.description, quantity, unitPrice, discountPercent, totalPrice, maxOrder.rows[0].next_order]
|
||||
)
|
||||
|
||||
await recalcProposal(id)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Proposal items POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to add item" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await getSessionUser()
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const itemId = searchParams.get("itemId")
|
||||
if (!itemId) return NextResponse.json({ error: "itemId required" }, { status: 400 })
|
||||
|
||||
await query("DELETE FROM proposal_items WHERE id = $1 AND proposal_id = $2", [itemId, id])
|
||||
await recalcProposal(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Proposal items DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete item" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function recalcProposal(proposalId: string) {
|
||||
const items = await query(
|
||||
"SELECT COALESCE(SUM(total_price), 0) AS subtotal FROM proposal_items WHERE proposal_id = $1",
|
||||
[proposalId]
|
||||
)
|
||||
const subtotal = parseFloat(items.rows[0].subtotal)
|
||||
const prop = await query("SELECT tax_percent FROM proposals WHERE id = $1", [proposalId])
|
||||
const taxPercent = parseFloat(prop.rows[0]?.tax_percent || 15)
|
||||
const taxAmount = subtotal * (taxPercent / 100)
|
||||
const totalAmount = subtotal + taxAmount
|
||||
|
||||
await query(
|
||||
`UPDATE proposals SET subtotal = $1, tax_amount = $2, total_amount = $3, updated_at = NOW() WHERE id = $4`,
|
||||
[subtotal, taxAmount, totalAmount, proposalId]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const [propResult, itemsResult, sigResult] = await Promise.all([
|
||||
query(
|
||||
`SELECT p.id, p.title, p.proposal_number, p.subtotal, p.tax_percent, p.tax_amount, p.total_amount,
|
||||
p.valid_until, p.notes, p.terms, p.content, p.created_at, p.updated_at,
|
||||
ps.id AS status_id, ps.name AS status_name, ps.color AS status_color,
|
||||
u.first_name || ' ' || u.last_name AS created_by_name,
|
||||
l.id AS lead_id, l.company_name AS lead_company, l.contact_name AS lead_contact,
|
||||
c.id AS customer_id, c.company_name
|
||||
FROM proposals p
|
||||
JOIN proposal_statuses ps ON ps.id = p.status_id
|
||||
JOIN users u ON u.id = p.created_by
|
||||
LEFT JOIN leads l ON l.id = p.lead_id
|
||||
LEFT JOIN customers c ON c.id = p.customer_id
|
||||
WHERE p.id = $1 AND p.deleted_at IS NULL`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT id, product_id, description, quantity, unit_price, discount_percent, total_price, sort_order
|
||||
FROM proposal_items WHERE proposal_id = $1 ORDER BY sort_order`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT id, signatory_name, signatory_email, signed_at FROM proposal_signatures WHERE proposal_id = $1 AND is_valid = true ORDER BY signed_at DESC`,
|
||||
[id]
|
||||
),
|
||||
])
|
||||
|
||||
if (propResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Proposal not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...propResult.rows[0],
|
||||
items: itemsResult.rows,
|
||||
signatures: sigResult.rows,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Proposal detail API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load proposal" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
title: "title",
|
||||
statusId: "status_id",
|
||||
content: "content",
|
||||
subtotal: "subtotal",
|
||||
taxPercent: "tax_percent",
|
||||
taxAmount: "tax_amount",
|
||||
totalAmount: "total_amount",
|
||||
validUntil: "valid_until",
|
||||
notes: "notes",
|
||||
terms: "terms",
|
||||
leadId: "lead_id",
|
||||
customerId: "customer_id",
|
||||
opportunityId: "opportunity_id",
|
||||
}
|
||||
|
||||
for (const [key, col] of Object.entries(fieldMap)) {
|
||||
if (body[key] !== undefined) {
|
||||
fields.push(`${col} = $${idx++}`)
|
||||
values.push(body[key])
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
await query(
|
||||
`UPDATE proposals SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL`,
|
||||
values
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Proposal PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update proposal" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await getSessionUser()
|
||||
const { id } = await params
|
||||
await query("UPDATE proposals SET deleted_at = NOW() WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Proposal DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete proposal" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const signature = await query(
|
||||
`INSERT INTO proposal_signatures (proposal_id, signatory_name, signatory_email, signature_data, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5::inet) RETURNING id`,
|
||||
[
|
||||
id,
|
||||
body.name,
|
||||
body.email,
|
||||
body.signatureData || null,
|
||||
request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "127.0.0.1",
|
||||
]
|
||||
)
|
||||
|
||||
const acceptedStatus = await query("SELECT id FROM proposal_statuses WHERE name = 'Accepted'")
|
||||
if (acceptedStatus.rows[0]) {
|
||||
await query(
|
||||
"UPDATE proposals SET status_id = $1, updated_at = NOW() WHERE id = $2",
|
||||
[acceptedStatus.rows[0].id, id]
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: signature.rows[0].id })
|
||||
} catch (error) {
|
||||
console.error("Proposal sign error:", error)
|
||||
return NextResponse.json({ error: "Failed to sign proposal" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search") || ""
|
||||
const statusId = searchParams.get("status") || ""
|
||||
|
||||
let sql = `SELECT p.id, p.title, p.proposal_number, p.subtotal, p.total_amount, p.valid_until, p.created_at,
|
||||
ps.name AS status_name, ps.color AS status_color,
|
||||
u.first_name || ' ' || u.last_name AS created_by_name
|
||||
FROM proposals p
|
||||
JOIN proposal_statuses ps ON ps.id = p.status_id
|
||||
JOIN users u ON u.id = p.created_by
|
||||
WHERE p.deleted_at IS NULL`
|
||||
|
||||
const params: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (search) {
|
||||
sql += ` AND (p.title ILIKE $${idx} OR p.proposal_number ILIKE $${idx})`
|
||||
params.push(`%${search}%`)
|
||||
idx++
|
||||
}
|
||||
if (statusId) {
|
||||
sql += ` AND p.status_id = $${idx}`
|
||||
params.push(statusId)
|
||||
idx++
|
||||
}
|
||||
|
||||
sql += ` ORDER BY p.created_at DESC LIMIT 50`
|
||||
|
||||
const result = await query(sql, params)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Proposals API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load proposals" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const draftStatus = await query("SELECT id FROM proposal_statuses WHERE name = 'Draft'")
|
||||
const statusId = draftStatus.rows[0]?.id
|
||||
|
||||
const numResult = await query("SELECT COALESCE(MAX(SUBSTRING(proposal_number FROM 'Q-(\\d+)')::INT), 0) + 1 AS next_num FROM proposals WHERE deleted_at IS NULL")
|
||||
const nextNum = numResult.rows[0]?.next_num || 1
|
||||
const proposalNumber = `Q-${String(nextNum).padStart(4, "0")}`
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO proposals (lead_id, customer_id, title, proposal_number, status_id, content, subtotal, tax_percent, tax_amount, total_amount, valid_until, notes, terms, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id`,
|
||||
[
|
||||
body.leadId || null,
|
||||
body.customerId || null,
|
||||
body.title,
|
||||
proposalNumber,
|
||||
statusId,
|
||||
body.content ? JSON.stringify(body.content) : null,
|
||||
body.subtotal || 0,
|
||||
body.taxPercent !== undefined ? body.taxPercent : 15,
|
||||
body.taxAmount || 0,
|
||||
body.totalAmount || 0,
|
||||
body.validUntil || null,
|
||||
body.notes || null,
|
||||
body.terms || null,
|
||||
user.id,
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id, proposalNumber }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Proposals POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create proposal" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO tasks (title, description, status, project_id, milestone_id, assigned_to, assigned_by, created_at, updated_at)
|
||||
VALUES ($1, $2, 'pending', $3, $4, $5, $6, NOW(), NOW()) RETURNING id`,
|
||||
[
|
||||
body.title,
|
||||
body.description || null,
|
||||
body.projectId || null,
|
||||
body.milestoneId || null,
|
||||
body.assignedTo || null,
|
||||
user.id,
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Tasks POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create task" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const { id, ...fields } = body
|
||||
|
||||
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
|
||||
|
||||
const setClauses: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (fields.status !== undefined) { setClauses.push(`status = $${idx++}`); values.push(fields.status) }
|
||||
if (fields.title !== undefined) { setClauses.push(`title = $${idx++}`); values.push(fields.title) }
|
||||
if (fields.assignedTo !== undefined) { setClauses.push(`assigned_to = $${idx++}`); values.push(fields.assignedTo) }
|
||||
|
||||
if (fields.status === "completed") {
|
||||
setClauses.push(`completed_at = NOW()`)
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
setClauses.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
await query(
|
||||
`UPDATE tasks SET ${setClauses.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL`,
|
||||
values
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Tasks PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update task" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const projectId = searchParams.get("projectId") || ""
|
||||
const userId = searchParams.get("userId") || ""
|
||||
const from = searchParams.get("from") || ""
|
||||
const to = searchParams.get("to") || ""
|
||||
|
||||
let sql = `SELECT te.id, te.description, te.date, te.duration_minutes, te.billable, te.hourly_rate, te.created_at,
|
||||
u.first_name || ' ' || u.last_name AS user_name,
|
||||
p.name AS project_name
|
||||
FROM time_entries te
|
||||
JOIN users u ON u.id = te.user_id
|
||||
LEFT JOIN projects p ON p.id = te.project_id`
|
||||
|
||||
const conditions: string[] = ["te.user_id IS NOT NULL"]
|
||||
const params: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (!(user.role === "admin" || user.role === "super_admin")) {
|
||||
conditions.push(`te.user_id = $${idx++}`)
|
||||
params.push(user.id)
|
||||
} else if (userId) {
|
||||
conditions.push(`te.user_id = $${idx++}`)
|
||||
params.push(userId)
|
||||
}
|
||||
if (projectId) {
|
||||
conditions.push(`te.project_id = $${idx++}`)
|
||||
params.push(projectId)
|
||||
}
|
||||
if (from) { conditions.push(`te.date >= $${idx++}`); params.push(from) }
|
||||
if (to) { conditions.push(`te.date <= $${idx++}`); params.push(to) }
|
||||
|
||||
sql += ` WHERE ${conditions.join(" AND ")} ORDER BY te.date DESC, te.created_at DESC LIMIT 100`
|
||||
|
||||
const result = await query(sql, params)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Time entries API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load time entries" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const duration = body.durationMinutes || 0
|
||||
|
||||
if (duration <= 0) {
|
||||
return NextResponse.json({ error: "Duration must be greater than 0" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO time_entries (user_id, project_id, milestone_id, task_id, description, date, start_time, end_time, duration_minutes, billable, hourly_rate)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
|
||||
[
|
||||
body.userId || user.id,
|
||||
body.projectId || null,
|
||||
body.milestoneId || null,
|
||||
body.taskId || null,
|
||||
body.description || null,
|
||||
body.date || new Date().toISOString().split("T")[0],
|
||||
body.startTime || null,
|
||||
body.endTime || null,
|
||||
duration,
|
||||
body.billable !== false,
|
||||
body.hourlyRate || null,
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Time entries POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create time entry" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
await getSessionUser()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||
await query("DELETE FROM time_entries WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Time entries DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete entry" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
const userFilter = isAdmin ? "" : "WHERE te.user_id = $1"
|
||||
const params = isAdmin ? [] : [user.id]
|
||||
|
||||
const [totals, perProject, perUser] = await Promise.all([
|
||||
query(
|
||||
`SELECT COALESCE(SUM(duration_minutes), 0) AS total_minutes,
|
||||
COALESCE(SUM(CASE WHEN billable THEN duration_minutes ELSE 0 END), 0) AS billable_minutes,
|
||||
COALESCE(SUM(duration_minutes * hourly_rate / 60), 0) AS total_revenue,
|
||||
COUNT(*) AS entry_count
|
||||
FROM time_entries te ${userFilter}`,
|
||||
params
|
||||
),
|
||||
query(
|
||||
`SELECT p.id, p.name,
|
||||
COALESCE(SUM(te.duration_minutes), 0) AS total_minutes,
|
||||
COALESCE(SUM(CASE WHEN te.billable THEN te.duration_minutes ELSE 0 END), 0) AS billable_minutes,
|
||||
COALESCE(SUM(te.duration_minutes * te.hourly_rate / 60), 0) AS revenue
|
||||
FROM time_entries te
|
||||
LEFT JOIN projects p ON p.id = te.project_id
|
||||
${userFilter ? userFilter + " AND " : "WHERE "} te.project_id IS NOT NULL
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY total_minutes DESC
|
||||
LIMIT 10`,
|
||||
params
|
||||
),
|
||||
isAdmin
|
||||
? query(
|
||||
`SELECT u.id, u.first_name || ' ' || u.last_name AS name,
|
||||
COALESCE(SUM(te.duration_minutes), 0) AS total_minutes,
|
||||
COALESCE(SUM(te.duration_minutes * te.hourly_rate / 60), 0) AS revenue
|
||||
FROM time_entries te
|
||||
JOIN users u ON u.id = te.user_id
|
||||
GROUP BY u.id, u.first_name, u.last_name
|
||||
ORDER BY total_minutes DESC`
|
||||
)
|
||||
: Promise.resolve({ rows: [] }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
totals: totals.rows[0],
|
||||
perProject: perProject.rows,
|
||||
perUser: perUser.rows,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Time summary API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load summary" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
Bot,
|
||||
Facebook,
|
||||
Calendar,
|
||||
FolderKanban,
|
||||
FileText,
|
||||
Clock,
|
||||
} from "lucide-react"
|
||||
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
@@ -30,8 +33,11 @@ import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/leads", label: "Leads", icon: Users },
|
||||
{ href: "/projects", label: "Projects", icon: FolderKanban },
|
||||
{ href: "/calendar", label: "Calendar", icon: Calendar },
|
||||
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
||||
{ href: "/proposals", label: "Proposals", icon: FileText },
|
||||
{ href: "/time-tracking", label: "Time", icon: Clock },
|
||||
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
||||
{ href: "/users", label: "Users", icon: Building2 },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { LeadStatusBadge } from "./lead-status-badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { CustomFieldsSection } from "@/components/shared/custom-fields-section"
|
||||
import { Lead } from "@/types"
|
||||
import {
|
||||
Building2,
|
||||
@@ -84,6 +85,16 @@ export function LeadDetailsCard({ lead }: LeadDetailsCardProps) {
|
||||
<p className="text-sm text-muted-foreground">No user assigned</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<CustomFieldsSection
|
||||
entityType="lead"
|
||||
customFields={lead.customFields}
|
||||
entityId={lead.id}
|
||||
apiEndpoint={`/api/leads/${lead.id}`}
|
||||
title="Custom Fields"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { GripVertical, Plus, Pencil, Trash2 } from "lucide-react"
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: "lead", label: "Leads" },
|
||||
{ value: "customer", label: "Customers" },
|
||||
{ value: "opportunity", label: "Opportunities" },
|
||||
{ value: "project", label: "Projects" },
|
||||
]
|
||||
|
||||
const FIELD_TYPES = [
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "date", label: "Date" },
|
||||
{ value: "select", label: "Select (Single)" },
|
||||
{ value: "multi_select", label: "Select (Multiple)" },
|
||||
{ value: "boolean", label: "Boolean (Yes/No)" },
|
||||
{ value: "url", label: "URL" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "phone", label: "Phone" },
|
||||
]
|
||||
|
||||
interface FieldDefinition {
|
||||
id: string
|
||||
entity_type: string
|
||||
field_key: string
|
||||
field_label: string
|
||||
field_type: string
|
||||
options: string[] | null
|
||||
placeholder: string | null
|
||||
default_value: string | null
|
||||
section: string | null
|
||||
is_required: boolean
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
function SortableField({ field, onEdit, onDelete }: {
|
||||
field: FieldDefinition
|
||||
onEdit: (f: FieldDefinition) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id })
|
||||
const style = { transform: CSS.Transform.toString(transform), transition }
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={`flex items-center gap-3 rounded-lg border p-3 ${isDragging ? "opacity-50" : ""}`}>
|
||||
<button {...attributes} {...listeners} className="cursor-grab touch-none text-muted-foreground hover:text-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{field.field_label}</span>
|
||||
<span className="text-xs text-muted-foreground">({field.field_key})</span>
|
||||
{field.is_required && <span className="text-xs text-red-500">required</span>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{FIELD_TYPES.find(t => t.value === field.field_type)?.label || field.field_type}
|
||||
{field.section ? ` — ${field.section}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => onEdit(field)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onDelete(field.id)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomFieldsManager() {
|
||||
const [entityType, setEntityType] = useState("lead")
|
||||
const [fields, setFields] = useState<FieldDefinition[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingField, setEditingField] = useState<FieldDefinition | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
fieldKey: "", fieldLabel: "", fieldType: "text", options: "",
|
||||
placeholder: "", defaultValue: "", section: "", isRequired: false,
|
||||
})
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
async function loadFields() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/custom-fields?entityType=${entityType}`)
|
||||
if (res.ok) setFields(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { loadFields() }, [entityType])
|
||||
|
||||
function resetForm() {
|
||||
setForm({ fieldKey: "", fieldLabel: "", fieldType: "text", options: "", placeholder: "", defaultValue: "", section: "", isRequired: false })
|
||||
setEditingField(null)
|
||||
}
|
||||
|
||||
function openEdit(field: FieldDefinition) {
|
||||
setEditingField(field)
|
||||
setForm({
|
||||
fieldKey: field.field_key,
|
||||
fieldLabel: field.field_label,
|
||||
fieldType: field.field_type,
|
||||
options: field.options?.join("\n") || "",
|
||||
placeholder: field.placeholder || "",
|
||||
defaultValue: field.default_value || "",
|
||||
section: field.section || "",
|
||||
isRequired: field.is_required,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const options = form.options ? form.options.split("\n").map(s => s.trim()).filter(Boolean) : null
|
||||
const payload = { ...form, options, entityType }
|
||||
|
||||
const res = editingField
|
||||
? await fetch("/api/custom-fields", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: editingField.id, ...payload }) })
|
||||
: await fetch("/api/custom-fields", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
|
||||
|
||||
if (res.ok) {
|
||||
setDialogOpen(false)
|
||||
resetForm()
|
||||
loadFields()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("Delete this custom field?")) return
|
||||
const res = await fetch(`/api/custom-fields?id=${id}`, { method: "DELETE" })
|
||||
if (res.ok) loadFields()
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = fields.findIndex(f => f.id === active.id)
|
||||
const newIndex = fields.findIndex(f => f.id === over.id)
|
||||
const reordered = arrayMove(fields, oldIndex, newIndex)
|
||||
setFields(reordered)
|
||||
await Promise.all(reordered.map((f, i) =>
|
||||
fetch("/api/custom-fields", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: f.id, sortOrder: i }) })
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={entityType} onValueChange={setEntityType}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTITY_TYPES.map(et => (
|
||||
<SelectItem key={et.value} value={et.value}>{et.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm() }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="mr-1 h-4 w-4" />Add Field</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingField ? "Edit Field" : "New Custom Field"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Field Key</label>
|
||||
<Input value={form.fieldKey} onChange={e => setForm(p => ({ ...p, fieldKey: e.target.value }))} placeholder="e.g. service_type" disabled={!!editingField} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Field Label</label>
|
||||
<Input value={form.fieldLabel} onChange={e => setForm(p => ({ ...p, fieldLabel: e.target.value }))} placeholder="e.g. Service Type" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Field Type</label>
|
||||
<Select value={form.fieldType} onValueChange={v => setForm(p => ({ ...p, fieldType: v }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map(ft => (
|
||||
<SelectItem key={ft.value} value={ft.value}>{ft.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Section (optional)</label>
|
||||
<Input value={form.section} onChange={e => setForm(p => ({ ...p, section: e.target.value }))} placeholder="e.g. Details, Technical" />
|
||||
</div>
|
||||
</div>
|
||||
{(form.fieldType === "select" || form.fieldType === "multi_select") && (
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Options (one per line)</label>
|
||||
<textarea
|
||||
className="w-full min-h-[80px] rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.options}
|
||||
onChange={e => setForm(p => ({ ...p, options: e.target.value }))}
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Placeholder</label>
|
||||
<Input value={form.placeholder} onChange={e => setForm(p => ({ ...p, placeholder: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Default Value</label>
|
||||
<Input value={form.defaultValue} onChange={e => setForm(p => ({ ...p, defaultValue: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.isRequired} onCheckedChange={v => setForm(p => ({ ...p, isRequired: v }))} />
|
||||
<label className="text-sm">Required</label>
|
||||
</div>
|
||||
<Button onClick={handleSave} className="w-full">{editingField ? "Update" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : fields.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No custom fields defined for {ENTITY_TYPES.find(e => e.value === entityType)?.label}.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Click "Add Field" to create one.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={fields.map(f => f.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{fields.map(field => (
|
||||
<SortableField key={field.id} field={field} onEdit={openEdit} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Check, X, Pencil, Save } from "lucide-react"
|
||||
|
||||
interface FieldDef {
|
||||
id: string
|
||||
entity_type: string
|
||||
field_key: string
|
||||
field_label: string
|
||||
field_type: string
|
||||
options: string[] | null
|
||||
placeholder: string | null
|
||||
default_value: string | null
|
||||
section: string | null
|
||||
is_required: boolean
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface CustomFieldsSectionProps {
|
||||
entityType: string
|
||||
customFields?: Record<string, any>
|
||||
entityId: string
|
||||
apiEndpoint: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function CustomFieldsSection({ entityType, customFields = {}, entityId, apiEndpoint, title = "Custom Fields" }: CustomFieldsSectionProps) {
|
||||
const [definitions, setDefinitions] = useState<FieldDef[]>([])
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [values, setValues] = useState<Record<string, any>>(customFields)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadDefinitions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/custom-fields?entityType=${entityType}`)
|
||||
if (res.ok) setDefinitions(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [entityType])
|
||||
|
||||
useEffect(() => { loadDefinitions() }, [loadDefinitions])
|
||||
useEffect(() => { setValues(customFields) }, [customFields])
|
||||
|
||||
if (loading || definitions.length === 0) return null
|
||||
|
||||
const grouped = definitions.reduce<Record<string, FieldDef[]>>((acc, f) => {
|
||||
const section = f.section || "Other"
|
||||
if (!acc[section]) acc[section] = []
|
||||
acc[section].push(f)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch(apiEndpoint, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customFields: values }),
|
||||
})
|
||||
setEditing(false)
|
||||
} catch { /* ignore */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
function renderField(field: FieldDef) {
|
||||
const key = field.field_key
|
||||
const val = values?.[key]
|
||||
|
||||
if (editing) {
|
||||
return renderEditField(field, key, val)
|
||||
}
|
||||
|
||||
if (val === undefined || val === null || val === "") return null
|
||||
|
||||
return renderDisplayField(field, key, val)
|
||||
}
|
||||
|
||||
function renderDisplayField(field: FieldDef, key: string, val: any) {
|
||||
let display: React.ReactNode = String(val)
|
||||
|
||||
switch (field.field_type) {
|
||||
case "boolean":
|
||||
display = val ? <Badge variant="default" className="bg-emerald-500"><Check className="h-3 w-3 mr-1" />Yes</Badge> : <Badge variant="secondary"><X className="h-3 w-3 mr-1" />No</Badge>
|
||||
break
|
||||
case "select":
|
||||
display = <Badge variant="outline">{String(val)}</Badge>
|
||||
break
|
||||
case "multi_select":
|
||||
const arr = Array.isArray(val) ? val : [val]
|
||||
display = <div className="flex flex-wrap gap-1">{arr.map((v: string) => <Badge key={v} variant="secondary">{v}</Badge>)}</div>
|
||||
break
|
||||
case "url":
|
||||
display = <a href={String(val)} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline truncate block max-w-[200px]">{String(val)}</a>
|
||||
break
|
||||
case "date":
|
||||
display = new Date(val).toLocaleDateString()
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground">{field.field_label}</p>
|
||||
<div className="text-sm font-medium">{display}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderEditField(field: FieldDef, key: string, val: any) {
|
||||
const setVal = (v: any) => setValues(prev => ({ ...prev, [key]: v }))
|
||||
|
||||
switch (field.field_type) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "phone":
|
||||
case "url":
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="text-xs font-medium mb-1 block">{field.field_label}</label>
|
||||
<Input value={val || ""} onChange={e => setVal(e.target.value)} placeholder={field.placeholder || ""} />
|
||||
</div>
|
||||
)
|
||||
case "number":
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="text-xs font-medium mb-1 block">{field.field_label}</label>
|
||||
<Input type="number" value={val || ""} onChange={e => setVal(e.target.value ? Number(e.target.value) : null)} placeholder={field.placeholder || ""} />
|
||||
</div>
|
||||
)
|
||||
case "date":
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="text-xs font-medium mb-1 block">{field.field_label}</label>
|
||||
<Input type="date" value={val || ""} onChange={e => setVal(e.target.value)} />
|
||||
</div>
|
||||
)
|
||||
case "boolean":
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<Switch checked={!!val} onCheckedChange={setVal} />
|
||||
<label className="text-sm">{field.field_label}</label>
|
||||
</div>
|
||||
)
|
||||
case "select":
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="text-xs font-medium mb-1 block">{field.field_label}</label>
|
||||
<Select value={val || ""} onValueChange={setVal}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map(o => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
case "multi_select":
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="text-xs font-medium mb-1 block">{field.field_label}</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(field.options || []).map(o => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => {
|
||||
const current = Array.isArray(val) ? val : []
|
||||
setVal(current.includes(o) ? current.filter((v: string) => v !== o) : [...current, o])
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded-full border transition-colors ${(Array.isArray(val) && val.includes(o)) ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-muted"}`}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{editing ? (
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditing(false); setValues(customFields) }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
|
||||
<Pencil className="h-4 w-4 mr-1" />Edit
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.entries(grouped).map(([section, sectionFields]) => {
|
||||
const rendered = sectionFields.map(renderField).filter(Boolean)
|
||||
if (rendered.length === 0 && !editing) return null
|
||||
return (
|
||||
<div key={section} className={section !== Object.keys(grouped)[0] ? "mt-4 pt-4 border-t" : ""}>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">{section}</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{rendered}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{definitions.length > 0 && !Object.values(grouped).some(sf => sf.some(f => {
|
||||
const v = values?.[f.field_key]
|
||||
return v !== undefined && v !== null && v !== ""
|
||||
})) && !editing && (
|
||||
<p className="text-sm text-muted-foreground">No custom fields set yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export interface Lead {
|
||||
status: LeadStatus;
|
||||
assignedUserId: string | null;
|
||||
assignedUser: User | null;
|
||||
customFields?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user