Merge branch 'main' of https://git.coastit.co.za/CRM_ENVIROMENT/Newbie_CRM
Build & Auto-Repair / build (push) Has been cancelled

This commit is contained in:
JCBSComputer
2026-07-01 11:25:15 +02:00
38 changed files with 3770 additions and 4 deletions
+104
View File
@@ -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 $$;
+119
View File
@@ -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 $$;
+43
View File
@@ -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;
+10
View File
@@ -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;
+56
View File
@@ -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",
+3
View File
@@ -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",
+254
View File
@@ -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>
)
}
+7 -1
View File
@@ -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">
+382
View File
@@ -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>
)
}
+273
View File
@@ -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>
)
}
+203
View File
@@ -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>
)
}
+234
View File
@@ -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>
)
}
+115
View File
@@ -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>
)
}
+3 -1
View File
@@ -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 (
+294
View File
@@ -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>
)
}
+125
View File
@@ -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 })
}
}
+31
View File
@@ -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 })
}
}
+6
View File
@@ -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 })
+73
View File
@@ -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 })
}
}
+3 -2
View File
@@ -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) : '{}',
]
)
+60
View File
@@ -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 })
}
}
+18
View File
@@ -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 })
}
}
+79
View File
@@ -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 })
}
}
+103
View File
@@ -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 })
}
}
+16
View File
@@ -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 })
}
}
+71
View File
@@ -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]
)
}
+118
View File
@@ -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 })
}
}
+34
View File
@@ -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 })
}
}
+85
View File
@@ -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 })
}
}
+71
View File
@@ -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 })
}
}
+100
View File
@@ -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 })
}
}
+58
View File
@@ -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 })
}
}
+6
View File
@@ -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&#10;Option 2&#10;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>
)
}
+1
View File
@@ -28,6 +28,7 @@ export interface Lead {
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
customFields?: Record<string, any>;
createdAt: string;
updatedAt: string;
}