Compare commits
5 Commits
4898bf7142
...
3f839bc0fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f839bc0fc | |||
| c29fd287c4 | |||
| ee44e9fc47 | |||
| 0fead80e1f | |||
| d6d784cef3 |
@@ -0,0 +1,164 @@
|
|||||||
|
# CRM Database Schema — Enterprise RBAC + CRM
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Enterprise-grade PostgreSQL schema implementing **hierarchical Role-Based Access Control (RBAC)**
|
||||||
|
with a complete CRM system. Designed for production deployments with strict security requirements,
|
||||||
|
audit compliance, and scalability to millions of records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Hierarchical RBAC Model
|
||||||
|
|
||||||
|
Four roles arranged in a strict hierarchy by `hierarchy_level` (lower = higher privilege):
|
||||||
|
|
||||||
|
| Role | Level | Authority |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| **SUPER_ADMIN** | 1 | Unrestricted — create/delete users, assign roles, override all permissions |
|
||||||
|
| **ADMIN** | 2 | Operational — ban/suspend users, review reports, moderate activity. **Cannot create accounts.** |
|
||||||
|
| **SALES_USER** | 3 | CRM operations — leads, customers, opportunities, communications |
|
||||||
|
| **DEVELOPER** | 4 | Technical + CRM read access for post-sale project visibility |
|
||||||
|
|
||||||
|
### Enforcement via PostgreSQL Triggers
|
||||||
|
|
||||||
|
- **`enforce_create_user()`** (BEFORE INSERT ON users): Only SUPER_ADMIN (`hierarchy_level = 1`)
|
||||||
|
can create new user accounts. Bootstrap case (`created_by IS NULL`) is allowed for initial setup.
|
||||||
|
- **`enforce_role_assignment()`** (BEFORE INSERT ON user_roles): Only SUPER_ADMIN can assign roles.
|
||||||
|
Prevents privilege escalation by checking hierarchy levels.
|
||||||
|
|
||||||
|
### Permission Model
|
||||||
|
|
||||||
|
Granular permissions stored in `permissions` table with `(resource, action)` pairs.
|
||||||
|
`role_permissions` maps roles to permissions. The `has_permission(user_id, resource, action)`
|
||||||
|
function provides application-layer authorization checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ban & Suspension System
|
||||||
|
|
||||||
|
Two separate tables for enforcement flexibility:
|
||||||
|
|
||||||
|
- **`banned_users`**: Permanent or time-limited bans. Supports reversal (by SUPER_ADMIN).
|
||||||
|
Admins can create bans; SUPER_ADMIN can reverse or permanently delete banned users.
|
||||||
|
- **`suspended_users`**: Time-limited suspensions (always has expiry). Admins can create
|
||||||
|
and lift suspensions.
|
||||||
|
|
||||||
|
Both tables are fully audited via dedicated trigger functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Session & Login Security
|
||||||
|
|
||||||
|
- **`sessions`**: Stores hashed session tokens with expiry. Only one active session per
|
||||||
|
token hash.
|
||||||
|
- **`login_attempts`**: Captures every login attempt (successful or failed) with IP address
|
||||||
|
and user agent. Used for brute-force detection and audit.
|
||||||
|
- Users have `failed_login_attempts` and `locked_until` for account lockout policies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Password Security
|
||||||
|
|
||||||
|
- All passwords hashed with **bcrypt (cost factor 12)**
|
||||||
|
- `password_change_required` forces password change on first login (TRUE for all non-root
|
||||||
|
test accounts)
|
||||||
|
- Password hashes are opaque to the database — validation is application-layer only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UUID Primary Keys
|
||||||
|
|
||||||
|
- All tables use `UUID PRIMARY KEY DEFAULT uuid_generate_v4()`
|
||||||
|
- Fixed UUIDs used for seed data and system entities for referential transparency
|
||||||
|
- Prevents enumeration attacks and supports distributed ID generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Audit Trail
|
||||||
|
|
||||||
|
Every mutation is logged to `audit_logs` via `audit_trigger_function()`:
|
||||||
|
|
||||||
|
- Captures `(table_name, record_id, action, old_data, new_data, changed_by, ip_address)`
|
||||||
|
- Ban actions, suspension actions, and successful logins have dedicated audit triggers
|
||||||
|
- Indexed on `(table_name, record_id)` for per-record lookup
|
||||||
|
|
||||||
|
Dedicated audit views:
|
||||||
|
- `v_active_bans` — Currently active bans with user details
|
||||||
|
- `v_active_suspensions` — Currently active suspensions
|
||||||
|
- `v_user_permissions` — Flattened permission report per user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Third Normal Form (3NF)
|
||||||
|
|
||||||
|
Customer model uses the **discriminator pattern**:
|
||||||
|
|
||||||
|
```
|
||||||
|
customers (parent — shared columns: status, owner, tags, score)
|
||||||
|
├── customer_type = 'individual' → individual_customers
|
||||||
|
└── customer_type = 'company' → company_customers
|
||||||
|
```
|
||||||
|
|
||||||
|
All lookup tables are normalized. Contact information, addresses, and notes are
|
||||||
|
separate 1:N child tables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Indexing Strategy
|
||||||
|
|
||||||
|
- **Core indexes**: FK columns, status/sort/date columns, unique constraints
|
||||||
|
- **Partial indexes**: `WHERE deleted_at IS NULL` on soft-delete tables,
|
||||||
|
`WHERE is_active = TRUE` on bans/suspensions, `WHERE due_date < NOW()` on overdue invoices
|
||||||
|
- **Full-text search**: GIN indexes on customers, leads, products, communications, tasks
|
||||||
|
- **Composite indexes**: Common query patterns (e.g., `opportunities(owner_id, stage_id)`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Soft Delete
|
||||||
|
|
||||||
|
- `deleted_at TIMESTAMPTZ` on all business tables
|
||||||
|
- Partial unique indexes ignore soft-deleted rows (e.g., `WHERE deleted_at IS NULL`)
|
||||||
|
- Audit logs capture the full row snapshot before soft-delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Test Accounts
|
||||||
|
|
||||||
|
| Username | Password | Role |
|
||||||
|
|----------|----------|------|
|
||||||
|
| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN |
|
||||||
|
| `admin_demo` | `AdminAccess@2026` | ADMIN |
|
||||||
|
| `sales_demo` | `SalesAccess@2026` | SALES_USER |
|
||||||
|
| `dev_demo` | `DevTesting@2026` | DEVELOPER |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Migration Instructions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create database
|
||||||
|
psql -U postgres -c "CREATE DATABASE crm;"
|
||||||
|
|
||||||
|
# Run full migration
|
||||||
|
psql -U postgres -d crm -f database/migrations/run_all.sql
|
||||||
|
|
||||||
|
# Or step by step:
|
||||||
|
psql -U postgres -d crm -f database/migrations/001_schema.sql
|
||||||
|
psql -U postgres -d crm -f database/migrations/002_seed.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Security Rules Summary
|
||||||
|
|
||||||
|
| Rule | Enforcement |
|
||||||
|
|------|-------------|
|
||||||
|
| Only SUPER_ADMIN creates accounts | Trigger `trg_enforce_create_user` |
|
||||||
|
| Only SUPER_ADMIN assigns roles | Trigger `trg_enforce_role_assignment` |
|
||||||
|
| No privilege escalation | Level check in `enforce_role_assignment()` |
|
||||||
|
| bcrypt password hashing | Application-layer (cost=12) |
|
||||||
|
| Unique usernames/emails | Partial unique indexes |
|
||||||
|
| Force password change on first login | `password_change_required` column |
|
||||||
|
| Audit every login/logout | Trigger `trg_audit_login` on `login_attempts` |
|
||||||
|
| Audit every ban action | Trigger `trg_audit_ban` on `banned_users` |
|
||||||
|
| Audit every suspension action | Trigger `trg_audit_suspension` on `suspended_users` |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- CRM Database Seed Data — RBAC + Test Accounts + Reference Data
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FIXED UUIDs FOR REFERENTIAL INTEGRITY
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Role UUIDs
|
||||||
|
-- SUPER_ADMIN=1, ADMIN=2, SALES_USER=3, DEVELOPER=4
|
||||||
|
-- User UUIDs: SUPER_ADMIN=u0001, ADMIN=u0002, SALES=u0003, DEV=u0004
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 1. ROLES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO roles (id, name, display_name, description, hierarchy_level, is_system) VALUES
|
||||||
|
('00000001-0000-0000-0000-000000000000', 'SUPER_ADMIN', 'Super Administrator',
|
||||||
|
'Highest authority. Full system access. Can create/delete users, assign roles, override all permissions.',
|
||||||
|
1, TRUE),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'ADMIN', 'Administrator',
|
||||||
|
'Operational administration. Can ban/suspend users, review reports, moderate activity. Cannot create accounts.',
|
||||||
|
2, TRUE),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'SALES_USER', 'Sales Representative',
|
||||||
|
'Day-to-day CRM operations. Manage leads, customers, opportunities, and communications.',
|
||||||
|
3, TRUE),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'DEVELOPER', 'Developer',
|
||||||
|
'Internal development/testing with CRM read access. API testing, diagnostics, debugging. Can view projects and customer data post-sale.',
|
||||||
|
4, TRUE)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 2. PERMISSIONS — organised by category
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- USER MANAGEMENT (SUPER_ADMIN only)
|
||||||
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
|
('p0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
|
||||||
|
('p0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
|
||||||
|
('p0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
|
||||||
|
('p0010004-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'),
|
||||||
|
('p0010006-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'),
|
||||||
|
('p0010008-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'),
|
||||||
|
('p0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
|
||||||
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
|
('p0020001-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'),
|
||||||
|
('p0020003-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'),
|
||||||
|
('p0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
|
||||||
|
('p0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- CRM CORE (SALES_USER + MANAGERS)
|
||||||
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
|
('p0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
|
||||||
|
('p0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
|
||||||
|
('p0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
|
||||||
|
('p0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
|
||||||
|
('p0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
|
||||||
|
('p0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
|
||||||
|
('p0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
|
||||||
|
('p0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
|
||||||
|
('p0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
|
||||||
|
('p0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
|
||||||
|
('p0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
|
||||||
|
('p0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
|
||||||
|
('p0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
|
||||||
|
('p0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
|
||||||
|
('p0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
|
||||||
|
('p0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
|
||||||
|
('p0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- REPORTING & INCIDENTS (ADMIN)
|
||||||
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
|
('p0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
|
||||||
|
('p0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
|
||||||
|
('p0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
|
||||||
|
('p0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
|
||||||
|
('p0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
|
||||||
|
('p0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
|
||||||
|
('p0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
|
||||||
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
|
('p0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
|
||||||
|
('p0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
|
||||||
|
('p0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
|
||||||
|
('p0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
|
||||||
|
('p0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
|
||||||
|
('p0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- DEVELOPER PERMISSIONS
|
||||||
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
|
('p0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
|
||||||
|
('p0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
|
||||||
|
('p0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
|
||||||
|
('p0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
|
||||||
|
('p0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
|
||||||
|
('p0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 3. ROLE-PERMISSION MAPPING
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- SUPER_ADMIN — ALL permissions
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT '00000001-0000-0000-0000-000000000000', id FROM permissions
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
|
-- Bans & Suspensions
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0020001-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0020004-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0020005-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0020006-0000-0000-0000-000000000000', NULL),
|
||||||
|
-- User read + disable
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-0000-000000000000', 'p0010009-0000-0000-0000-000000000000', NULL),
|
||||||
|
-- Reporting & Incidents
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0040001-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0040002-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0040003-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0040004-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0040005-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0040006-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0040007-0000-0000-0000-000000000000', NULL),
|
||||||
|
-- CRM read access
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL),
|
||||||
|
-- Audit read
|
||||||
|
('00000002-0000-0000-0000-000000000000', 'p0050002-0000-0000-0000-000000000000', NULL)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- SALES_USER — CRM operations
|
||||||
|
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', 'p0030002-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030003-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030005-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030007-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030008-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030009-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030011-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030012-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030013-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030015-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000003-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
|
-- Developer technical permissions
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0060001-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0060002-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0060003-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0060004-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0060005-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0060006-0000-0000-0000-000000000000', NULL),
|
||||||
|
-- User self-view
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL),
|
||||||
|
-- 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', 'p0030006-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
||||||
|
('00000004-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 4. TEST ACCOUNTS
|
||||||
|
-- ============================================================================
|
||||||
|
-- Passwords (bcrypt, cost=12):
|
||||||
|
-- superadmin_demo / SuperAdmin@2026
|
||||||
|
-- admin_demo / AdminAccess@2026
|
||||||
|
-- sales_demo / SalesAccess@2026
|
||||||
|
-- dev_demo / DevTesting@2026
|
||||||
|
|
||||||
|
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',
|
||||||
|
'$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
|
||||||
|
'Super', 'Admin', TRUE, FALSE),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@crm.demo',
|
||||||
|
'$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
|
||||||
|
'System', 'Admin', TRUE, TRUE),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@crm.demo',
|
||||||
|
'$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
|
||||||
|
'Sales', 'User', TRUE, TRUE),
|
||||||
|
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@crm.demo',
|
||||||
|
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
||||||
|
'Dev', 'User', TRUE, TRUE)
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
||||||
|
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
|
||||||
|
|
||||||
|
-- Set created_by for other users (SUPER_ADMIN created them)
|
||||||
|
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||||
|
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
|
||||||
|
AND created_by IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5. ROLE ASSIGNMENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Assign SUPER_ADMIN role to superadmin_demo (bootstrap — assigned_by NULL)
|
||||||
|
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000001', '00000001-0000-0000-0000-000000000000', NULL)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Assign remaining roles (SUPER_ADMIN assigned them)
|
||||||
|
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 6. REFERENCE DATA
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Customer Statuses
|
||||||
|
INSERT INTO customer_statuses (id, name, description, color, sort_order, is_default) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000001', 'Active', 'Customer in good standing', '#22C55E', 1, TRUE),
|
||||||
|
('c0000000-0000-0000-0000-000000000002', 'Inactive', 'No recent activity', '#A0AEC0', 2, FALSE),
|
||||||
|
('c0000000-0000-0000-0000-000000000003', 'At Risk', 'Showing signs of churn', '#F59E0B', 3, FALSE),
|
||||||
|
('c0000000-0000-0000-0000-000000000004', 'Churned', 'Relationship ended', '#EF4444', 4, FALSE),
|
||||||
|
('c0000000-0000-0000-0000-000000000005', 'VIP', 'High-value priority customer', '#8B5CF6', 0, FALSE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Lead Sources
|
||||||
|
INSERT INTO lead_sources (id, name, description) VALUES
|
||||||
|
('d0000000-0000-0000-0000-000000000001', 'Website', 'Organic website traffic or web forms'),
|
||||||
|
('d0000000-0000-0000-0000-000000000002', 'Referral', 'Referred by existing customer'),
|
||||||
|
('d0000000-0000-0000-0000-000000000003', 'LinkedIn', 'LinkedIn outreach'),
|
||||||
|
('d0000000-0000-0000-0000-000000000004', 'Email Campaign', 'Email marketing campaign'),
|
||||||
|
('d0000000-0000-0000-0000-000000000005', 'Trade Show', 'Event or trade show'),
|
||||||
|
('d0000000-0000-0000-0000-000000000006', 'Cold Call', 'Outbound cold calling'),
|
||||||
|
('d0000000-0000-0000-0000-000000000007', 'Partner', 'Channel partner referral'),
|
||||||
|
('d0000000-0000-0000-0000-000000000008', 'Social Media', 'Social media platforms'),
|
||||||
|
('d0000000-0000-0000-0000-000000000009', 'Advertisement', 'Paid advertising'),
|
||||||
|
('d0000000-0000-0000-0000-000000000010', 'Other', 'Other sources')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Lead Stages
|
||||||
|
INSERT INTO lead_stages (id, name, description, sort_order, probability) VALUES
|
||||||
|
('e0000000-0000-0000-0000-000000000001', 'New', 'Newly captured lead', 1, 5),
|
||||||
|
('e0000000-0000-0000-0000-000000000002', 'Contacted', 'Initial contact made', 2, 10),
|
||||||
|
('e0000000-0000-0000-0000-000000000003', 'Qualified', 'Meets qualification criteria', 3, 25),
|
||||||
|
('e0000000-0000-0000-0000-000000000004', 'Interested', 'Shown interest', 4, 40),
|
||||||
|
('e0000000-0000-0000-0000-000000000005', 'Demo Scheduled', 'Product demo scheduled', 5, 60),
|
||||||
|
('e0000000-0000-0000-0000-000000000006', 'Negotiation', 'In price/terms negotiation', 6, 75),
|
||||||
|
('e0000000-0000-0000-0000-000000000007', 'Closed Won', 'Successfully converted', 7, 100),
|
||||||
|
('e0000000-0000-0000-0000-000000000008', 'Closed Lost', 'Lead was lost', 8, 0)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Deal Stages
|
||||||
|
INSERT INTO deal_stages (id, name, description, sort_order, probability) VALUES
|
||||||
|
('f0000000-0000-0000-0000-000000000001', 'Prospecting', 'Initial qualification', 1, 10),
|
||||||
|
('f0000000-0000-0000-0000-000000000002', 'Discovery', 'Understanding needs', 2, 20),
|
||||||
|
('f0000000-0000-0000-0000-000000000003', 'Proposal', 'Solution proposed', 3, 40),
|
||||||
|
('f0000000-0000-0000-0000-000000000004', 'Negotiation', 'Commercial discussions', 4, 60),
|
||||||
|
('f0000000-0000-0000-0000-000000000005', 'Closing', 'Final stage before decision', 5, 80),
|
||||||
|
('f0000000-0000-0000-0000-000000000006', 'Won', 'Deal closed won', 6, 100),
|
||||||
|
('f0000000-0000-0000-0000-000000000007', 'Lost', 'Deal closed lost', 7, 0)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Communication Types
|
||||||
|
INSERT INTO communication_types (id, name, description, icon) VALUES
|
||||||
|
('g0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
|
||||||
|
('g0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
|
||||||
|
('g0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
|
||||||
|
('g0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
|
||||||
|
('g0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
|
||||||
|
('g0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
|
||||||
|
('g0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Task Priorities
|
||||||
|
INSERT INTO task_priorities (id, name, color, sort_order) VALUES
|
||||||
|
('h0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
|
||||||
|
('h0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
|
||||||
|
('h0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
|
||||||
|
('h0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Invoice Statuses
|
||||||
|
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
|
||||||
|
('i0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
|
||||||
|
('i0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
|
||||||
|
('i0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
|
||||||
|
('i0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
|
||||||
|
('i0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
|
||||||
|
('i0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
|
||||||
|
('i0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Product Categories
|
||||||
|
INSERT INTO product_categories (id, name, description, sort_order) VALUES
|
||||||
|
('j0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
|
||||||
|
('j0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
|
||||||
|
('j0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
|
||||||
|
('j0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
|
||||||
|
('j0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 7. SAMPLE CRM DATA (for demo purposes)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Sample Products
|
||||||
|
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),
|
||||||
|
('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),
|
||||||
|
('k0000000-0000-0000-0000-000000000003', 'j0000000-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),
|
||||||
|
('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),
|
||||||
|
('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)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Sample Customers
|
||||||
|
INSERT INTO customers (id, customer_type, status_id, owner_id, source, tags, score) VALUES
|
||||||
|
('a0000000-0000-0000-0000-000000000001', 'company', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'00000000-0000-0000-0000-000000000003', 'Website', ARRAY['tech','enterprise'], 85),
|
||||||
|
('a0000000-0000-0000-0000-000000000002', 'individual', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'00000000-0000-0000-0000-000000000003', 'Referral', ARRAY['retail'], 60),
|
||||||
|
('a0000000-0000-0000-0000-000000000003', 'company', 'c0000000-0000-0000-0000-000000000005',
|
||||||
|
'00000000-0000-0000-0000-000000000003', 'LinkedIn', ARRAY['finance','enterprise','vip'], 95),
|
||||||
|
('a0000000-0000-0000-0000-000000000004', 'individual', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'00000000-0000-0000-0000-000000000003', 'Email Campaign', ARRAY['education'], 15)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Company details
|
||||||
|
INSERT INTO company_customers (customer_id, company_name, registration_number, tax_id, website, industry, company_size, annual_revenue) VALUES
|
||||||
|
('a0000000-0000-0000-0000-000000000001', 'TechCorp International', 'REG-2024-001', 'TAX-987654', 'https://techcorp.example.com', 'Technology', '500-1000', 50000000.00),
|
||||||
|
('a0000000-0000-0000-0000-000000000003', 'FinServ Group', 'REG-2024-002', 'TAX-123456', 'https://finserv.example.com', 'Financial Services', '1000-5000', 250000000.00)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Individual details
|
||||||
|
INSERT INTO individual_customers (customer_id, first_name, last_name, job_title, company_name) VALUES
|
||||||
|
('a0000000-0000-0000-0000-000000000002', 'Michael', 'Brown', 'Store Owner', 'Browns Retail Shop'),
|
||||||
|
('a0000000-0000-0000-0000-000000000004', 'Sarah', 'Davis', 'Professor', 'State University')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Contact Information
|
||||||
|
INSERT INTO contact_information (customer_id, type, value, is_primary) VALUES
|
||||||
|
('a0000000-0000-0000-0000-000000000001', 'email', 'contact@techcorp.example.com', TRUE),
|
||||||
|
('a0000000-0000-0000-0000-000000000001', 'phone', '+1-555-0200', TRUE),
|
||||||
|
('a0000000-0000-0000-0000-000000000002', 'email', 'michael.brown@email.example.com', TRUE),
|
||||||
|
('a0000000-0000-0000-0000-000000000002', 'mobile', '+1-555-0201', TRUE),
|
||||||
|
('a0000000-0000-0000-0000-000000000003', 'email', 'info@finserv.example.com', TRUE),
|
||||||
|
('a0000000-0000-0000-0000-000000000003', 'phone', '+1-555-0202', TRUE),
|
||||||
|
('a0000000-0000-0000-0000-000000000004', 'email', 'sarah.davis@email.example.com', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Sample Leads
|
||||||
|
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',
|
||||||
|
'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',
|
||||||
|
'00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Sample Opportunities
|
||||||
|
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',
|
||||||
|
'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',
|
||||||
|
'00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Sample Tasks
|
||||||
|
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',
|
||||||
|
'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'),
|
||||||
|
('n0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'm0000000-0000-0000-0000-000000000002',
|
||||||
|
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
||||||
|
'Schedule FinServ executive meeting', 'h0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- CRM Database — Full Migration Runner
|
||||||
|
-- ============================================================================
|
||||||
|
-- Usage: psql -U postgres -d crm -f run_all.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
|
||||||
|
\i 001_schema.sql
|
||||||
|
|
||||||
|
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
|
||||||
|
\i 002_seed.sql
|
||||||
|
|
||||||
|
\echo '=== Migration Complete ==='
|
||||||
|
\echo ''
|
||||||
|
\echo 'Test accounts created:'
|
||||||
|
\echo ' superadmin_demo / SuperAdmin@2026'
|
||||||
|
\echo ' admin_demo / AdminAccess@2026'
|
||||||
|
\echo ' sales_demo / SalesAccess@2026'
|
||||||
|
\echo ' dev_demo / DevTesting@2026'
|
||||||
Generated
+412
-1
@@ -8,6 +8,8 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
@@ -25,8 +27,10 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
@@ -86,7 +90,6 @@
|
|||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
@@ -102,6 +105,22 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emoji-mart/data": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@emoji-mart/react": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"emoji-mart": "^5.2",
|
||||||
|
"react": "^16.8 || ^17 || ^18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
@@ -339,6 +358,384 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.33.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||||
@@ -2589,6 +2986,14 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -3208,6 +3613,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz",
|
||||||
"integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="
|
"integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-mart": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
|||||||
+5
-1
@@ -9,8 +9,9 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
@@ -26,8 +27,11 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,401 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { conversations as conversationsData } from "@/data/chats"
|
||||||
|
import {
|
||||||
|
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||||
|
Smile, Flag, Ban, Trash2, Image, File, X,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
DialogFooter, DialogClose,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { useUser } from "@/providers/user-provider"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import data from "@emoji-mart/data"
|
||||||
|
import Picker from "@emoji-mart/react"
|
||||||
|
|
||||||
|
export default function ChatsPage() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const { user } = useUser()
|
||||||
|
const [activeChat, setActiveChat] = useState(conversationsData[0]?.id ?? null)
|
||||||
|
const [messageInput, setMessageInput] = useState("")
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
|
const [attachments, setAttachments] = useState<File[]>([])
|
||||||
|
const [panelWidth, setPanelWidth] = useState(320)
|
||||||
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
||||||
|
const [reportReason, setReportReason] = useState("")
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const resizeStartRef = useRef({ x: 0, width: 0 })
|
||||||
|
|
||||||
|
const [conversations, setConversations] = useState(conversationsData)
|
||||||
|
const conversation = conversations.find((c) => c.id === activeChat)
|
||||||
|
const otherParticipant = (conv: typeof conversationsData[0]) =>
|
||||||
|
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
||||||
|
setShowEmojiPicker(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsResizing(true)
|
||||||
|
resizeStartRef.current = { x: e.clientX, width: panelWidth }
|
||||||
|
}, [panelWidth])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResizing) return
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const delta = e.clientX - resizeStartRef.current.x
|
||||||
|
const newWidth = Math.max(240, Math.min(560, resizeStartRef.current.width + delta))
|
||||||
|
setPanelWidth(newWidth)
|
||||||
|
}
|
||||||
|
const handleMouseUp = () => setIsResizing(false)
|
||||||
|
document.addEventListener("mousemove", handleMouseMove)
|
||||||
|
document.addEventListener("mouseup", handleMouseUp)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove)
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp)
|
||||||
|
}
|
||||||
|
}, [isResizing])
|
||||||
|
|
||||||
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||||
|
setMessageInput((prev) => prev + emoji.native)
|
||||||
|
setShowEmojiPicker(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files ?? [])
|
||||||
|
setAttachments((prev) => [...prev, ...files])
|
||||||
|
if (e.target) e.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAttachment = (index: number) => {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!messageInput.trim() && attachments.length === 0) return
|
||||||
|
const newMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
conversationId: activeChat!,
|
||||||
|
senderId: "user1",
|
||||||
|
senderName: "Sarah Chen",
|
||||||
|
senderAvatar: "SC",
|
||||||
|
content: messageInput.trim(),
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
}
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((conv) =>
|
||||||
|
conv.id === activeChat
|
||||||
|
? {
|
||||||
|
...conv,
|
||||||
|
messages: [...conv.messages, newMessage],
|
||||||
|
lastMessage: newMessage.content,
|
||||||
|
lastMessageTime: "Just now",
|
||||||
|
unread: 0,
|
||||||
|
}
|
||||||
|
: conv
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setMessageInput("")
|
||||||
|
setAttachments([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + " B"
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImageFile = (file: File) => file.type.startsWith("image/")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
||||||
|
{/* Conversations list - left panel */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col border-r shrink-0 overflow-hidden"
|
||||||
|
style={{ width: panelWidth }}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold">Chats</h2>
|
||||||
|
<div className="relative">
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{conversations.map((conv) => {
|
||||||
|
const person = otherParticipant(conv)
|
||||||
|
const isActive = conv.id === activeChat
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => setActiveChat(conv.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50",
|
||||||
|
isActive && "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="h-10 w-10 shrink-0">
|
||||||
|
<AvatarFallback>{person.avatar}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{person.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
|
||||||
|
</div>
|
||||||
|
{conv.unread > 0 && (
|
||||||
|
<span className="shrink-0 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-medium text-primary-foreground">
|
||||||
|
{conv.unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 -left-1 -right-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat area - right panel */}
|
||||||
|
{conversation ? (
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Chat header */}
|
||||||
|
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar className="h-9 w-9 shrink-0">
|
||||||
|
<AvatarFallback>{otherParticipant(conversation).avatar}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{otherParticipant(conversation).name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{otherParticipant(conversation).role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon" className="h-8 w-8"
|
||||||
|
onClick={() => toast.info("Voice calling coming soon")}
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon" className="h-8 w-8"
|
||||||
|
onClick={() => toast.info("Video calling coming soon")}
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setReportDialogOpen(true)}>
|
||||||
|
<Flag className="mr-2 h-4 w-4" /> Report
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => toast.info("Blocked")}>
|
||||||
|
<Ban className="mr-2 h-4 w-4" /> Block
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => toast.info("Conversation deleted")}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<ScrollArea className="flex-1 p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{conversation.messages.map((msg) => {
|
||||||
|
const isMe = msg.senderId === "user1"
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={cn("flex gap-3", isMe && "flex-row-reverse")}>
|
||||||
|
<Avatar className="h-8 w-8 mt-0.5 shrink-0">
|
||||||
|
{isMe ? <AvatarImage src={user.avatar} /> : null}
|
||||||
|
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
|
||||||
|
{msg.senderAvatar}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl px-4 py-2.5 text-sm",
|
||||||
|
isMe ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-muted rounded-tl-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-1 px-1">{msg.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t shrink-0 space-y-2">
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attachments.map((file, i) => (
|
||||||
|
<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]"
|
||||||
|
>
|
||||||
|
{isImageFile(file) ? (
|
||||||
|
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{file.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(file.size)}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon" className="h-5 w-5 -mr-1 shrink-0"
|
||||||
|
onClick={() => removeAttachment(i)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSend} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
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" />
|
||||||
|
</Button>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
value={messageInput}
|
||||||
|
onChange={(e) => setMessageInput(e.target.value)}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
className="h-9 w-full pr-9"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
||||||
|
<Button
|
||||||
|
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" />
|
||||||
|
</Button>
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={handleEmojiSelect}
|
||||||
|
theme={theme === "dark" ? "dark" : "light"}
|
||||||
|
previewPosition="none"
|
||||||
|
skinTonePosition="none"
|
||||||
|
set="native"
|
||||||
|
maxFrequentRows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<p>Select a conversation to start chatting</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resize overlay */}
|
||||||
|
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
|
||||||
|
|
||||||
|
{/* Report dialog */}
|
||||||
|
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Report {conversation ? otherParticipant(conversation).name : "User"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Let us know why you're reporting this conversation.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<Label htmlFor="reason">Reason</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reason"
|
||||||
|
placeholder="Describe the issue..."
|
||||||
|
value={reportReason}
|
||||||
|
onChange={(e) => setReportReason(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
toast.success("Report submitted")
|
||||||
|
setReportReason("")
|
||||||
|
setReportDialogOpen(false)
|
||||||
|
}}
|
||||||
|
disabled={!reportReason.trim()}
|
||||||
|
>
|
||||||
|
Submit Report
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AppShell } from "@/components/layout/app-shell"
|
import { AppShell } from "@/components/layout/app-shell"
|
||||||
|
import { UserProvider } from "@/providers/user-provider"
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return <AppShell>{children}</AppShell>
|
return (
|
||||||
|
<UserProvider>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</UserProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef } from "react"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useUser } from "@/providers/user-provider"
|
||||||
|
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { user, updateAvatar } = useUser()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const validTypes = ["image/png", "image/jpeg"]
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
toast.error("Only PNG and JPEG files are allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
updateAvatar(url)
|
||||||
|
toast.success("Avatar updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Profile" description="Your account information" />
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardContent className="flex flex-col items-center pt-8">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="h-24 w-24">
|
||||||
|
<AvatarImage src={user.avatar} />
|
||||||
|
<AvatarFallback className="text-2xl">{user.name.split(" ").map((n) => n[0]).join("")}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Camera className="h-6 w-6 text-white" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpeg,.jpg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">{user.name}</h2>
|
||||||
|
<Badge variant="secondary" className="mt-1 capitalize">
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Joined {new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground capitalize">{user.role} access</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">{user.active ? "Active" : "Inactive"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Full Name</span>
|
||||||
|
<p className="text-sm font-medium">{user.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Email</span>
|
||||||
|
<p className="text-sm font-medium">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Role</span>
|
||||||
|
<p className="text-sm font-medium capitalize">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Status</span>
|
||||||
|
<p className="text-sm font-medium">{user.active ? "Active" : "Inactive"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Member Since</span>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">User ID</span>
|
||||||
|
<p className="text-sm font-medium font-mono">{user.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+4
-1
@@ -10,8 +10,11 @@ const inter = Inter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Coastal IT - CRM",
|
title: "Coast IT - CRM",
|
||||||
description: "Customer Relationship Management System",
|
description: "Customer Relationship Management System",
|
||||||
|
icons: {
|
||||||
|
icon: "/logo/CompanyMiniLogo.png",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
+11
-7
@@ -35,9 +35,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20 backdrop-blur-sm">
|
<img
|
||||||
<span className="text-lg font-bold">C</span>
|
src="/logo/CompanyLogo.png"
|
||||||
</div>
|
alt={COMPANY_NAME}
|
||||||
|
className="h-10 w-10 rounded-xl object-contain"
|
||||||
|
/>
|
||||||
<span className="text-xl font-semibold">{COMPANY_NAME}</span>
|
<span className="text-xl font-semibold">{COMPANY_NAME}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +74,7 @@ export default function LoginPage() {
|
|||||||
<div className="h-10 w-10 rounded-full bg-white/20" />
|
<div className="h-10 w-10 rounded-full bg-white/20" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Marcus Johnson</p>
|
<p className="text-sm font-medium">Marcus Johnson</p>
|
||||||
<p className="text-xs text-white/60">Sales Lead, Coastal IT</p>
|
<p className="text-xs text-white/60">Sales Lead, Coast IT</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -93,9 +95,11 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{/* Mobile logo */}
|
{/* Mobile logo */}
|
||||||
<div className="flex flex-col items-center gap-3 lg:hidden">
|
<div className="flex flex-col items-center gap-3 lg:hidden">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
<img
|
||||||
<span className="text-xl font-bold text-primary-foreground">C</span>
|
src="/logo/CompanyLogo.png"
|
||||||
</div>
|
alt={COMPANY_NAME}
|
||||||
|
className="h-12 w-12 rounded-xl object-contain"
|
||||||
|
/>
|
||||||
<span className="text-xl font-semibold">{COMPANY_NAME}</span>
|
<span className="text-xl font-semibold">{COMPANY_NAME}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } 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"
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"
|
|
||||||
|
|
||||||
interface StatusData {
|
interface StatusData {
|
||||||
name: string
|
name: string
|
||||||
@@ -14,7 +14,38 @@ interface LeadStatusChartProps {
|
|||||||
data: StatusData[]
|
data: StatusData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function polar(cx: number, cy: number, r: number, deg: number) {
|
||||||
|
const rad = ((deg - 90) * Math.PI) / 180
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) {
|
||||||
|
const gap = 3
|
||||||
|
const s = start + gap / 2
|
||||||
|
const e = end - gap / 2
|
||||||
|
const o1 = polar(cx, cy, oR, s)
|
||||||
|
const o2 = polar(cx, cy, oR, e)
|
||||||
|
const i1 = polar(cx, cy, iR, e)
|
||||||
|
const i2 = polar(cx, cy, iR, s)
|
||||||
|
const lg = e - s > 180 ? 1 : 0
|
||||||
|
return `M${o1.x} ${o1.y} A${oR} ${oR} 0 ${lg} 1 ${o2.x} ${o2.y} L${i1.x} ${i1.y} A${iR} ${iR} 0 ${lg} 0 ${i2.x} ${i2.y}Z`
|
||||||
|
}
|
||||||
|
|
||||||
export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0)
|
||||||
|
|
||||||
|
let cum = 0
|
||||||
|
const segs = data.map((d) => {
|
||||||
|
const start = cum
|
||||||
|
const deg = (d.value / total) * 360
|
||||||
|
cum += deg
|
||||||
|
return { ...d, start, end: cum, deg, pct: Math.round((d.value / total) * 100) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = hov !== null ? segs[hov] : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -26,45 +57,120 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
|||||||
<CardTitle>Lead Status</CardTitle>
|
<CardTitle>Lead Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<div className="flex flex-col items-center">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{/* Donut */}
|
||||||
<PieChart>
|
<svg width="280" height="280" viewBox="0 0 320 320" className="overflow-visible">
|
||||||
<Pie
|
<defs>
|
||||||
data={data}
|
{segs.map((s, i) => (
|
||||||
cx="50%"
|
<radialGradient key={i} id={`chart-grad-${i}`} cx="50%" cy="50%" r="50%">
|
||||||
cy="50%"
|
<stop offset="0%" stopColor={s.color} stopOpacity="1" />
|
||||||
innerRadius={60}
|
<stop offset="100%" stopColor={s.color} stopOpacity="0.75" />
|
||||||
outerRadius={100}
|
</radialGradient>
|
||||||
paddingAngle={2}
|
))}
|
||||||
dataKey="value"
|
</defs>
|
||||||
animationBegin={200}
|
|
||||||
animationDuration={1000}
|
{/* Background ring */}
|
||||||
>
|
<circle cx="160" cy="160" r="105" fill="none" stroke="hsl(var(--muted))" strokeWidth="52" />
|
||||||
{data.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
{/* Segments */}
|
||||||
))}
|
{segs.map((s, i) => {
|
||||||
</Pie>
|
const isH = hov === i
|
||||||
<Tooltip
|
const oR = isH ? 137 : 130
|
||||||
contentStyle={{
|
const iR = isH ? 73 : 80
|
||||||
background: "hsl(var(--popover))",
|
return (
|
||||||
border: "1px solid hsl(var(--border))",
|
<path
|
||||||
borderRadius: "8px",
|
key={i}
|
||||||
fontSize: "14px",
|
d={arcPath(160, 160, oR, iR, s.start, s.end)}
|
||||||
|
fill={`url(#chart-grad-${i})`}
|
||||||
|
opacity={hov !== null && !isH ? 0.3 : 1}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.22s cubic-bezier(.4,0,.2,1)",
|
||||||
|
filter: isH ? `drop-shadow(0 0 10px ${s.color}99)` : "none",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHov(i)}
|
||||||
|
onMouseLeave={() => setHov(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Center circle */}
|
||||||
|
<circle cx="160" cy="160" r="66" fill="hsl(var(--card))" />
|
||||||
|
<circle cx="160" cy="160" r="66" fill="none" stroke="hsl(var(--border))" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* Center text */}
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<circle cx="160" cy="160" r="66" fill={active.color + "15"} />
|
||||||
|
<text x="160" y="148" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="30" fontWeight="700" fontFamily="-apple-system,sans-serif">
|
||||||
|
{active.value}
|
||||||
|
</text>
|
||||||
|
<text x="160" y="166" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
|
||||||
|
{active.name}
|
||||||
|
</text>
|
||||||
|
<text x="160" y="184" textAnchor="middle" fill={active.color} fontSize="13" fontWeight="600" fontFamily="-apple-system,sans-serif">
|
||||||
|
{active.pct}%
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<text x="160" y="152" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="34" fontWeight="700" fontFamily="-apple-system,sans-serif">
|
||||||
|
{total}
|
||||||
|
</text>
|
||||||
|
<text x="160" y="172" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
|
||||||
|
Total Leads
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-6 grid w-full grid-cols-2 gap-2">
|
||||||
|
{segs.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onMouseEnter={() => setHov(i)}
|
||||||
|
onMouseLeave={() => setHov(null)}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-xl p-2.5 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
background: hov === i ? `${s.color}18` : "hsl(var(--muted) / 0.3)",
|
||||||
|
border: `1px solid ${hov === i ? s.color + "50" : "hsl(var(--border))"}`,
|
||||||
|
gridColumn: i === segs.length - 1 && segs.length % 2 !== 0 ? "1 / -1" : undefined,
|
||||||
|
maxWidth: i === segs.length - 1 && segs.length % 2 !== 0 ? "50%" : undefined,
|
||||||
|
margin: i === segs.length - 1 && segs.length % 2 !== 0 ? "0 auto" : undefined,
|
||||||
|
width: i === segs.length - 1 && segs.length % 2 !== 0 ? "100%" : undefined,
|
||||||
}}
|
}}
|
||||||
formatter={(value: number, name: string) => [value, name]}
|
>
|
||||||
/>
|
<div
|
||||||
<Legend
|
className="h-2.5 w-2.5 shrink-0 rounded-full transition-shadow duration-200"
|
||||||
verticalAlign="bottom"
|
style={{
|
||||||
height={36}
|
background: s.color,
|
||||||
formatter={(value: string) => (
|
boxShadow: hov === i ? `0 0 8px ${s.color}` : "none",
|
||||||
<span className="text-sm text-muted-foreground">{value}</span>
|
}}
|
||||||
)}
|
/>
|
||||||
/>
|
<div className="min-w-0 flex-1">
|
||||||
</PieChart>
|
<div
|
||||||
</ResponsiveContainer>
|
className="text-xs font-medium transition-colors duration-200"
|
||||||
|
style={{ color: hov === i ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))" }}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px]" style={{ color: "hsl(var(--muted-foreground) / 0.7)" }}>
|
||||||
|
{s.value} · {s.pct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs font-semibold transition-colors duration-200"
|
||||||
|
style={{ color: hov === i ? s.color : "hsl(var(--muted-foreground) / 0.5)" }}
|
||||||
|
>
|
||||||
|
{s.pct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
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"
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
|
||||||
|
|
||||||
interface MonthlyData {
|
interface MonthlyData {
|
||||||
month: string
|
month: string
|
||||||
@@ -14,7 +14,42 @@ interface LeadsPerMonthChartProps {
|
|||||||
data: MonthlyData[]
|
data: MonthlyData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BAR_GRADIENT_TOP = "#3B6AB8"
|
||||||
|
const NEW_LEADS = "#1e3c72"
|
||||||
|
const CLOSED = "#457fca"
|
||||||
|
|
||||||
export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||||
|
const [hovered, setHovered] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const width = 880
|
||||||
|
const height = 380
|
||||||
|
const padding = { top: 28, right: 24, bottom: 44, left: 44 }
|
||||||
|
const chartW = width - padding.left - padding.right
|
||||||
|
const chartH = height - padding.top - padding.bottom
|
||||||
|
|
||||||
|
const maxVal = useMemo(() => {
|
||||||
|
const max = Math.max(...data.map((d) => Math.max(d.leads, d.closed)))
|
||||||
|
return Math.ceil(max / 7) * 7
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const ticks = useMemo(() => {
|
||||||
|
const steps = 4
|
||||||
|
return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i))
|
||||||
|
}, [maxVal])
|
||||||
|
|
||||||
|
const groupW = chartW / data.length
|
||||||
|
const barW = groupW * 0.28
|
||||||
|
const gap = groupW * 0.06
|
||||||
|
|
||||||
|
const yScale = (v: number) => chartH - (v / maxVal) * chartH
|
||||||
|
|
||||||
|
const active = hovered
|
||||||
|
const activeDatum = active !== null ? data[active] : null
|
||||||
|
|
||||||
|
const totalNew = data.reduce((s, d) => s + d.leads, 0)
|
||||||
|
const totalClosed = data.reduce((s, d) => s + d.closed, 0)
|
||||||
|
const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -23,61 +58,193 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
|||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Leads Per Month</CardTitle>
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Leads Per Month</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{totalNew} new · {totalClosed} closed · {closeRate}% close rate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: NEW_LEADS }} />
|
||||||
|
<span className="text-xs text-muted-foreground">New Leads</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: CLOSED }} />
|
||||||
|
<span className="text-xs text-muted-foreground">Closed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<div className="relative">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<svg
|
||||||
<BarChart data={data} barGap={4}>
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
width="100%"
|
||||||
<XAxis
|
height="auto"
|
||||||
dataKey="month"
|
className="block overflow-visible"
|
||||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
>
|
||||||
tickLine={false}
|
<defs>
|
||||||
axisLine={false}
|
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
/>
|
<stop offset="0%" stopColor={BAR_GRADIENT_TOP} />
|
||||||
<YAxis
|
<stop offset="100%" stopColor={NEW_LEADS} />
|
||||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
</linearGradient>
|
||||||
tickLine={false}
|
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
axisLine={false}
|
<stop offset="0%" stopColor="#5691c8" />
|
||||||
allowDecimals={false}
|
<stop offset="100%" stopColor={CLOSED} />
|
||||||
/>
|
</linearGradient>
|
||||||
<Tooltip
|
<filter id="shadowBlue">
|
||||||
contentStyle={{
|
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={NEW_LEADS} floodOpacity="0.4" />
|
||||||
|
</filter>
|
||||||
|
<filter id="shadowClosed">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={CLOSED} floodOpacity="0.4" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||||
|
{/* Grid lines */}
|
||||||
|
{ticks.map((t, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<line
|
||||||
|
x1={0}
|
||||||
|
x2={chartW}
|
||||||
|
y1={yScale(t)}
|
||||||
|
y2={yScale(t)}
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeDasharray={t === 0 ? "0" : "3 5"}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={-12}
|
||||||
|
y={yScale(t)}
|
||||||
|
fill="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Bars */}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const groupX = i * groupW
|
||||||
|
const isActive = active === i
|
||||||
|
const newH = chartH - yScale(d.leads)
|
||||||
|
const closedH = chartH - yScale(d.closed)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={d.month}
|
||||||
|
onMouseEnter={() => setHovered(i)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{/* Hover highlight band */}
|
||||||
|
<rect
|
||||||
|
x={groupX}
|
||||||
|
y={0}
|
||||||
|
width={groupW}
|
||||||
|
height={chartH}
|
||||||
|
fill={isActive ? "hsl(var(--muted) / 0.5)" : "transparent"}
|
||||||
|
rx={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New Leads bar */}
|
||||||
|
<rect
|
||||||
|
x={groupX + groupW / 2 - barW - gap / 2}
|
||||||
|
y={yScale(d.leads)}
|
||||||
|
width={barW}
|
||||||
|
height={newH}
|
||||||
|
rx={4}
|
||||||
|
fill="url(#newLeadsGrad)"
|
||||||
|
filter={isActive ? "url(#shadowBlue)" : undefined}
|
||||||
|
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
||||||
|
style={{ transition: "opacity 0.15s ease" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Closed bar */}
|
||||||
|
<rect
|
||||||
|
x={groupX + groupW / 2 + gap / 2}
|
||||||
|
y={yScale(d.closed)}
|
||||||
|
width={barW}
|
||||||
|
height={closedH}
|
||||||
|
rx={4}
|
||||||
|
fill="url(#closedGrad)"
|
||||||
|
filter={isActive ? "url(#shadowClosed)" : undefined}
|
||||||
|
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
||||||
|
style={{ transition: "opacity 0.15s ease" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Month label */}
|
||||||
|
<text
|
||||||
|
x={groupX + groupW / 2}
|
||||||
|
y={chartH + 26}
|
||||||
|
fill={isActive ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}
|
||||||
|
fontSize={12.5}
|
||||||
|
fontWeight={isActive ? 600 : 400}
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{d.month}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{activeDatum && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-0"
|
||||||
|
style={{
|
||||||
|
left: `${((active! + 0.5) / data.length) * 100}%`,
|
||||||
|
transform: active! > data.length - 2
|
||||||
|
? "translate(-100%, 0)"
|
||||||
|
: "translate(-12%, 0)",
|
||||||
|
transition: "left 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="min-w-[150px] rounded-xl border p-3 shadow-xl"
|
||||||
|
style={{
|
||||||
background: "hsl(var(--popover))",
|
background: "hsl(var(--popover))",
|
||||||
border: "1px solid hsl(var(--border))",
|
borderColor: "hsl(var(--border))",
|
||||||
borderRadius: "8px",
|
|
||||||
fontSize: "14px",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Legend
|
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
|
||||||
verticalAlign="bottom"
|
{activeDatum.month}
|
||||||
height={36}
|
</div>
|
||||||
formatter={(value: string) => (
|
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||||
<span className="text-sm text-muted-foreground">{value}</span>
|
<span className="flex items-center gap-1.5">
|
||||||
)}
|
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: NEW_LEADS }} />
|
||||||
/>
|
New Leads
|
||||||
<Bar
|
</span>
|
||||||
dataKey="leads"
|
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.leads}</span>
|
||||||
name="New Leads"
|
</div>
|
||||||
fill="hsl(var(--primary))"
|
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||||
radius={[4, 4, 0, 0]}
|
<span className="flex items-center gap-1.5">
|
||||||
animationBegin={300}
|
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: CLOSED }} />
|
||||||
animationDuration={800}
|
Closed
|
||||||
/>
|
</span>
|
||||||
<Bar
|
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.closed}</span>
|
||||||
dataKey="closed"
|
</div>
|
||||||
name="Closed"
|
<div
|
||||||
fill="#10b981"
|
className="mt-1.5 border-t pt-1.5 text-[11px]"
|
||||||
radius={[4, 4, 0, 0]}
|
style={{ borderColor: "hsl(var(--border))", color: "hsl(var(--muted-foreground))" }}
|
||||||
animationBegin={500}
|
>
|
||||||
animationDuration={800}
|
{activeDatum.leads > 0
|
||||||
/>
|
? `${Math.round((activeDatum.closed / activeDatum.leads) * 100)}% close rate`
|
||||||
</BarChart>
|
: "No leads"}
|
||||||
</ResponsiveContainer>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"
|
|||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -15,12 +16,18 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Building2,
|
Building2,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
|
MessageSquare,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { COMPANY_NAME } from "@/lib/constants"
|
import { COMPANY_NAME } from "@/lib/constants"
|
||||||
|
import { useUser } from "@/providers/user-provider"
|
||||||
|
import { conversations as conversationsData } from "@/data/chats"
|
||||||
|
|
||||||
|
const totalUnread = conversationsData.reduce((sum, c) => sum + c.unread, 0)
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/leads", label: "Leads", icon: Users },
|
{ href: "/leads", label: "Leads", icon: Users },
|
||||||
|
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
||||||
{ href: "/users", label: "Users", icon: Building2 },
|
{ href: "/users", label: "Users", icon: Building2 },
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
]
|
]
|
||||||
@@ -34,6 +41,8 @@ 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 initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<div
|
<div
|
||||||
@@ -45,9 +54,11 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
||||||
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary">
|
<img
|
||||||
<span className="text-sm font-bold text-primary-foreground">C</span>
|
src="/logo/CompanyLogo.png"
|
||||||
</div>
|
alt={COMPANY_NAME}
|
||||||
|
className="h-8 w-8 shrink-0 rounded-lg object-contain"
|
||||||
|
/>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<motion.span
|
<motion.span
|
||||||
@@ -85,13 +96,18 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
|
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-5 w-5" />
|
<item.icon className="h-5 w-5" />
|
||||||
|
{item.label === "Chats" && totalUnread > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
|
||||||
|
{totalUnread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="ml-2">
|
<TooltipContent side="right" className="ml-2">
|
||||||
@@ -141,17 +157,19 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
|
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sidebar-accent">
|
<Avatar className="h-10 w-10">
|
||||||
<span className="text-sm font-medium text-sidebar-accent-foreground">SC</span>
|
<AvatarImage src={user.avatar} />
|
||||||
</div>
|
<AvatarFallback className="text-sm font-medium">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-sidebar-accent">
|
<Avatar className="h-9 w-9">
|
||||||
<span className="text-sm font-medium text-sidebar-accent-foreground">SC</span>
|
<AvatarImage src={user.avatar} />
|
||||||
</div>
|
<AvatarFallback className="text-sm font-medium">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<p className="text-sm font-medium truncate">Sarah Chen</p>
|
<p className="text-sm font-medium truncate">{user.name}</p>
|
||||||
<p className="text-xs text-sidebar-foreground/60 truncate">Admin</p>
|
<p className="text-xs text-sidebar-foreground/60 truncate capitalize">{user.role}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { useUser } from "@/providers/user-provider"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -31,8 +33,11 @@ interface TopbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
|
const router = useRouter()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const { user } = useUser()
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||||
|
|
||||||
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">
|
||||||
@@ -107,26 +112,26 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3">
|
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3">
|
||||||
<Avatar className="h-7 w-7">
|
<Avatar className="h-7 w-7">
|
||||||
<AvatarImage src="https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=64" />
|
<AvatarImage src={user.avatar} />
|
||||||
<AvatarFallback>SC</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="hidden text-sm font-medium md:inline-block">Sarah Chen</span>
|
<span className="hidden text-sm font-medium md:inline-block">{user.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium">Sarah Chen</p>
|
<p className="text-sm font-medium">{user.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">sarah@coastalit.com</p>
|
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||||
<Badge variant="secondary" className="mt-1 w-fit text-xs">Admin</Badge>
|
<Badge variant="secondary" className="mt-1 w-fit text-xs capitalize">{user.role}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Profile
|
Profile
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { COMPANY_NAME } from "@/lib/constants"
|
import { COMPANY_NAME } from "@/lib/constants"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function CompanySettingsForm() {
|
export function CompanySettingsForm() {
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +40,7 @@ export function CompanySettingsForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
<Button>Save Changes</Button>
|
<Button onClick={() => toast.success("Company settings saved")}>Save Changes</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const notifications = [
|
const notifications = [
|
||||||
{
|
{
|
||||||
@@ -63,7 +64,7 @@ export function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
<Button>Save Preferences</Button>
|
<Button onClick={() => toast.success("Notification preferences saved")}>Save Preferences</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -65,7 +66,7 @@ export function UserPreferencesForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
<Button>Save Preferences</Button>
|
<Button onClick={() => toast.success("Preferences saved")}>Save Preferences</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Conversation } from "@/types"
|
||||||
|
|
||||||
|
export const conversations: Conversation[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
participants: [
|
||||||
|
{ id: "user1", name: "Sarah Chen", avatar: "SC", role: "Admin" },
|
||||||
|
{ id: "user2", name: "Mike Johnson", avatar: "MJ", role: "Sales" },
|
||||||
|
],
|
||||||
|
lastMessage: "Sure, I'll send over the proposal by EOD",
|
||||||
|
lastMessageTime: "2m ago",
|
||||||
|
unread: 2,
|
||||||
|
messages: [
|
||||||
|
{ id: "m1", conversationId: "1", senderId: "user2", senderName: "Mike Johnson", senderAvatar: "MJ", content: "Hey Sarah, have you reviewed the Brightwave leads?", timestamp: "10:32 AM" },
|
||||||
|
{ id: "m2", conversationId: "1", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "Yes, I went through them this morning. Looks promising!", timestamp: "10:33 AM" },
|
||||||
|
{ id: "m3", conversationId: "1", senderId: "user2", senderName: "Mike Johnson", senderAvatar: "MJ", content: "Great! Should I prepare a proposal for the top 3?", timestamp: "10:34 AM" },
|
||||||
|
{ id: "m4", conversationId: "1", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "Absolutely. Focus on Brightwave and Nexus Digital first.", timestamp: "10:35 AM" },
|
||||||
|
{ id: "m5", conversationId: "1", senderId: "user2", senderName: "Mike Johnson", senderAvatar: "MJ", content: "Sure, I'll send over the proposal by EOD", timestamp: "10:36 AM" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
participants: [
|
||||||
|
{ id: "user1", name: "Sarah Chen", avatar: "SC", role: "Admin" },
|
||||||
|
{ id: "user3", name: "Emily Davis", avatar: "ED", role: "Sales" },
|
||||||
|
],
|
||||||
|
lastMessage: "Can you review the contract terms?",
|
||||||
|
lastMessageTime: "1h ago",
|
||||||
|
unread: 0,
|
||||||
|
messages: [
|
||||||
|
{ id: "m6", conversationId: "2", senderId: "user3", senderName: "Emily Davis", senderAvatar: "ED", content: "Hi Sarah, I'm finalizing the deal with Pinnacle Web Solutions.", timestamp: "9:15 AM" },
|
||||||
|
{ id: "m7", conversationId: "2", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "That's great news! What's the status?", timestamp: "9:16 AM" },
|
||||||
|
{ id: "m8", conversationId: "2", senderId: "user3", senderName: "Emily Davis", senderAvatar: "ED", content: "They're ready to sign, just need approval on the discount.", timestamp: "9:17 AM" },
|
||||||
|
{ id: "m9", conversationId: "2", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "Can you review the contract terms?", timestamp: "9:18 AM" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
participants: [
|
||||||
|
{ id: "user1", name: "Sarah Chen", avatar: "SC", role: "Admin" },
|
||||||
|
{ id: "user4", name: "Alex Turner", avatar: "AT", role: "Sales" },
|
||||||
|
],
|
||||||
|
lastMessage: "Updated the lead status for Vertex Media",
|
||||||
|
lastMessageTime: "3h ago",
|
||||||
|
unread: 1,
|
||||||
|
messages: [
|
||||||
|
{ id: "m10", conversationId: "3", senderId: "user4", senderName: "Alex Turner", senderAvatar: "AT", content: "Just a heads up, Vertex Media called back.", timestamp: "7:45 AM" },
|
||||||
|
{ id: "m11", conversationId: "3", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "Oh nice! What did they say?", timestamp: "7:46 AM" },
|
||||||
|
{ id: "m12", conversationId: "3", senderId: "user4", senderName: "Alex Turner", senderAvatar: "AT", content: "They're interested in the enterprise plan. Set up a meeting for next week.", timestamp: "7:48 AM" },
|
||||||
|
{ id: "m13", conversationId: "3", senderId: "user4", senderName: "Alex Turner", senderAvatar: "AT", content: "Updated the lead status for Vertex Media", timestamp: "7:50 AM" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
participants: [
|
||||||
|
{ id: "user1", name: "Sarah Chen", avatar: "SC", role: "Admin" },
|
||||||
|
{ id: "user5", name: "Lisa Wong", avatar: "LW", role: "Sales" },
|
||||||
|
],
|
||||||
|
lastMessage: "Thanks for the update!",
|
||||||
|
lastMessageTime: "Yesterday",
|
||||||
|
unread: 0,
|
||||||
|
messages: [
|
||||||
|
{ id: "m14", conversationId: "4", senderId: "user5", senderName: "Lisa Wong", senderAvatar: "LW", content: "Sarah, I just closed the deal with Crafted Web Agency!", timestamp: "4:20 PM" },
|
||||||
|
{ id: "m15", conversationId: "4", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "Congratulations Lisa! That's fantastic!", timestamp: "4:21 PM" },
|
||||||
|
{ id: "m16", conversationId: "4", senderId: "user5", senderName: "Lisa Wong", senderAvatar: "LW", content: "Thanks! Contract is signed and onboarding starts Monday.", timestamp: "4:22 PM" },
|
||||||
|
{ id: "m17", conversationId: "4", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "Thanks for the update!", timestamp: "4:23 PM" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
participants: [
|
||||||
|
{ id: "user1", name: "Sarah Chen", avatar: "SC", role: "Admin" },
|
||||||
|
{ id: "user6", name: "James Wilson", avatar: "JW", role: "Sales" },
|
||||||
|
],
|
||||||
|
lastMessage: "Can you assign me the new leads?",
|
||||||
|
lastMessageTime: "Yesterday",
|
||||||
|
unread: 0,
|
||||||
|
messages: [
|
||||||
|
{ id: "m18", conversationId: "5", senderId: "user6", senderName: "James Wilson", senderAvatar: "JW", content: "I've got capacity for more leads this quarter.", timestamp: "2:00 PM" },
|
||||||
|
{ id: "m19", conversationId: "5", senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", content: "Good to know James. I'll assign you some from the new batch.", timestamp: "2:01 PM" },
|
||||||
|
{ id: "m20", conversationId: "5", senderId: "user6", senderName: "James Wilson", senderAvatar: "JW", content: "Can you assign me the new leads?", timestamp: "2:02 PM" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -32,7 +32,7 @@ function getStatusDistribution() {
|
|||||||
{ name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" },
|
{ name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" },
|
||||||
{ name: "Pending", value: statusCounts.pending, color: "#8b5cf6" },
|
{ name: "Pending", value: statusCounts.pending, color: "#8b5cf6" },
|
||||||
{ name: "Closed", value: statusCounts.closed, color: "#10b981" },
|
{ name: "Closed", value: statusCounts.closed, color: "#10b981" },
|
||||||
{ name: "Ignored", value: statusCounts.ignored, color: "#71717a" },
|
{ name: "Ignored", value: statusCounts.ignored, color: "#6B7280" },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ export const users: User[] = [
|
|||||||
{
|
{
|
||||||
id: "u1",
|
id: "u1",
|
||||||
name: "Sarah Chen",
|
name: "Sarah Chen",
|
||||||
email: "sarah@coastalit.com",
|
email: "SarahChen@coastit.co.za",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ export const LEAD_SOURCES = [
|
|||||||
|
|
||||||
export const ITEMS_PER_PAGE = 10
|
export const ITEMS_PER_PAGE = 10
|
||||||
|
|
||||||
export const COMPANY_NAME = "Coastal IT"
|
export const COMPANY_NAME = "Coast IT"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, ReactNode } from "react"
|
||||||
|
import { currentUser } from "@/data/users"
|
||||||
|
import type { User } from "@/types"
|
||||||
|
|
||||||
|
interface UserContextValue {
|
||||||
|
user: User
|
||||||
|
updateAvatar: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserContext = createContext<UserContextValue | null>(null)
|
||||||
|
|
||||||
|
export function UserProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [avatar, setAvatar] = useState(currentUser.avatar)
|
||||||
|
|
||||||
|
const user: User = { ...currentUser, avatar }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ user, updateAvatar: setAvatar }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
const ctx = useContext(UserContext)
|
||||||
|
if (!ctx) throw new Error("useUser must be used within UserProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -55,3 +55,22 @@ export interface ColumnFilter {
|
|||||||
id: string
|
id: string
|
||||||
value: unknown
|
value: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
conversationId: string
|
||||||
|
senderId: string
|
||||||
|
senderName: string
|
||||||
|
senderAvatar: string
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string
|
||||||
|
participants: { id: string; name: string; avatar: string; role: string }[]
|
||||||
|
lastMessage: string
|
||||||
|
lastMessageTime: string
|
||||||
|
unread: number
|
||||||
|
messages: ChatMessage[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user