Compare commits
8 Commits
7a42e3c41c
...
a0d8420a2f
| Author | SHA1 | Date | |
|---|---|---|---|
| a0d8420a2f | |||
| 24dc4855d7 | |||
| de6cbfccc2 | |||
| 178677aa85 | |||
| 277c719963 | |||
| 5238b59140 | |||
| 8cb49167f4 | |||
| fd2d4ff976 |
@@ -119,7 +119,7 @@ CREATE TABLE banned_users (
|
|||||||
|
|
||||||
CREATE INDEX idx_banned_users_user ON banned_users(banned_user_id);
|
CREATE INDEX idx_banned_users_user ON banned_users(banned_user_id);
|
||||||
CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
|
CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
|
||||||
WHERE is_reversed = FALSE AND (permanent_ban = TRUE OR expires_at > NOW());
|
WHERE is_reversed = FALSE;
|
||||||
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
|
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
|
||||||
|
|
||||||
CREATE TABLE suspended_users (
|
CREATE TABLE suspended_users (
|
||||||
@@ -138,7 +138,7 @@ CREATE TABLE suspended_users (
|
|||||||
|
|
||||||
CREATE INDEX idx_suspended_users_user ON suspended_users(suspended_user_id);
|
CREATE INDEX idx_suspended_users_user ON suspended_users(suspended_user_id);
|
||||||
CREATE INDEX idx_suspended_users_active ON suspended_users(suspended_user_id)
|
CREATE INDEX idx_suspended_users_active ON suspended_users(suspended_user_id)
|
||||||
WHERE is_active = TRUE AND expires_at > NOW();
|
WHERE is_active = TRUE;
|
||||||
CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
|
CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -687,7 +687,7 @@ CREATE INDEX idx_invoices_customer ON invoices(customer_id) WHERE deleted_at IS
|
|||||||
CREATE INDEX idx_invoices_status ON invoices(status_id) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_invoices_status ON invoices(status_id) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_invoices_overdue ON invoices(due_date, status_id) WHERE deleted_at IS NULL AND due_date < NOW();
|
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
|
||||||
|
|
||||||
CREATE TABLE invoice_items (
|
CREATE TABLE invoice_items (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
@@ -800,10 +800,14 @@ DECLARE
|
|||||||
t TEXT;
|
t TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR t IN
|
FOR t IN
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
AND table_name IN (
|
||||||
SELECT table_name FROM information_schema.columns
|
SELECT table_name FROM information_schema.columns
|
||||||
WHERE column_name = 'updated_at'
|
WHERE column_name = 'updated_at'
|
||||||
AND table_schema = 'public'
|
AND table_schema = 'public'
|
||||||
AND table_type = 'BASE TABLE'
|
)
|
||||||
LOOP
|
LOOP
|
||||||
EXECUTE format(
|
EXECUTE format(
|
||||||
'CREATE TRIGGER trg_%I_updated_at
|
'CREATE TRIGGER trg_%I_updated_at
|
||||||
|
|||||||
+143
-143
@@ -35,78 +35,78 @@ ON CONFLICT (name) DO NOTHING;
|
|||||||
|
|
||||||
-- USER MANAGEMENT (SUPER_ADMIN only)
|
-- USER MANAGEMENT (SUPER_ADMIN only)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
|
('a0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
|
||||||
('p0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
|
('a0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
|
||||||
('p0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
|
('a0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
|
||||||
('p0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'),
|
('a0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'),
|
||||||
('p0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'),
|
('a0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'),
|
||||||
('p0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'),
|
('a0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'),
|
||||||
('p0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'),
|
('a0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'),
|
||||||
('p0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'),
|
('a0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'),
|
||||||
('p0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'),
|
('a0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'),
|
||||||
('p0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
|
('a0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
|
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'),
|
('a0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'),
|
||||||
('p0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'),
|
('a0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'),
|
||||||
('p0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'),
|
('a0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'),
|
||||||
('p0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'),
|
('a0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'),
|
||||||
('p0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
|
('a0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
|
||||||
('p0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
|
('a0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- CRM CORE (SALES_USER + MANAGERS)
|
-- CRM CORE (SALES_USER + MANAGERS)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
|
('a0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
|
||||||
('p0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
|
('a0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
|
||||||
('p0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
|
('a0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
|
||||||
('p0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
|
('a0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
|
||||||
('p0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
|
('a0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
|
||||||
('p0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
|
('a0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
|
||||||
('p0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
|
('a0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
|
||||||
('p0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
|
('a0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
|
||||||
('p0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
|
('a0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
|
||||||
('p0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
|
('a0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
|
||||||
('p0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
|
('a0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
|
||||||
('p0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
|
('a0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
|
||||||
('p0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
|
('a0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
|
||||||
('p0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
|
('a0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
|
||||||
('p0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
|
('a0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
|
||||||
('p0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
|
('a0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
|
||||||
('p0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
|
('a0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- REPORTING & INCIDENTS (ADMIN)
|
-- REPORTING & INCIDENTS (ADMIN)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
|
('a0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
|
||||||
('p0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
|
('a0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
|
||||||
('p0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
|
('a0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
|
||||||
('p0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
|
('a0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
|
||||||
('p0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
|
('a0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
|
||||||
('p0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
|
('a0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
|
||||||
('p0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
|
('a0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
|
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
|
('a0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
|
||||||
('p0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
|
('a0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
|
||||||
('p0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
|
('a0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
|
||||||
('p0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
|
('a0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
|
||||||
('p0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
|
('a0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
|
||||||
('p0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
|
('a0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- DEVELOPER PERMISSIONS
|
-- DEVELOPER PERMISSIONS
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
|
('a0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
|
||||||
('p0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
|
('a0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
|
||||||
('p0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
|
('a0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
|
||||||
('p0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
|
('a0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
|
||||||
('p0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
|
('a0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
|
||||||
('p0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
|
('a0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -121,70 +121,70 @@ ON CONFLICT DO NOTHING;
|
|||||||
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
|
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
|
||||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
-- Bans & Suspensions
|
-- Bans & Suspensions
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020001-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020004-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020004-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020005-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020006-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020006-0000-0000-0000-000000000000', NULL),
|
||||||
-- User read + disable
|
-- User read + disable
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-0000-000000000000', 'p0010009-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0010009-0000-0000-0000-000000000000', NULL),
|
||||||
-- Reporting & Incidents
|
-- Reporting & Incidents
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040001-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040002-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040003-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040003-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040004-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040004-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040005-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040006-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040007-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040007-0000-0000-0000-000000000000', NULL),
|
||||||
-- CRM read access
|
-- CRM read access
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL),
|
||||||
-- Audit read
|
-- Audit read
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0050002-0000-0000-0000-000000000000', NULL)
|
('00000002-0000-0000-0000-000000000000', 'a0050002-0000-0000-0000-000000000000', NULL)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- SALES_USER — CRM operations
|
-- SALES_USER — CRM operations
|
||||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030001-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030003-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030003-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030005-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030007-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030007-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030008-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030008-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030009-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030009-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030011-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030011-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030012-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030012-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030013-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030013-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030015-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030015-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL)
|
('00000003-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
|
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
|
||||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
-- Developer technical permissions
|
-- Developer technical permissions
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060001-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060002-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060003-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060003-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060004-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060004-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060005-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060006-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060006-0000-0000-0000-000000000000', NULL),
|
||||||
-- User self-view
|
-- User self-view
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
|
||||||
-- CRM access (matching SALES_USER level for post-sale project visibility)
|
-- CRM access (matching SALES_USER level for post-sale project visibility)
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL)
|
('00000004-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -197,19 +197,19 @@ ON CONFLICT DO NOTHING;
|
|||||||
-- dev_demo / DevTesting@2026
|
-- dev_demo / DevTesting@2026
|
||||||
|
|
||||||
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
||||||
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@crm.demo',
|
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
|
||||||
'$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
|
'$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
|
||||||
'Super', 'Admin', TRUE, FALSE),
|
'Super', 'Admin', TRUE, FALSE),
|
||||||
('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@crm.demo',
|
('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@coastit.co.za',
|
||||||
'$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
|
'$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
|
||||||
'System', 'Admin', TRUE, TRUE),
|
'System', 'Admin', TRUE, TRUE),
|
||||||
('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@crm.demo',
|
('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@coastit.co.za',
|
||||||
'$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
|
'$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
|
||||||
'Sales', 'User', TRUE, TRUE),
|
'Sales', 'User', TRUE, TRUE),
|
||||||
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@crm.demo',
|
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
||||||
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
||||||
'Dev', 'User', TRUE, TRUE)
|
'Dev', 'User', TRUE, TRUE)
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
||||||
|
|
||||||
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
||||||
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||||
@@ -288,41 +288,41 @@ ON CONFLICT DO NOTHING;
|
|||||||
|
|
||||||
-- Communication Types
|
-- Communication Types
|
||||||
INSERT INTO communication_types (id, name, description, icon) VALUES
|
INSERT INTO communication_types (id, name, description, icon) VALUES
|
||||||
('g0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
|
('b0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
|
||||||
('g0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
|
('b0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
|
||||||
('g0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
|
('b0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
|
||||||
('g0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
|
('b0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
|
||||||
('g0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
|
('b0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
|
||||||
('g0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
|
('b0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
|
||||||
('g0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
|
('b0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Task Priorities
|
-- Task Priorities
|
||||||
INSERT INTO task_priorities (id, name, color, sort_order) VALUES
|
INSERT INTO task_priorities (id, name, color, sort_order) VALUES
|
||||||
('h0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
|
('c0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
|
||||||
('h0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
|
('c0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
|
||||||
('h0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
|
('c0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
|
||||||
('h0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
|
('c0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Invoice Statuses
|
-- Invoice Statuses
|
||||||
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
|
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
|
||||||
('i0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
|
('d0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
|
||||||
('i0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
|
('d0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
|
||||||
('i0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
|
('d0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
|
||||||
('i0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
|
('d0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
|
||||||
('i0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
|
('d0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
|
||||||
('i0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
|
('d0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
|
||||||
('i0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
|
('d0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Product Categories
|
-- Product Categories
|
||||||
INSERT INTO product_categories (id, name, description, sort_order) VALUES
|
INSERT INTO product_categories (id, name, description, sort_order) VALUES
|
||||||
('j0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
|
('e0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
|
||||||
('j0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
|
('e0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
|
||||||
('j0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
|
('e0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
|
||||||
('j0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
|
('e0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
|
||||||
('j0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
|
('e0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -331,12 +331,12 @@ ON CONFLICT DO NOTHING;
|
|||||||
|
|
||||||
-- Sample Products
|
-- Sample Products
|
||||||
INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES
|
INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES
|
||||||
('k0000000-0000-0000-0000-000000000001', 'j0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00),
|
('f0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00),
|
||||||
('k0000000-0000-0000-0000-000000000002', 'j0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00),
|
('f0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00),
|
||||||
('k0000000-0000-0000-0000-000000000003', 'j0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00),
|
('f0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00),
|
||||||
('k0000000-0000-0000-0000-000000000004', 'j0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00),
|
('f0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00),
|
||||||
('k0000000-0000-0000-0000-000000000005', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00),
|
('f0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00),
|
||||||
('k0000000-0000-0000-0000-000000000006', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00)
|
('f0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Sample Customers
|
-- Sample Customers
|
||||||
@@ -376,26 +376,26 @@ ON CONFLICT DO NOTHING;
|
|||||||
|
|
||||||
-- Sample Leads
|
-- Sample Leads
|
||||||
INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES
|
INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES
|
||||||
('l0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004',
|
('c0010000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004',
|
||||||
'00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70),
|
'00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70),
|
||||||
('l0000000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003',
|
('c0010000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003',
|
||||||
'00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
|
'00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Sample Opportunities
|
-- Sample Opportunities
|
||||||
INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES
|
INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES
|
||||||
('m0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005',
|
('d0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005',
|
||||||
'00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'),
|
'00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'),
|
||||||
('m0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003',
|
('d0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003',
|
||||||
'00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
|
'00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Sample Tasks
|
-- Sample Tasks
|
||||||
INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES
|
INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES
|
||||||
('n0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'm0000000-0000-0000-0000-000000000001',
|
('e0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'd0010000-0000-0000-0000-000000000001',
|
||||||
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
||||||
'Prepare renewal proposal for TechCorp', 'h0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'),
|
'Prepare renewal proposal for TechCorp', 'c0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'),
|
||||||
('n0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'm0000000-0000-0000-0000-000000000002',
|
('e0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'd0010000-0000-0000-0000-000000000002',
|
||||||
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
||||||
'Schedule FinServ executive meeting', 'h0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days')
|
'Schedule FinServ executive meeting', 'c0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|||||||
+16
-15
@@ -1,18 +1,19 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { dirname } from "path";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import { fileURLToPath } from "url";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
...nextVitals,
|
const __dirname = dirname(__filename);
|
||||||
...nextTs,
|
|
||||||
// Override default ignores of eslint-config-next.
|
const compat = new FlatCompat({
|
||||||
globalIgnores([
|
baseDirectory: __dirname,
|
||||||
// Default ignores of eslint-config-next:
|
});
|
||||||
".next/**",
|
|
||||||
"out/**",
|
const eslintConfig = [
|
||||||
"build/**",
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
"next-env.d.ts",
|
{
|
||||||
]),
|
ignores: [".next/**", "out/**", "build/**", "next-env.d.ts"],
|
||||||
]);
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+155
@@ -32,9 +32,11 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -2010,6 +2013,17 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -5122,6 +5136,15 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5774,6 +5797,87 @@
|
|||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.13.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.14.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||||
|
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5993,6 +6097,41 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6628,6 +6767,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -7452,6 +7599,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
+4
-1
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 3006",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -33,9 +33,11 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
import { conversations as conversationsData } from "@/data/chats"
|
import { conversations as conversationsData } from "@/data/chats"
|
||||||
import {
|
import {
|
||||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||||
Smile, Flag, Ban, Trash2, Image, File, X,
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||||
|
CornerDownRight, Forward, Pencil, Download,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
@@ -31,6 +32,65 @@ import { toast } from "sonner"
|
|||||||
import data from "@emoji-mart/data"
|
import data from "@emoji-mart/data"
|
||||||
import Picker from "@emoji-mart/react"
|
import Picker from "@emoji-mart/react"
|
||||||
|
|
||||||
|
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) {
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [current, setCurrent] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(initialDuration)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
const animRef = useRef<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (isFinite(audio.duration)) setDuration(audio.duration)
|
||||||
|
}
|
||||||
|
const onEnded = () => { setPlaying(false); setCurrent(0) }
|
||||||
|
audio.addEventListener("loadedmetadata", onLoaded)
|
||||||
|
audio.addEventListener("ended", onEnded)
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||||
|
audio.removeEventListener("ended", onEnded)
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
||||||
|
const tick = () => {
|
||||||
|
if (audioRef.current) setCurrent(audioRef.current.currentTime)
|
||||||
|
animRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
animRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(animRef.current)
|
||||||
|
}, [playing])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!audioRef.current) return
|
||||||
|
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
||||||
|
else { audioRef.current.play(); setPlaying(true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
|
||||||
|
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
|
||||||
|
const fmt = (s: number) => {
|
||||||
|
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
|
||||||
|
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-[180px]">
|
||||||
|
<audio ref={audioRef} src={src} preload="metadata" />
|
||||||
|
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
|
||||||
|
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatsPage() {
|
export default function ChatsPage() {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
@@ -42,14 +102,53 @@ export default function ChatsPage() {
|
|||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
||||||
const [reportReason, setReportReason] = useState("")
|
const [reportReason, setReportReason] = useState("")
|
||||||
|
const [forwardDialogOpen, setForwardDialogOpen] = useState(false)
|
||||||
|
const [forwardMessage, setForwardMessage] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||||
|
const [forwardSearch, setForwardSearch] = useState("")
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [isPaused, setIsPaused] = useState(false)
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0)
|
||||||
|
const recordingDurationRef = useRef(0)
|
||||||
|
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
|
||||||
|
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
||||||
|
const [replyingTo, setReplyingTo] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||||
|
const [editingMessage, setEditingMessage] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||||
|
const [replyMap, setReplyMap] = useState<Map<string, { repliedToSender: string; repliedToContent: string }>>(new Map())
|
||||||
|
const [messageStatus, setMessageStatus] = useState<Map<string, "sent" | "delivered" | "read">>(() => {
|
||||||
|
const map = new Map<string, "sent" | "delivered" | "read">()
|
||||||
|
conversationsData.forEach((conv) =>
|
||||||
|
conv.messages.forEach((msg) => {
|
||||||
|
if (msg.senderId === "user1") map.set(msg.id, "read")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return map
|
||||||
|
})
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const recordingChunksRef = useRef<Blob[]>([])
|
||||||
const resizeStartRef = useRef({ x: 0, width: 0 })
|
const resizeStartRef = useRef({ x: 0, width: 0 })
|
||||||
|
|
||||||
|
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
||||||
|
|
||||||
|
const autoResizeTextarea = useCallback(() => {
|
||||||
|
const ta = textareaRef.current
|
||||||
|
if (!ta) return
|
||||||
|
ta.style.height = "auto"
|
||||||
|
ta.style.height = `${ta.scrollHeight}px`
|
||||||
|
}, [])
|
||||||
|
|
||||||
const [conversations, setConversations] = useState(conversationsData)
|
const [conversations, setConversations] = useState(conversationsData)
|
||||||
|
if (!user) return null
|
||||||
const conversation = conversations.find((c) => c.id === activeChat)
|
const conversation = conversations.find((c) => c.id === activeChat)
|
||||||
const otherParticipant = (conv: typeof conversationsData[0]) =>
|
const otherParticipant = (conv: typeof conversationsData[0]) =>
|
||||||
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
|
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
|
||||||
|
const filteredConversations = searchQuery.trim()
|
||||||
|
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
: conversations
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
@@ -83,9 +182,14 @@ export default function ChatsPage() {
|
|||||||
}
|
}
|
||||||
}, [isResizing])
|
}, [isResizing])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}, [conversations])
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||||
setMessageInput((prev) => prev + emoji.native)
|
setMessageInput((prev) => prev + emoji.native)
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -98,16 +202,15 @@ export default function ChatsPage() {
|
|||||||
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = (e: React.FormEvent) => {
|
const addMessageToChat = (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, attachments?: { name: string; type: string; url: string }[]) => {
|
||||||
e.preventDefault()
|
const id = crypto.randomUUID()
|
||||||
if (!messageInput.trim() && attachments.length === 0) return
|
|
||||||
const newMessage = {
|
const newMessage = {
|
||||||
id: crypto.randomUUID(),
|
id,
|
||||||
conversationId: activeChat!,
|
conversationId: activeChat!,
|
||||||
senderId: "user1",
|
senderId: "user1",
|
||||||
senderName: "Sarah Chen",
|
senderName: "Sarah Chen",
|
||||||
senderAvatar: "SC",
|
senderAvatar: "SC",
|
||||||
content: messageInput.trim(),
|
content,
|
||||||
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
}
|
}
|
||||||
setConversations((prev) =>
|
setConversations((prev) =>
|
||||||
@@ -116,17 +219,165 @@ export default function ChatsPage() {
|
|||||||
? {
|
? {
|
||||||
...conv,
|
...conv,
|
||||||
messages: [...conv.messages, newMessage],
|
messages: [...conv.messages, newMessage],
|
||||||
lastMessage: newMessage.content,
|
lastMessage: voice ? "🎤 Voice note" : (replyTo ? `↪ ${content}` : content),
|
||||||
lastMessageTime: "Just now",
|
lastMessageTime: "Just now",
|
||||||
unread: 0,
|
unread: 0,
|
||||||
}
|
}
|
||||||
: conv
|
: conv
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
setMessageStatus((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, "sent")
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
setMessageStatus((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (next.get(id) === "sent") next.set(id, "delivered")
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
if (voice) {
|
||||||
|
setVoiceMessages((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, voice)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (replyTo) {
|
||||||
|
setReplyMap((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, { repliedToSender: replyTo.senderName, repliedToContent: replyTo.content })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
setMessageAttachments((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, attachments)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!messageInput.trim() && attachments.length === 0) return
|
||||||
|
if (editingMessage) {
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((conv) => ({
|
||||||
|
...conv,
|
||||||
|
messages: conv.messages.map((m) =>
|
||||||
|
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||||
|
),
|
||||||
|
lastMessage: conv.id === activeChat && conv.messages.some((m) => m.id === editingMessage.id)
|
||||||
|
? messageInput.trim() : conv.lastMessage,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setEditingMessage(null)
|
||||||
setMessageInput("")
|
setMessageInput("")
|
||||||
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fileAttachments = attachments.length > 0
|
||||||
|
? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) }))
|
||||||
|
: undefined
|
||||||
|
if (replyingTo) {
|
||||||
|
const replySender = replyingTo.senderId === "user1" ? user.name : otherParticipant(conversation!).name
|
||||||
|
addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||||
|
setReplyingTo(null)
|
||||||
|
} else {
|
||||||
|
addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||||
|
}
|
||||||
|
setMessageInput("")
|
||||||
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteMessage = (messageId: string) => {
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((conv) => ({
|
||||||
|
...conv,
|
||||||
|
messages: conv.messages.filter((m) => m.id !== messageId),
|
||||||
|
lastMessage: conv.messages.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
toast.success("Message deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" })
|
||||||
|
recordingChunksRef.current = []
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
||||||
|
}
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
addMessageToChat("", { url, duration: recordingDurationRef.current })
|
||||||
|
stream.getTracks().forEach((t) => t.stop())
|
||||||
|
setRecordingDuration(0)
|
||||||
|
}
|
||||||
|
mediaRecorderRef.current = recorder
|
||||||
|
recorder.start()
|
||||||
|
setIsRecording(true)
|
||||||
|
setIsPaused(false)
|
||||||
|
} catch {
|
||||||
|
toast.error("Microphone access denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePauseRecording = () => {
|
||||||
|
const recorder = mediaRecorderRef.current
|
||||||
|
if (!recorder || recorder.state === "inactive") return
|
||||||
|
if (recorder.state === "recording") {
|
||||||
|
recorder.pause()
|
||||||
|
setIsPaused(true)
|
||||||
|
} else {
|
||||||
|
recorder.resume()
|
||||||
|
setIsPaused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
mediaRecorderRef.current?.stop()
|
||||||
|
setIsRecording(false)
|
||||||
|
setIsPaused(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const discardRecording = () => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||||
|
mediaRecorderRef.current.ondataavailable = null
|
||||||
|
mediaRecorderRef.current.onstop = null
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
mediaRecorderRef.current.stream?.getTracks().forEach((t) => t.stop())
|
||||||
|
}
|
||||||
|
setIsRecording(false)
|
||||||
|
setIsPaused(false)
|
||||||
|
setRecordingDuration(0)
|
||||||
|
recordingDurationRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRecording) return
|
||||||
|
recordingDurationRef.current = 0
|
||||||
|
setRecordingDuration(0)
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
recordingDurationRef.current += 1
|
||||||
|
setRecordingDuration(recordingDurationRef.current)
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isRecording])
|
||||||
|
|
||||||
|
const formatDuration = (secs: number) => {
|
||||||
|
const m = Math.floor(secs / 60)
|
||||||
|
const s = secs % 60
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes < 1024) return bytes + " B"
|
if (bytes < 1024) return bytes + " B"
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||||||
@@ -146,11 +397,11 @@ export default function ChatsPage() {
|
|||||||
<h2 className="text-lg font-semibold">Chats</h2>
|
<h2 className="text-lg font-semibold">Chats</h2>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input placeholder="Search conversations..." className="h-9 pl-9" />
|
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{conversations.map((conv) => {
|
{filteredConversations.map((conv) => {
|
||||||
const person = otherParticipant(conv)
|
const person = otherParticipant(conv)
|
||||||
const isActive = conv.id === activeChat
|
const isActive = conv.id === activeChat
|
||||||
return (
|
return (
|
||||||
@@ -180,6 +431,9 @@ export default function ChatsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{filteredConversations.length === 0 && searchQuery && (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">No conversations found</div>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,16 +460,10 @@ export default function ChatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
|
||||||
variant="ghost" size="icon" className="h-8 w-8"
|
|
||||||
onClick={() => toast.info("Voice calling coming soon")}
|
|
||||||
>
|
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
||||||
variant="ghost" size="icon" className="h-8 w-8"
|
|
||||||
onClick={() => toast.info("Video calling coming soon")}
|
|
||||||
>
|
|
||||||
<Video className="h-4 w-4" />
|
<Video className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -234,10 +482,7 @@ export default function ChatsPage() {
|
|||||||
<Ban className="mr-2 h-4 w-4" /> Block
|
<Ban className="mr-2 h-4 w-4" /> Block
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem className="text-destructive" onClick={() => toast.info("Conversation deleted")}>
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => toast.info("Conversation deleted")}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -246,12 +491,13 @@ export default function ChatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<ScrollArea className="flex-1 p-6">
|
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 min-h-0">
|
||||||
{conversation.messages.map((msg) => {
|
{conversation.messages.map((msg) => {
|
||||||
const isMe = msg.senderId === "user1"
|
const isMe = msg.senderId === "user1"
|
||||||
|
const voiceUrl = voiceMessages.get(msg.id)
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={cn("flex gap-3", isMe && "flex-row-reverse")}>
|
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
||||||
<Avatar className="h-8 w-8 mt-0.5 shrink-0">
|
<Avatar className="h-8 w-8 mt-0.5 shrink-0">
|
||||||
{isMe ? <AvatarImage src={user.avatar} /> : null}
|
{isMe ? <AvatarImage src={user.avatar} /> : null}
|
||||||
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
|
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
|
||||||
@@ -261,92 +507,208 @@ export default function ChatsPage() {
|
|||||||
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
|
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-2xl px-4 py-2.5 text-sm",
|
"rounded-2xl px-5 py-3 text-base relative",
|
||||||
isMe ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-muted rounded-tl-sm"
|
isMe ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-muted rounded-tl-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{msg.content}
|
{!isMe && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className="absolute -right-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 flex items-center justify-center rounded-full hover:bg-sky-400/20">
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5 text-sky-400" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" side="top" className="w-32">
|
||||||
|
<DropdownMenuItem onClick={() => setReplyingTo(msg)}>
|
||||||
|
<CornerDownRight className="mr-2 h-4 w-4" /> Reply
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => { setForwardMessage(msg); setForwardDialogOpen(true) }}>
|
||||||
|
<Forward className="mr-2 h-4 w-4" /> Forward
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{isMe && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className="absolute -left-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 flex items-center justify-center rounded-full hover:bg-sky-400/20">
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5 text-sky-400" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="top" className="w-32">
|
||||||
|
<DropdownMenuItem onClick={() => { setEditingMessage(msg); setMessageInput(msg.content) }}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setReplyingTo(msg)}>
|
||||||
|
<CornerDownRight className="mr-2 h-4 w-4" /> Reply
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => { setForwardMessage(msg); setForwardDialogOpen(true) }}>
|
||||||
|
<Forward className="mr-2 h-4 w-4" /> Forward
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => handleDeleteMessage(msg.id)}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const replyInfo = replyMap.get(msg.id)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{replyInfo && (
|
||||||
|
<div className={cn("mb-1.5 pl-2 border-l-2 overflow-hidden bg-black/70 rounded-sm text-white", isMe ? "border-white/40" : "border-primary/40")}>
|
||||||
|
<p className="text-[11px] font-medium truncate">{replyInfo.repliedToSender}</p>
|
||||||
|
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground mt-1 px-1">{msg.timestamp}</span>
|
)}
|
||||||
|
{voiceUrl ? (
|
||||||
|
<div>
|
||||||
|
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
|
||||||
|
{msg.content && (
|
||||||
|
<p className="mt-1 text-sm text-black">{msg.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const files = messageAttachments.get(msg.id)
|
||||||
|
return files && files.length > 0 ? (
|
||||||
|
<div className="space-y-1.5 mb-1.5">
|
||||||
|
{files.map((file, i) =>
|
||||||
|
file.type.startsWith("image/") ? (
|
||||||
|
<div key={i} className="relative group">
|
||||||
|
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => window.open(file.url, "_blank")} />
|
||||||
|
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Download className="h-3.5 w-3.5 text-white" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<a key={i} href={file.url} download={file.name} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
|
||||||
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
||||||
|
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
{isOnlyEmoji(msg.content) ? (
|
||||||
|
<span className="text-4xl leading-none">{msg.content.trim()}</span>
|
||||||
|
) : (
|
||||||
|
msg.content
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground mt-1.5 px-1">
|
||||||
|
{msg.timestamp}
|
||||||
|
{isMe && (
|
||||||
|
(() => {
|
||||||
|
const status = messageStatus.get(msg.id)
|
||||||
|
if (status === "read") return <CheckCheck className="h-3 w-3 text-blue-400" />
|
||||||
|
if (status === "delivered") return <CheckCheck className="h-3 w-3 text-muted-foreground/60" />
|
||||||
|
return <Check className="h-3 w-3 text-muted-foreground/60" />
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="p-4 border-t shrink-0 space-y-2">
|
<div className="p-4 border-t shrink-0 space-y-2">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{attachments.map((file, i) => (
|
{attachments.map((file, i) => (
|
||||||
<div
|
<div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm max-w-[200px]">
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm max-w-[200px]"
|
|
||||||
>
|
|
||||||
{isImageFile(file) ? (
|
{isImageFile(file) ? (
|
||||||
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{file.name}</span>
|
<span className="truncate">{file.name}</span>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(file.size)}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(file.size)}</span>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-5 w-5 -mr-1 shrink-0" onClick={() => removeAttachment(i)}>
|
||||||
variant="ghost" size="icon" className="h-5 w-5 -mr-1 shrink-0"
|
|
||||||
onClick={() => removeAttachment(i)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Reply bar */}
|
||||||
|
{replyingTo && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border-l-4 border-primary bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-primary">
|
||||||
|
Replying to {replyingTo.senderId === "user1" ? "yourself" : otherParticipant(conversation).name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{replyingTo.content}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setReplyingTo(null)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editingMessage && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
<Pencil className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-xs text-muted-foreground">Editing message</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 ml-auto" onClick={() => { setEditingMessage(null); setMessageInput(""); setTimeout(() => autoResizeTextarea(), 0) }}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form onSubmit={handleSend} className="flex items-center gap-2">
|
<form onSubmit={handleSend} className="flex items-center gap-2">
|
||||||
<input
|
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
|
||||||
ref={fileInputRef}
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => fileInputRef.current?.click()}>
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
{isRecording ? (
|
||||||
value={messageInput}
|
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
||||||
onChange={(e) => setMessageInput(e.target.value)}
|
<span className={cn("h-2 w-2 rounded-full", isPaused ? "bg-muted-foreground" : "bg-destructive animate-pulse")} />
|
||||||
placeholder="Type a message..."
|
<span className="text-sm tabular-nums font-medium text-muted-foreground">{formatDuration(recordingDuration)}</span>
|
||||||
className="h-9 w-full pr-9"
|
<div className="flex-1" />
|
||||||
/>
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-muted-foreground" onClick={togglePauseRecording}>
|
||||||
|
{isPaused ? <Play className="h-3.5 w-3.5 fill-current ml-0.5" /> : <Pause className="h-3.5 w-3.5 fill-current" />}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-destructive hover:text-destructive" onClick={discardRecording}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-destructive hover:text-destructive" onClick={stopRecording}>
|
||||||
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||||
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
||||||
<Button
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
|
||||||
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0"
|
|
||||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
|
||||||
>
|
|
||||||
<Smile className="h-4 w-4 text-muted-foreground" />
|
<Smile className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={startRecording}>
|
||||||
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
{showEmojiPicker && (
|
{showEmojiPicker && (
|
||||||
<div className="absolute bottom-full right-0 mb-2 z-50">
|
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||||
<Picker
|
<Picker data={data} onEmojiSelect={handleEmojiSelect} theme={theme === "dark" ? "dark" : "light"} previewPosition="none" skinTonePosition="none" set="native" emojiSize={36} maxFrequentRows={2} />
|
||||||
data={data}
|
|
||||||
onEmojiSelect={handleEmojiSelect}
|
|
||||||
theme={theme === "dark" ? "dark" : "light"}
|
|
||||||
previewPosition="none"
|
|
||||||
skinTonePosition="none"
|
|
||||||
set="native"
|
|
||||||
maxFrequentRows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
||||||
<Send className="h-4 w-4" />
|
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -365,37 +727,95 @@ export default function ChatsPage() {
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Report {conversation ? otherParticipant(conversation).name : "User"}</DialogTitle>
|
<DialogTitle>Report {conversation ? otherParticipant(conversation).name : "User"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Let us know why you're reporting this conversation.</DialogDescription>
|
||||||
Let us know why you're reporting this conversation.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 py-2">
|
<div className="space-y-3 py-2">
|
||||||
<Label htmlFor="reason">Reason</Label>
|
<Label htmlFor="reason">Reason</Label>
|
||||||
<Textarea
|
<Textarea id="reason" placeholder="Describe the issue..." value={reportReason} onChange={(e) => setReportReason(e.target.value)} rows={4} />
|
||||||
id="reason"
|
|
||||||
placeholder="Describe the issue..."
|
|
||||||
value={reportReason}
|
|
||||||
onChange={(e) => setReportReason(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild><Button variant="outline">Cancel</Button></DialogClose>
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button onClick={() => { toast.success("Report submitted"); setReportReason(""); setReportDialogOpen(false) }} disabled={!reportReason.trim()}>Submit Report</Button>
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
toast.success("Report submitted")
|
|
||||||
setReportReason("")
|
|
||||||
setReportDialogOpen(false)
|
|
||||||
}}
|
|
||||||
disabled={!reportReason.trim()}
|
|
||||||
>
|
|
||||||
Submit Report
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Forward dialog */}
|
||||||
|
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Forward message</DialogTitle>
|
||||||
|
<DialogDescription>Select who to forward this message to.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input value={forwardSearch} onChange={(e) => setForwardSearch(e.target.value)} placeholder="Search contacts..." className="h-9 pl-9" />
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-64">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{conversations
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.messages.length - a.messages.length)
|
||||||
|
.filter((conv) => {
|
||||||
|
const person = otherParticipant(conv)
|
||||||
|
return person.name.toLowerCase().includes(forwardSearch.toLowerCase())
|
||||||
|
})
|
||||||
|
.map((conv) => {
|
||||||
|
const person = otherParticipant(conv)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const msg = forwardMessage
|
||||||
|
if (!msg) return
|
||||||
|
const newContent = `📨 Forwarded: ${msg.content}`
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === conv.id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
messages: [
|
||||||
|
...c.messages,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
conversationId: conv.id,
|
||||||
|
senderId: "user1",
|
||||||
|
senderName: "Sarah Chen",
|
||||||
|
senderAvatar: "SC",
|
||||||
|
content: newContent,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastMessage: newContent,
|
||||||
|
lastMessageTime: "Just now",
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setForwardDialogOpen(false)
|
||||||
|
setForwardSearch("")
|
||||||
|
toast.success("Message forwarded")
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 rounded-lg p-2.5 text-left transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Avatar className="h-9 w-9 shrink-0">
|
||||||
|
<AvatarFallback>{person.avatar}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{person.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{person.role} · {conv.messages.length} messages</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{forwardSearch && conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(forwardSearch.toLowerCase())).length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No contacts found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/shared/page-header"
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
import { StatCard } from "@/components/dashboard/stat-card"
|
import { StatCard } from "@/components/dashboard/stat-card"
|
||||||
|
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
||||||
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
||||||
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
||||||
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
||||||
import { dashboardStats } from "@/data/dashboard"
|
import { getDashboardStats } from "@/data/dashboard"
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@@ -23,19 +24,26 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { DashboardStats } from "@/types"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const stats = dashboardStats
|
|
||||||
const [period, setPeriod] = useState("6months")
|
const [period, setPeriod] = useState("6months")
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
|
||||||
const statCards = [
|
useEffect(() => {
|
||||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: "All time" },
|
setStats(getDashboardStats(period))
|
||||||
|
}, [period])
|
||||||
|
|
||||||
|
const statCards = stats
|
||||||
|
? [
|
||||||
|
{ title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: stats.periodLabel },
|
||||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" },
|
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" },
|
||||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" },
|
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" },
|
||||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" },
|
{ title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" },
|
||||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" },
|
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" },
|
||||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` },
|
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` },
|
||||||
]
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -55,17 +63,20 @@ export default function DashboardPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{statCards.map((card, i) => (
|
{stats
|
||||||
|
? statCards.map((card, i) => (
|
||||||
<StatCard key={card.title} {...card} index={i} />
|
<StatCard key={card.title} {...card} index={i} />
|
||||||
))}
|
))
|
||||||
|
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<LeadStatusChart data={stats.statusDistribution} />
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
<LeadsPerMonthChart data={stats.leadsPerMonth} />
|
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecentLeadsTable leads={stats.recentLeads} />
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AppShell } from "@/components/layout/app-shell"
|
import { AppShell } from "@/components/layout/app-shell"
|
||||||
import { UserProvider } from "@/providers/user-provider"
|
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useUser()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return <AppShell>{children}</AppShell>
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -10,7 +27,7 @@ export default function DashboardLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<AppShell>{children}</AppShell>
|
<DashboardContent>{children}</DashboardContent>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
|
import { use } from "react"
|
||||||
import { PageHeader } from "@/components/shared/page-header"
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
|
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
|
||||||
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
|
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
|
||||||
@@ -19,8 +20,9 @@ import { getLeadById } from "@/data/leads"
|
|||||||
import { getNotesByLeadId } from "@/data/notes"
|
import { getNotesByLeadId } from "@/data/notes"
|
||||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||||
|
|
||||||
export default function LeadDetailsPage({ params }: { params: { id: string } }) {
|
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const lead = getLeadById(params.id)
|
const { id } = use(params)
|
||||||
|
const lead = getLeadById(id)
|
||||||
const leadNotes = lead ? getNotesByLeadId(lead.id) : []
|
const leadNotes = lead ? getNotesByLeadId(lead.id) : []
|
||||||
|
|
||||||
if (!lead) {
|
if (!lead) {
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { Lead, LeadStatus } from "@/types"
|
||||||
|
import { leads } from "@/data/leads"
|
||||||
|
import { users } from "@/data/users"
|
||||||
|
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||||
|
|
||||||
|
const leadFormSchema = z.object({
|
||||||
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
|
contactName: z.string().min(1, "Contact name is required"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.string(),
|
||||||
|
assignedUserId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type LeadFormValues = z.infer<typeof leadFormSchema>
|
||||||
|
|
||||||
|
export default function CreateLeadPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<LeadFormValues>({
|
||||||
|
resolver: zodResolver(leadFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
companyName: "",
|
||||||
|
contactName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
source: "",
|
||||||
|
description: "",
|
||||||
|
status: "open",
|
||||||
|
assignedUserId: "none",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(values: LeadFormValues) {
|
||||||
|
setSaving(true)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const assignedUserId: string | null = values.assignedUserId === "none" ? null : (values.assignedUserId ?? null)
|
||||||
|
const assignedUser = assignedUserId ? users.find((u) => u.id === assignedUserId) ?? null : null
|
||||||
|
|
||||||
|
const newLead: Lead = {
|
||||||
|
id: `lead-${String(leads.length + 1).padStart(3, "0")}`,
|
||||||
|
companyName: values.companyName,
|
||||||
|
contactName: values.contactName,
|
||||||
|
email: values.email,
|
||||||
|
phone: values.phone ?? "",
|
||||||
|
source: values.source ?? "",
|
||||||
|
description: values.description ?? "",
|
||||||
|
status: values.status as LeadStatus,
|
||||||
|
assignedUserId,
|
||||||
|
assignedUser,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
leads.unshift(newLead)
|
||||||
|
router.push("/leads")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href="/leads"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to leads
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Create New Lead</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="companyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Company Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Acme Corp" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="contactName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Contact Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="John Doe" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="john@acme.com" type="email" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Phone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="(555) 123-4567" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="source"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Source</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select source" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{LEAD_SOURCES.map((source) => (
|
||||||
|
<SelectItem key={source} value={source}>
|
||||||
|
{source}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Brief description of the lead and their requirements..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="assignedUserId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Assign To</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Unassigned</SelectItem>
|
||||||
|
{users.filter((u) => u.active).map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name} ({user.role})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Create Lead"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/leads")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useMemo } from "react"
|
import { useState, useMemo } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { PageHeader } from "@/components/shared/page-header"
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
import { LeadsTable } from "@/components/leads/leads-table"
|
import { LeadsTable } from "@/components/leads/leads-table"
|
||||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||||
import { LeadFormDialog } from "@/components/leads/lead-form-dialog"
|
|
||||||
import { leads } from "@/data/leads"
|
import { leads } from "@/data/leads"
|
||||||
|
import { filterLeadsByPeriod } from "@/lib/date-utils"
|
||||||
|
|
||||||
export default function LeadsPage() {
|
export default function LeadsPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState("all")
|
const [statusFilter, setStatusFilter] = useState("all")
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [periodFilter, setPeriodFilter] = useState("all")
|
||||||
|
|
||||||
const filteredLeads = useMemo(() => {
|
const filteredLeads = useMemo(() => {
|
||||||
let result = leads
|
let result = leads
|
||||||
|
|
||||||
|
if (periodFilter && periodFilter !== "all") {
|
||||||
|
result = filterLeadsByPeriod(result, periodFilter)
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
@@ -31,7 +37,7 @@ export default function LeadsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}, [search, statusFilter])
|
}, [search, statusFilter, periodFilter])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -47,13 +53,13 @@ export default function LeadsPage() {
|
|||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onCreateClick={() => setCreateOpen(true)}
|
periodFilter={periodFilter}
|
||||||
|
onPeriodFilterChange={setPeriodFilter}
|
||||||
|
onCreateClick={() => router.push("/leads/new")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LeadsTable data={filteredLeads} />
|
<LeadsTable data={filteredLeads} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LeadFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, updateAvatar } = useUser()
|
const { user, updateAvatar } = useUser()
|
||||||
|
if (!user) return null
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import {
|
||||||
|
comparePassword,
|
||||||
|
getUserByEmail,
|
||||||
|
getUserByUsername,
|
||||||
|
mapDbUserToSessionUser,
|
||||||
|
recordLoginAttempt,
|
||||||
|
incrementFailedAttempts,
|
||||||
|
resetFailedAttempts,
|
||||||
|
isAccountLocked,
|
||||||
|
createSession,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, username, password } = await request.json()
|
||||||
|
|
||||||
|
const credential = email || username
|
||||||
|
|
||||||
|
if (!credential || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Email/Username and password are required." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credential.trim().length === 0 || password.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credentials cannot be empty." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddress =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"127.0.0.1"
|
||||||
|
|
||||||
|
const userAgent = request.headers.get("user-agent") || null
|
||||||
|
|
||||||
|
// Try to find user by email first, then by username
|
||||||
|
let dbUser =
|
||||||
|
email || credential.includes("@")
|
||||||
|
? await getUserByEmail(credential)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
dbUser = await getUserByUsername(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
await recordLoginAttempt(
|
||||||
|
null,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
false,
|
||||||
|
"User not found"
|
||||||
|
)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email/username or password." },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockStatus = await isAccountLocked(dbUser)
|
||||||
|
if (lockStatus.locked) {
|
||||||
|
await recordLoginAttempt(
|
||||||
|
dbUser.id,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
false,
|
||||||
|
lockStatus.reason
|
||||||
|
)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: lockStatus.reason },
|
||||||
|
{ status: 423 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await comparePassword(password, dbUser.password_hash)
|
||||||
|
if (!valid) {
|
||||||
|
await incrementFailedAttempts(dbUser.id)
|
||||||
|
await recordLoginAttempt(
|
||||||
|
dbUser.id,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
false,
|
||||||
|
"Invalid password"
|
||||||
|
)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email/username or password." },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetFailedAttempts(dbUser.id)
|
||||||
|
await recordLoginAttempt(
|
||||||
|
dbUser.id,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
await createSession(dbUser.id, dbUser.role_name)
|
||||||
|
|
||||||
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
|
return NextResponse.json({ user }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { destroySession } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
await destroySession()
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Logout failed." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ user }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth me error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication service unavailable." },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,8 +68,11 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-72
@@ -1,32 +1,107 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { Suspense, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { COMPANY_NAME } from "@/lib/constants"
|
import { COMPANY_NAME } from "@/lib/constants"
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
export default function LoginPage() {
|
function LoginForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [email, setEmail] = useState("admin@coastalit.com")
|
const searchParams = useSearchParams()
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [remember, setRemember] = useState(false)
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
|
||||||
router.push("/dashboard")
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Authentication failed.")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const redirect = searchParams.get("redirect") || "/dashboard"
|
||||||
|
router.push(redirect)
|
||||||
|
} catch {
|
||||||
|
setError("Unable to connect to authentication service. Please ensure the database is running.")
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@company.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Left - Brand panel */}
|
{/* Left - Brand panel */}
|
||||||
@@ -110,69 +185,9 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<Suspense fallback={<div className="text-center text-muted-foreground py-8">Loading...</div>}>
|
||||||
<div className="space-y-2">
|
<LoginForm />
|
||||||
<Label htmlFor="email">Email</Label>
|
</Suspense>
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="name@company.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="remember"
|
|
||||||
checked={remember}
|
|
||||||
onCheckedChange={(checked) => setRemember(checked as boolean)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="remember"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
Remember me
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{loading ? "Signing in..." : "Sign in"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
By signing in, you agree to our{" "}
|
By signing in, you agree to our{" "}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { useState, useMemo } from "react"
|
|||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
interface MonthlyData {
|
interface IntervalData {
|
||||||
month: string
|
label: string
|
||||||
leads: number
|
leads: number
|
||||||
closed: number
|
closed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LeadsPerMonthChartProps {
|
interface LeadsPerMonthChartProps {
|
||||||
data: MonthlyData[]
|
data: IntervalData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAR_GRADIENT_TOP = "#3B6AB8"
|
const BAR_GRADIENT_TOP = "#3B6AB8"
|
||||||
@@ -138,7 +138,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={d.month}
|
key={d.label}
|
||||||
onMouseEnter={() => setHovered(i)}
|
onMouseEnter={() => setHovered(i)}
|
||||||
onMouseLeave={() => setHovered(null)}
|
onMouseLeave={() => setHovered(null)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
@@ -188,7 +188,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
|||||||
fontWeight={isActive ? 600 : 400}
|
fontWeight={isActive ? 600 : 400}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
>
|
>
|
||||||
{d.month}
|
{d.label}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
@@ -216,7 +216,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
|
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
|
||||||
{activeDatum.month}
|
{activeDatum.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface SidebarProps {
|
|||||||
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
|
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
if (!user) return null
|
||||||
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
|
|||||||
@@ -35,12 +35,13 @@ interface TopbarProps {
|
|||||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const { user } = useUser()
|
const { user, logout } = useUser()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => { setMounted(true) }, [])
|
useEffect(() => { setMounted(true) }, [])
|
||||||
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
if (!user) return null
|
||||||
|
const initials = user.name.split(" ").map((n: string) => n[0]).join("").toUpperCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
|
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
|
||||||
@@ -139,7 +140,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive">
|
<DropdownMenuItem className="text-destructive" onClick={logout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Log out
|
Log out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
|
|||||||
source: "",
|
source: "",
|
||||||
description: "",
|
description: "",
|
||||||
status: "open",
|
status: "open",
|
||||||
assignedUserId: "",
|
assignedUserId: "none",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
|
|||||||
source: lead.source || "",
|
source: lead.source || "",
|
||||||
description: lead.description || "",
|
description: lead.description || "",
|
||||||
status: lead.status,
|
status: lead.status,
|
||||||
assignedUserId: lead.assignedUserId || "",
|
assignedUserId: lead.assignedUserId || "none",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -91,13 +91,17 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
|
|||||||
source: "",
|
source: "",
|
||||||
description: "",
|
description: "",
|
||||||
status: "open",
|
status: "open",
|
||||||
assignedUserId: "",
|
assignedUserId: "none",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [lead, form])
|
}, [lead, form])
|
||||||
|
|
||||||
function onSubmit(values: LeadFormValues) {
|
function onSubmit(values: LeadFormValues) {
|
||||||
console.log(values)
|
const payload = {
|
||||||
|
...values,
|
||||||
|
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
|
||||||
|
}
|
||||||
|
console.log(payload)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +250,7 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Unassigned</SelectItem>
|
<SelectItem value="none">Unassigned</SelectItem>
|
||||||
{users.filter(u => u.active).map((user) => (
|
{users.filter(u => u.active).map((user) => (
|
||||||
<SelectItem key={user.id} value={user.id}>
|
<SelectItem key={user.id} value={user.id}>
|
||||||
{user.name} ({user.role})
|
{user.name} ({user.role})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -10,14 +9,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Search, Plus, SlidersHorizontal } from "lucide-react"
|
import { Search, Plus } from "lucide-react"
|
||||||
import { LeadStatus } from "@/types"
|
|
||||||
|
|
||||||
interface LeadsTableToolbarProps {
|
interface LeadsTableToolbarProps {
|
||||||
search: string
|
search: string
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
statusFilter: string
|
statusFilter: string
|
||||||
onStatusFilterChange: (value: string) => void
|
onStatusFilterChange: (value: string) => void
|
||||||
|
periodFilter: string
|
||||||
|
onPeriodFilterChange: (value: string) => void
|
||||||
onCreateClick: () => void
|
onCreateClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ export function LeadsTableToolbar({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
|
periodFilter,
|
||||||
|
onPeriodFilterChange,
|
||||||
onCreateClick,
|
onCreateClick,
|
||||||
}: LeadsTableToolbarProps) {
|
}: LeadsTableToolbarProps) {
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +42,18 @@ export function LeadsTableToolbar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={periodFilter} onValueChange={onPeriodFilterChange}>
|
||||||
|
<SelectTrigger className="h-9 w-[150px]">
|
||||||
|
<SelectValue placeholder="All Time" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
|
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="6months">Last 6 months</SelectItem>
|
||||||
|
<SelectItem value="12months">Last 12 months</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||||
<SelectTrigger className="h-9 w-[140px]">
|
<SelectTrigger className="h-9 w-[140px]">
|
||||||
<SelectValue placeholder="All Statuses" />
|
<SelectValue placeholder="All Statuses" />
|
||||||
@@ -53,10 +67,6 @@ export function LeadsTableToolbar({
|
|||||||
<SelectItem value="ignored">Ignored</SelectItem>
|
<SelectItem value="ignored">Ignored</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="sm" variant="outline" className="h-9 gap-2">
|
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Filters</span>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" className="h-9 gap-2" onClick={onCreateClick}>
|
<Button size="sm" className="h-9 gap-2" onClick={onCreateClick}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Create Lead</span>
|
<span className="hidden sm:inline">Create Lead</span>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const ScrollArea = React.forwardRef<
|
|||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollBar />
|
<ScrollBar forceMount />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
))
|
))
|
||||||
@@ -28,14 +28,14 @@ const ScrollBar = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex touch-none select-none transition-colors",
|
"flex touch-none select-none transition-opacity",
|
||||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
orientation === "vertical" && "h-full w-3 border-l border-l-transparent p-[1px]",
|
||||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-muted-foreground/30" />
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
))
|
))
|
||||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|||||||
+32
-30
@@ -1,29 +1,31 @@
|
|||||||
import { DashboardStats } from "@/types"
|
import { DashboardStats } from "@/types"
|
||||||
import { leads } from "./leads"
|
import { leads } from "./leads"
|
||||||
|
import { filterLeadsByPeriod, groupLeadsByInterval } from "@/lib/date-utils"
|
||||||
|
|
||||||
function getLeadsPerMonth() {
|
const periodLabels: Record<string, string> = {
|
||||||
const months: { month: string; leads: number; closed: number }[] = []
|
"7days": "Last 7 days",
|
||||||
const now = new Date()
|
"30days": "Last 30 days",
|
||||||
|
"6months": "Last 6 months",
|
||||||
for (let i = 5; i >= 0; i--) {
|
"12months": "Last 12 months",
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
|
||||||
const monthStr = d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
|
||||||
const yearMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
|
|
||||||
|
|
||||||
const leadsCount = leads.filter((l) => l.createdAt.startsWith(yearMonth)).length
|
|
||||||
const closedCount = leads.filter(
|
|
||||||
(l) => l.status === "closed" && l.updatedAt.startsWith(yearMonth)
|
|
||||||
).length
|
|
||||||
|
|
||||||
months.push({ month: monthStr, leads: leadsCount, closed: closedCount })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return months
|
export function getDashboardStats(period: string): DashboardStats {
|
||||||
}
|
const filtered = filterLeadsByPeriod(leads, period)
|
||||||
|
const label = periodLabels[period] ?? "Selected period"
|
||||||
|
|
||||||
|
const totalLeads = filtered.length
|
||||||
|
const openLeads = filtered.filter((l) => l.status === "open").length
|
||||||
|
const contactedLeads = filtered.filter((l) => l.status === "contacted").length
|
||||||
|
const pendingLeads = filtered.filter((l) => l.status === "pending").length
|
||||||
|
const closedLeads = filtered.filter((l) => l.status === "closed").length
|
||||||
|
const ignoredLeads = filtered.filter((l) => l.status === "ignored").length
|
||||||
|
const conversionRate = totalLeads > 0
|
||||||
|
? Math.round((closedLeads / totalLeads) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
function getStatusDistribution() {
|
function getStatusDistribution() {
|
||||||
const statusCounts: Record<string, number> = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
const statusCounts: Record<string, number> = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||||
leads.forEach((l) => {
|
filtered.forEach((l) => {
|
||||||
statusCounts[l.status]++
|
statusCounts[l.status]++
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,17 +38,17 @@ function getStatusDistribution() {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dashboardStats: DashboardStats = {
|
return {
|
||||||
totalLeads: leads.length,
|
totalLeads,
|
||||||
openLeads: leads.filter((l) => l.status === "open").length,
|
openLeads,
|
||||||
contactedLeads: leads.filter((l) => l.status === "contacted").length,
|
contactedLeads,
|
||||||
pendingLeads: leads.filter((l) => l.status === "pending").length,
|
pendingLeads,
|
||||||
closedLeads: leads.filter((l) => l.status === "closed").length,
|
closedLeads,
|
||||||
ignoredLeads: leads.filter((l) => l.status === "ignored").length,
|
ignoredLeads,
|
||||||
conversionRate: Math.round(
|
conversionRate,
|
||||||
(leads.filter((l) => l.status === "closed").length / leads.length) * 100
|
leadsPerMonth: groupLeadsByInterval(filtered, period),
|
||||||
),
|
recentLeads: filtered.slice(0, 10),
|
||||||
leadsPerMonth: getLeadsPerMonth(),
|
|
||||||
recentLeads: leads.slice(0, 10),
|
|
||||||
statusDistribution: getStatusDistribution(),
|
statusDistribution: getStatusDistribution(),
|
||||||
|
periodLabel: label,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ export const users: User[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const currentUser: User = users[0]
|
|
||||||
|
|
||||||
export function getUserById(id: string): User | undefined {
|
export function getUserById(id: string): User | undefined {
|
||||||
return users.find((u) => u.id === id)
|
return users.find((u) => u.id === id)
|
||||||
}
|
}
|
||||||
|
|||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
import { SignJWT, jwtVerify } from "jose"
|
||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(
|
||||||
|
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
|
||||||
|
)
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "session"
|
||||||
|
const MAX_FAILED_ATTEMPTS = 5
|
||||||
|
const LOCKOUT_DURATION_MINUTES = 15
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
role: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function comparePassword(
|
||||||
|
password: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signToken(payload: { userId: string; role: string }) {
|
||||||
|
return new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setExpirationTime("24h")
|
||||||
|
.setIssuedAt()
|
||||||
|
.sign(JWT_SECRET)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string) {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, JWT_SECRET)
|
||||||
|
return payload as { userId: string; role: string }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string) {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||||
|
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||||
|
u.password_change_required,
|
||||||
|
r.name AS role_name
|
||||||
|
FROM users u
|
||||||
|
JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE u.email = $1 AND u.deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[email.toLowerCase().trim()]
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByUsername(username: string) {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||||
|
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||||
|
u.password_change_required,
|
||||||
|
r.name AS role_name
|
||||||
|
FROM users u
|
||||||
|
JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE u.username = $1 AND u.deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[username.toLowerCase().trim()]
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string) {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||||
|
u.is_active,
|
||||||
|
r.name AS role_name
|
||||||
|
FROM users u
|
||||||
|
JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE u.id = $1 AND u.deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordLoginAttempt(
|
||||||
|
userId: string | null,
|
||||||
|
usernameAttempted: string,
|
||||||
|
ipAddress: string,
|
||||||
|
userAgent: string | null,
|
||||||
|
wasSuccessful: boolean,
|
||||||
|
failureReason?: string
|
||||||
|
) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[userId, usernameAttempted, ipAddress, userAgent, wasSuccessful, failureReason || null]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementFailedAttempts(userId: string) {
|
||||||
|
await query(
|
||||||
|
`UPDATE users
|
||||||
|
SET failed_login_attempts = failed_login_attempts + 1,
|
||||||
|
locked_until = CASE
|
||||||
|
WHEN failed_login_attempts + 1 >= $2
|
||||||
|
THEN NOW() + (INTERVAL '1 minute' * $3)
|
||||||
|
ELSE locked_until
|
||||||
|
END
|
||||||
|
WHERE id = $1`,
|
||||||
|
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetFailedAttempts(userId: string) {
|
||||||
|
await query(
|
||||||
|
`UPDATE users
|
||||||
|
SET failed_login_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
last_login_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isAccountLocked(user: {
|
||||||
|
is_locked: boolean
|
||||||
|
locked_until: Date | null
|
||||||
|
}): Promise<{ locked: boolean; reason?: string }> {
|
||||||
|
if (user.is_locked) {
|
||||||
|
return { locked: true, reason: "Account has been locked by an administrator." }
|
||||||
|
}
|
||||||
|
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||||
|
const minutes = Math.ceil(
|
||||||
|
(new Date(user.locked_until).getTime() - Date.now()) / 60000
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
locked: true,
|
||||||
|
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { locked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDbUserToSessionUser(dbUser: Record<string, unknown>): SessionUser {
|
||||||
|
const roleName = dbUser.role_name as string
|
||||||
|
const mappedRole =
|
||||||
|
roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales"
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dbUser.id as string,
|
||||||
|
username: dbUser.username as string,
|
||||||
|
email: dbUser.email as string,
|
||||||
|
firstName: dbUser.first_name as string,
|
||||||
|
lastName: dbUser.last_name as string,
|
||||||
|
role: mappedRole,
|
||||||
|
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||||
|
`${dbUser.first_name}+${dbUser.last_name}`
|
||||||
|
)}&background=1d4ed8&color=fff&size=128`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(userId: string, role: string) {
|
||||||
|
const token = await signToken({ userId, role })
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
cookieStore.set(SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
})
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
cookieStore.set(SESSION_COOKIE, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE)?.value
|
||||||
|
if (!token) return null
|
||||||
|
|
||||||
|
const payload = await verifyToken(token)
|
||||||
|
if (!payload) return null
|
||||||
|
|
||||||
|
const dbUser = await getUserById(payload.userId)
|
||||||
|
if (!dbUser) return null
|
||||||
|
|
||||||
|
return mapDbUserToSessionUser(dbUser)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Lead } from "@/types"
|
||||||
|
|
||||||
|
export function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case "7days":
|
||||||
|
start.setDate(start.getDate() - 7)
|
||||||
|
break
|
||||||
|
case "30days":
|
||||||
|
start.setDate(start.getDate() - 30)
|
||||||
|
break
|
||||||
|
case "6months":
|
||||||
|
start.setMonth(start.getMonth() - 6)
|
||||||
|
break
|
||||||
|
case "12months":
|
||||||
|
start.setMonth(start.getMonth() - 12)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
start.setMonth(start.getMonth() - 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterLeadsByPeriod(leads: Lead[], period: string): Lead[] {
|
||||||
|
const { start, end } = getPeriodDateRange(period)
|
||||||
|
return leads.filter((l) => {
|
||||||
|
const d = new Date(l.createdAt)
|
||||||
|
return d >= start && d <= end
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupLeadsByInterval(
|
||||||
|
leads: Lead[],
|
||||||
|
period: string
|
||||||
|
): { label: string; leads: number; closed: number }[] {
|
||||||
|
const { start, end } = getPeriodDateRange(period)
|
||||||
|
|
||||||
|
if (period === "7days" || period === "30days") {
|
||||||
|
return groupByDay(leads, start, end)
|
||||||
|
}
|
||||||
|
return groupByMonth(leads, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDay(
|
||||||
|
leads: Lead[],
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): { label: string; leads: number; closed: number }[] {
|
||||||
|
const result: { label: string; leads: number; closed: number }[] = []
|
||||||
|
const current = new Date(start)
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
const dateStr = current.toISOString().slice(0, 10)
|
||||||
|
const dayLeads = leads.filter((l) => l.createdAt.startsWith(dateStr))
|
||||||
|
const closedCount = dayLeads.filter((l) => l.status === "closed").length
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
label: current.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||||
|
leads: dayLeads.length,
|
||||||
|
closed: closedCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByMonth(
|
||||||
|
leads: Lead[],
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): { label: string; leads: number; closed: number }[] {
|
||||||
|
const result: { label: string; leads: number; closed: number }[] = []
|
||||||
|
const current = new Date(start.getFullYear(), start.getMonth(), 1)
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
const yearMonth = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}`
|
||||||
|
const monthLeads = leads.filter((l) => l.createdAt.startsWith(yearMonth))
|
||||||
|
const closedCount = monthLeads.filter((l) => l.status === "closed").length
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
label: current.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||||
|
leads: monthLeads.length,
|
||||||
|
closed: closedCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
current.setMonth(current.getMonth() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Pool } from "pg"
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
pool.on("error", (err) => {
|
||||||
|
console.error("Unexpected database pool error:", err)
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function query(text: string, params?: unknown[]) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
const result = await client.query(text, params)
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default pool
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { jwtVerify } from "jose"
|
||||||
|
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(
|
||||||
|
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
|
||||||
|
)
|
||||||
|
|
||||||
|
const publicRoutes = [
|
||||||
|
"/login",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/_next/static",
|
||||||
|
"/_next/image",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/logo",
|
||||||
|
"/fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
const isPublic = publicRoutes.some(
|
||||||
|
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pathname === "/api/auth/me") {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookie = request.cookies.get("session")?.value
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
const loginUrl = new URL("/login", request.url)
|
||||||
|
loginUrl.searchParams.set("redirect", pathname)
|
||||||
|
return NextResponse.redirect(loginUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jwtVerify(sessionCookie, JWT_SECRET)
|
||||||
|
return NextResponse.next()
|
||||||
|
} catch {
|
||||||
|
const loginUrl = new URL("/login", request.url)
|
||||||
|
loginUrl.searchParams.set("redirect", pathname)
|
||||||
|
return NextResponse.redirect(loginUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|logo|fonts).*)"],
|
||||||
|
}
|
||||||
@@ -1,23 +1,70 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { createContext, useContext, useState, ReactNode } from "react"
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
|
||||||
import { currentUser } from "@/data/users"
|
import { useRouter } from "next/navigation"
|
||||||
import type { User } from "@/types"
|
import type { User } from "@/types"
|
||||||
|
|
||||||
interface UserContextValue {
|
interface UserContextValue {
|
||||||
user: User
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
logout: () => Promise<void>
|
||||||
updateAvatar: (url: string) => void
|
updateAvatar: (url: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserContext = createContext<UserContextValue | null>(null)
|
const UserContext = createContext<UserContextValue | null>(null)
|
||||||
|
|
||||||
export function UserProvider({ children }: { children: ReactNode }) {
|
export function UserProvider({ children }: { children: ReactNode }) {
|
||||||
const [avatar, setAvatar] = useState(currentUser.avatar)
|
const router = useRouter()
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const user: User = { ...currentUser, avatar }
|
const fetchUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me")
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const u = data.user
|
||||||
|
setUser({
|
||||||
|
id: u.id,
|
||||||
|
name: `${u.firstName} ${u.lastName}`,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role,
|
||||||
|
active: true,
|
||||||
|
avatar: u.avatar,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser()
|
||||||
|
}, [fetchUser])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" })
|
||||||
|
} catch {
|
||||||
|
// Proceed with client-side logout even if API fails
|
||||||
|
}
|
||||||
|
setUser(null)
|
||||||
|
router.push("/login")
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const updateAvatar = useCallback((url: string) => {
|
||||||
|
setUser((prev) => (prev ? { ...prev, avatar: url } : prev))
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ user, updateAvatar: setAvatar }}>
|
<UserContext.Provider value={{ user, loading, error, logout, updateAvatar }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-1
@@ -46,9 +46,10 @@ export interface DashboardStats {
|
|||||||
closedLeads: number
|
closedLeads: number
|
||||||
ignoredLeads: number
|
ignoredLeads: number
|
||||||
conversionRate: number
|
conversionRate: number
|
||||||
leadsPerMonth: { month: string; leads: number; closed: number }[]
|
leadsPerMonth: { label: string; leads: number; closed: number }[]
|
||||||
recentLeads: Lead[]
|
recentLeads: Lead[]
|
||||||
statusDistribution: { name: string; value: number; color: string }[]
|
statusDistribution: { name: string; value: number; color: string }[]
|
||||||
|
periodLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColumnFilter {
|
export interface ColumnFilter {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
$pgDir = "$env:TEMP\pg\pgsql"
|
||||||
|
$pgData = "$env:TEMP\pg\data"
|
||||||
|
$pgBin = "$pgDir\bin"
|
||||||
|
$logFile = "$env:TEMP\pg\logfile"
|
||||||
|
|
||||||
|
if (-not (Test-Path "$pgData\postgresql.conf")) {
|
||||||
|
Write-Error "PostgreSQL not initialized. Run initdb first."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$running = $null -ne (Get-Process -Name "postgres" -ErrorAction SilentlyContinue)
|
||||||
|
$listening = $null -ne (netstat -ano -p TCP 2>$null | Select-String ":5432.*LISTENING")
|
||||||
|
|
||||||
|
if (-not $running -or -not $listening) {
|
||||||
|
$existing = Get-Process -Name "postgres" -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) { $existing | Stop-Process -Force }
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Remove-Item "$pgData\postmaster.pid" -Force -ErrorAction SilentlyContinue
|
||||||
|
$env:PATH = "$pgBin;$env:PATH"
|
||||||
|
Start-Process -FilePath "$pgBin\postgres.exe" -ArgumentList "-D `"$pgData`"" -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
Write-Output "PostgreSQL started."
|
||||||
|
} else {
|
||||||
|
Write-Output "PostgreSQL already running on port 5432."
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user