Initial commit: OnDeck project

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-02-18 13:20:52 +00:00
commit b036a93da2
139 changed files with 33198 additions and 0 deletions

View file

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(ssh-keygen:*)",
"Bash(chmod:*)",
"Bash(git init:*)",
"Bash(git add:*)"
]
}
}

16
dev/.postgres Normal file
View file

@ -0,0 +1,16 @@
# PostgreSQL Connection Details for OnDeck Database
# Connection String
DATABASE_URL=postgresql://ondeck_user:ondeck_password_2026!@localhost:5432/ondeck
# Individual Connection Parameters
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=ondeck
POSTGRES_USER=ondeck_user
POSTGRES_PASSWORD=ondeck_password_2026!
# Alternative Formats
# SQLAlchemy: postgresql+psycopg2://ondeck_user:ondeck_password_2026!@localhost:5432/ondeck
# JDBC: jdbc:postgresql://localhost:5432/ondeck?user=ondeck_user&password=ondeck_password_2026!
# Node.js: postgres://ondeck_user:ondeck_password_2026!@localhost:5432/ondeck

4
dev/.sql Normal file
View file

@ -0,0 +1,4 @@
server: 63.89.3.224
database: A1100080D1
user: 1100080_RO
password: iYi=14AxQh@7%oXkA[]9

416
dev/DATABASE_REFERENCE.md Normal file
View file

@ -0,0 +1,416 @@
# AFW Database Reference
**Server:** 63.89.3.224
**Database:** A1100080D1
---
## Departments
| ShortName | GLDeptCode | Full Name |
|-----------|------------|-----------|
| Dept0 | 000 | 00-Administration |
| Dept1 | 001 | 01-Commercial Lines |
| Dept2 | 002 | 02-Private Client |
| Dept3 | 003 | 03-Surety |
| Dept4 | 004 | 04-Group Benefits |
| Dept5 | 005 | 05-Life Insurance |
| Dept6 | 006 | 06-Retirement Benefits |
| Dept7 | 007 | DC Operations |
| Dept8 | 008 | 08-Select Commercial |
| Dept9 | 009 | 09-Transportation |
| Dept10 | 010 | 10-Real Estate/HealthCare |
| Dept11 | 011 | Streamline Captive |
| Dept12 | 012 | 12-Construction |
---
## Key Tables
### AFW_BasicPolInfo
Core policy information table.
| Column | Type | Description |
|--------|------|-------------|
| PolId | uniqueidentifier | Primary key - Policy ID |
| CustId | uniqueidentifier | Foreign key to AFW_Customer |
| PolNo | varchar(25) | Policy number |
| ShortPolNo | varchar(25) | Short policy number |
| PolEffDate | datetime | Policy effective date |
| PolExpDate | datetime | Policy expiration date |
| PolType | char(1) | Policy type |
| PolSubType | char(1) | Policy sub-type (P=Policy) |
| PolTypeLOB | varchar(150) | Line of business |
| Status | char(1) | Policy status (D=Deleted) |
| CoCode | varchar(3) | Parent company code |
| WritingCoCode | varchar(3) | Writing company code |
| ExecCode | varchar(3) | Executive/Producer code |
| CsrCode | varchar(3) | CSR code |
| BrokerCode | varchar(3) | Broker code |
| GLDivCode | varchar(3) | GL Division code |
| GLDeptCode | varchar(3) | GL Department code |
| GLBrnchCode | char(3) | GL Branch code |
| GLGrpCode | char(3) | GL Group code |
| BillMethod | varchar(1) | Billing method code |
| TypeOfBus | smallint | Type of business code |
| RenewalRptFlag | char(1) | Renewal/Status flag |
| IsContinuous | char(1) | Continuous policy flag |
| IsFinanced | varchar(1) | Financed flag |
| FullTermPremium | money | Full term premium amount |
| ChangedBy | varchar(3) | Last modified by |
| ChangedDate | datetime | Last modified date |
| EnteredDate | datetime | Entry date |
### AFW_Customer
Customer/insured information.
| Column | Type | Description |
|--------|------|-------------|
| CustId | uniqueidentifier | Primary key - Customer ID |
| CustNo | int | Customer number |
| FirmNameCust | varchar(75) | Firm/customer name |
| LastName | varchar(51) | Last name (individual) |
| FirstName | varchar(50) | First name (individual) |
| Addr1 | varchar(75) | Address line 1 |
| Addr2 | varchar(75) | Address line 2 |
| City | varchar(30) | City |
| State | char(2) | State code |
| ZipCode | varchar(9) | ZIP code |
| TypeCust | char(1) | Customer type |
| EMail | varchar(150) | Email address |
| BusPhone | varchar(7) | Business phone |
| ANotId | uniqueidentifier | Notation ID |
| Prod1Code | char(3) | Producer code |
| CsrCode | char(3) | CSR code |
| GLDivCode | char(3) | GL Division code |
| GLDeptCode | char(3) | GL Department code |
| Active | char(1) | Active status |
### AFW_Company
Insurance company/carrier information.
| Column | Type | Description |
|--------|------|-------------|
| CoId | uniqueidentifier | Primary key - Company ID |
| CoCode | char(3) | Company code |
| Name | varchar(75) | Company full name |
| ShortName | varchar(6) | Company short name |
| Type | char(1) | Company type |
| Status | char(1) | Status |
| NAIC | varchar(6) | NAIC code |
| ParentCoCode | char(3) | Parent company code |
### AFW_GeneralLedgerDepartment
Department lookup table.
| Column | Type | Description |
|--------|------|-------------|
| GLDeptCode | char(3) | Primary key - Department code |
| Name | varchar(25) | Department full name |
| ShortName | char(6) | Department short name (Dept1, Dept2, etc.) |
| Status | char(1) | Status |
| IsHide | char(1) | Hidden flag |
### AFW_Employee
Employee/personnel information.
| Column | Type | Description |
|--------|------|-------------|
| EmpCode | varchar(3) | Primary key - Employee code |
| LastName | varchar(20) | Last name |
| FirstName | varchar(16) | First name |
| MiddleName | varchar(11) | Middle name |
| ShortName | varchar(6) | Short name |
| Title | varchar(64) | Job title |
| EMail | varchar(150) | Email address |
| Status | char(1) | Status |
| IsRep | char(1) | Is representative flag |
| IsProd | char(1) | Is producer flag |
| EmpSupervisorCode | varchar(3) | Supervisor employee code |
| DefaultGLDivCode | char(3) | Default GL Division |
| DefaultGLDeptCode | char(3) | Default GL Department |
### AFW_PolicyPersonnel
Links policies to additional personnel (reps, executives).
| Column | Type | Description |
|--------|------|-------------|
| PolId | uniqueidentifier | Policy ID |
| PolPId | uniqueidentifier | Policy Personnel ID |
| EmpCode | char(3) | Employee code |
| EmpType | char(1) | Employee type (R=Rep, P=Producer/Exec) |
| IsPrimary | char(1) | Is primary flag (Y/N) |
| Percentage | float | Commission percentage |
| FlatAmount | money | Flat commission amount |
| Position | smallint | Position/order |
| EnteredDate | datetime | Entry date |
### AFW_PRCode
Lookup/reference codes table.
| Column | Type | Description |
|--------|------|-------------|
| AttrCode | char(3) | Attribute code (category) |
| Code | varchar(10) | Code value |
| Description | varchar(150) | Description |
| SortNo | smallint | Sort order |
| IsHide | char(1) | Hidden flag |
---
## Lookup Values (AFW_PRCode)
### Type of Business (AttrCode = 'TB')
| Code | Description |
|------|-------------|
| 0 | All |
| 1 | Personal Lines |
| 2 | Commercial Lines |
| 3 | Non Property & Casualty |
| 4 | Benefits |
| 5 | Life |
| 6 | Health |
| 7 | Financial Services |
### Billing Method (AttrCode = 'BM')
| Code | Description |
|------|-------------|
| A | Agency bill |
| F | 1st Installment Agency Bill, Remaining Direct Bill |
| P | Direct bill |
### Policy Status/Renewal Flag (AttrCode = 'PF')
| Code | Description |
|------|-------------|
| A | Active |
| C | Cancelled |
| E | Expired |
| N | Non-Renewed |
| R | Renewed |
| W | Rewritten |
| T | Not taken |
| I | Include |
| Q | Quote |
---
## Employee Assignment to Policies
Employees can be assigned to policies in two ways: **Primary Assignment** (direct fields on the policy) and **Additional Personnel** (via the AFW_PolicyPersonnel table).
### Primary Assignment (AFW_BasicPolInfo)
Every policy has three primary employee fields:
| Field | Role | Description |
|-------|------|-------------|
| `ExecCode` | **Executive/Producer** | Primary producer/account executive responsible for the policy |
| `CsrCode` | **CSR** | Customer Service Representative handling day-to-day service |
| `BrokerCode` | **Broker** | External broker (optional, can be NULL) |
These are required fields (except BrokerCode) and link to `AFW_Employee.EmpCode`.
```sql
-- Example: Get primary personnel for a policy
SELECT
bp.PolNo,
exec.LastName + ', ' + exec.FirstName AS Executive,
csr.LastName + ', ' + csr.FirstName AS CSR,
broker.LastName + ', ' + broker.FirstName AS Broker
FROM AFW_BasicPolInfo bp
INNER JOIN AFW_Employee exec ON exec.EmpCode = bp.ExecCode
INNER JOIN AFW_Employee csr ON csr.EmpCode = bp.CsrCode
LEFT JOIN AFW_Employee broker ON broker.EmpCode = bp.BrokerCode
```
### Additional Personnel (AFW_PolicyPersonnel)
Policies can have additional personnel assignments beyond the primary exec/csr/broker. These are stored in `AFW_PolicyPersonnel`.
#### Employee Types (EmpType)
| Code | Description | Usage |
|------|-------------|-------|
| `P` | **Producer/Executive** | Additional executives, account managers |
| `R` | **Representative** | Additional CSRs, service reps |
| `B` | **Broker** | Additional brokers |
| `T` | **Sales Center Rep** | Telemarketing/sales center staff |
#### Primary vs Additional
| IsPrimary | Meaning |
|-----------|---------|
| `Y` | Primary person of this type for the policy |
| `N` | Additional/secondary person of this type |
A policy can have:
- One primary Producer (`EmpType='P'`, `IsPrimary='Y'`)
- Multiple additional Producers (`EmpType='P'`, `IsPrimary='N'`)
- One primary Rep (`EmpType='R'`, `IsPrimary='Y'`)
- Multiple additional Reps (`EmpType='R'`, `IsPrimary='N'`)
#### Commission Fields
| Field | Description |
|-------|-------------|
| `Method` | Commission method: `A`=Amount/Percentage, `P`=Percentage only |
| `Percentage` | Commission split percentage |
| `FlatAmount` | Flat commission amount |
| `FeeMethod` | Fee calculation method |
| `FeePercentage` | Fee percentage |
| `ProductionCreditSplitPercentage` | Production credit split % |
| `IsSuspended` | Whether commission is suspended |
```sql
-- Example: Get all personnel for a policy with their types
SELECT
pp.EmpType,
CASE pp.EmpType
WHEN 'P' THEN 'Producer/Exec'
WHEN 'R' THEN 'Representative'
WHEN 'B' THEN 'Broker'
WHEN 'T' THEN 'Sales Center Rep'
END AS RoleDescription,
pp.IsPrimary,
emp.LastName + ', ' + emp.FirstName AS Name,
pp.Percentage AS CommissionPct
FROM AFW_PolicyPersonnel pp
INNER JOIN AFW_Employee emp ON emp.EmpCode = pp.EmpCode
WHERE pp.PolId = @PolId
ORDER BY pp.EmpType, pp.IsPrimary DESC, pp.Position
```
### Customer-Level Personnel (AFW_CustAddPersonnel)
Customers can also have default personnel assignments that may cascade to new policies.
| Column | Description |
|--------|-------------|
| `CustId` | Customer ID |
| `EmpCode` | Employee code |
| `TypeOfEmp` | Employee type (`P`=Producer, `R`=Rep) |
| `TypeOfBus` | Type of business this applies to |
| `IsPrimary` | Primary flag |
### Employee Role Flags (AFW_Employee)
Employees have role flags indicating their capabilities:
| Flag | Description |
|------|-------------|
| `IsRep` | Can be assigned as a Representative |
| `IsProd` | Can be assigned as a Producer |
| `IsTeleMarketer` | Telemarketing role |
| `IsOther` | Other role type |
Common combinations:
- `IsRep=Y, IsProd=Y` - Can serve as both producer and rep (most common)
- `IsRep=N, IsProd=Y` - Producer only
- `IsRep=Y, IsProd=N` - Rep/CSR only
### Query: Get All Personnel for a Policy (Full)
```sql
-- Primary personnel from AFW_BasicPolInfo
SELECT
'Primary Exec' AS Assignment,
bp.ExecCode AS EmpCode,
exec.LastName + ', ' + exec.FirstName AS Name
FROM AFW_BasicPolInfo bp
INNER JOIN AFW_Employee exec ON exec.EmpCode = bp.ExecCode
WHERE bp.PolId = @PolId
UNION ALL
SELECT
'Primary CSR' AS Assignment,
bp.CsrCode,
csr.LastName + ', ' + csr.FirstName
FROM AFW_BasicPolInfo bp
INNER JOIN AFW_Employee csr ON csr.EmpCode = bp.CsrCode
WHERE bp.PolId = @PolId
UNION ALL
-- Additional personnel from AFW_PolicyPersonnel
SELECT
CASE
WHEN pp.EmpType = 'P' AND pp.IsPrimary = 'Y' THEN 'Add''l Exec (Primary)'
WHEN pp.EmpType = 'P' AND pp.IsPrimary = 'N' THEN 'Add''l Exec'
WHEN pp.EmpType = 'R' AND pp.IsPrimary = 'Y' THEN 'Add''l Rep (Primary)'
WHEN pp.EmpType = 'R' AND pp.IsPrimary = 'N' THEN 'Add''l Rep'
WHEN pp.EmpType = 'B' THEN 'Add''l Broker'
END AS Assignment,
pp.EmpCode,
emp.LastName + ', ' + emp.FirstName
FROM AFW_PolicyPersonnel pp
INNER JOIN AFW_Employee emp ON emp.EmpCode = pp.EmpCode
WHERE pp.PolId = @PolId
```
---
## Key Views
| View Name | Description |
|-----------|-------------|
| AFW_PolicyBizVw | Policy business summary view |
| AFW_PolicyVw | Full policy view |
| AFW_PolicyShortVw | Abbreviated policy view |
| AFW_CustomerShortVw | Abbreviated customer view |
| AFW_EmployeeVw | Employee view |
| AFW_CompanyVw | Company view |
| AFW_BrokerVw | Broker view |
| AFW_BusinessUnitVw | Business unit view |
| AFW_InvoiceTransactionVw | Invoice transaction view |
| AFW_PolicyTransactionFactVw | Policy transaction fact view |
---
## Common Query Patterns
### Filter by Department
```sql
INNER JOIN AFW_GeneralLedgerDepartment
ON AFW_GeneralLedgerDepartment.GLDeptCode = AFW_BasicPolInfo.GLDeptCode
WHERE AFW_GeneralLedgerDepartment.ShortName IN ('Dept1', 'Dept8', 'Dept9')
```
### Get Policy Status Description
```sql
INNER JOIN AFW_PRCode RenewalRptFlag
ON RenewalRptFlag.Code = AFW_BasicPolInfo.RenewalRptFlag
AND RenewalRptFlag.AttrCode = 'PF'
```
### Get Employee Formatted Name
```sql
AFW_Employee.LastName + ', ' + AFW_Employee.FirstName AS FormattedName
```
### Filter Active Policies (Exclude Deleted)
```sql
WHERE AFW_BasicPolInfo.Status != 'D'
AND PolSubType = 'P'
```
---
## Table Relationships
```
AFW_Customer (CustId)
└── AFW_BasicPolInfo (CustId) ──┬── AFW_Company [ParentCompany] (CoCode)
├── AFW_Company [WritingCompany] (WritingCoCode)
├── AFW_GeneralLedgerDepartment (GLDeptCode)
├── AFW_Employee [ExecCode] (EmpCode)
├── AFW_Employee [CsrCode] (EmpCode)
├── AFW_PRCode [TypeOfBusiness] (TypeOfBus, AttrCode='TB')
├── AFW_PRCode [BillMethod] (BillMethod, AttrCode='BM')
├── AFW_PRCode [Status] (RenewalRptFlag, AttrCode='PF')
└── AFW_PolicyPersonnel (PolId)
└── AFW_Employee (EmpCode)
```

61
dev/create-prd.mdc Normal file
View file

@ -0,0 +1,61 @@
---
description:
globs:
alwaysApply: false
---
# Rule: Generating a Product Requirements Document (PRD)
## Goal
To guide an AI assistant in creating a detailed Product Requirements Document (PRD) in Markdown format, based on an initial user prompt. The PRD should be clear, actionable, and suitable for a junior developer to understand and implement the feature.
## Process
1. **Receive Initial Prompt:** The user provides a brief description or request for a new feature or functionality.
2. **Ask Clarifying Questions:** Before writing the PRD, the AI *must* ask clarifying questions to gather sufficient detail. The goal is to understand the "what" and "why" of the feature, not necessarily the "how" (which the developer will figure out).
3. **Generate PRD:** Based on the initial prompt and the user's answers to the clarifying questions, generate a PRD using the structure outlined below.
4. **Save PRD:** Save the generated document as `prd-[feature-name].md` inside the `/tasks` directory.
## Clarifying Questions (Examples)
The AI should adapt its questions based on the prompt, but here are some common areas to explore:
* **Problem/Goal:** "What problem does this feature solve for the user?" or "What is the main goal we want to achieve with this feature?"
* **Target User:** "Who is the primary user of this feature?"
* **Core Functionality:** "Can you describe the key actions a user should be able to perform with this feature?"
* **User Stories:** "Could you provide a few user stories? (e.g., As a [type of user], I want to [perform an action] so that [benefit].)"
* **Acceptance Criteria:** "How will we know when this feature is successfully implemented? What are the key success criteria?"
* **Scope/Boundaries:** "Are there any specific things this feature *should not* do (non-goals)?"
* **Data Requirements:** "What kind of data does this feature need to display or manipulate?"
* **Design/UI:** "Are there any existing design mockups or UI guidelines to follow?" or "Can you describe the desired look and feel?"
* **Edge Cases:** "Are there any potential edge cases or error conditions we should consider?"
## PRD Structure
The generated PRD should include the following sections:
1. **Introduction/Overview:** Briefly describe the feature and the problem it solves. State the goal.
2. **Goals:** List the specific, measurable objectives for this feature.
3. **User Stories:** Detail the user narratives describing feature usage and benefits.
4. **Functional Requirements:** List the specific functionalities the feature must have. Use clear, concise language (e.g., "The system must allow users to upload a profile picture."). Number these requirements.
5. **Non-Goals (Out of Scope):** Clearly state what this feature will *not* include to manage scope.
6. **Design Considerations (Optional):** Link to mockups, describe UI/UX requirements, or mention relevant components/styles if applicable.
7. **Technical Considerations (Optional):** Mention any known technical constraints, dependencies, or suggestions (e.g., "Should integrate with the existing Auth module").
8. **Success Metrics:** How will the success of this feature be measured? (e.g., "Increase user engagement by 10%", "Reduce support tickets related to X").
9. **Open Questions:** List any remaining questions or areas needing further clarification.
## Target Audience
Assume the primary reader of the PRD is a **junior developer**. Therefore, requirements should be explicit, unambiguous, and avoid jargon where possible. Provide enough detail for them to understand the feature's purpose and core logic.
## Output
* **Format:** Markdown (`.md`)
* **Location:** `/tasks/`
* **Filename:** `prd-[feature-name].md`
## Final instructions
1. Do NOT start implementing the PRD
2. Make sure to ask the user clarifying questions
3. Take the user's answers to the clarifying questions and improve the PRD

64
dev/generate-tasks.mdc Normal file
View file

@ -0,0 +1,64 @@
---
description:
globs:
alwaysApply: false
---
# Rule: Generating a Task List from a PRD
## Goal
To guide an AI assistant in creating a detailed, step-by-step task list in Markdown format based on an existing Product Requirements Document (PRD). The task list should guide a developer through implementation.
## Output
- **Format:** Markdown (`.md`)
- **Location:** `/tasks/`
- **Filename:** `tasks-[prd-file-name].md` (e.g., `tasks-prd-user-profile-editing.md`)
## Process
1. **Receive PRD Reference:** The user points the AI to a specific PRD file
2. **Analyze PRD:** The AI reads and analyzes the functional requirements, user stories, and other sections of the specified PRD.
3. **Phase 1: Generate Parent Tasks:** Based on the PRD analysis, create the file and generate the main, high-level tasks required to implement the feature. Use your judgement on how many high-level tasks to use. It's likely to be about 5. Present these tasks to the user in the specified format (without sub-tasks yet). Inform the user: "I have generated the high-level tasks based on the PRD. Ready to generate the sub-tasks? Respond with 'Go' to proceed."
4. **Wait for Confirmation:** Pause and wait for the user to respond with "Go".
5. **Phase 2: Generate Sub-Tasks:** Once the user confirms, break down each parent task into smaller, actionable sub-tasks necessary to complete the parent task. Ensure sub-tasks logically follow from the parent task and cover the implementation details implied by the PRD.
6. **Identify Relevant Files:** Based on the tasks and PRD, identify potential files that will need to be created or modified. List these under the `Relevant Files` section, including corresponding test files if applicable.
7. **Generate Final Output:** Combine the parent tasks, sub-tasks, relevant files, and notes into the final Markdown structure.
8. **Save Task List:** Save the generated document in the `/tasks/` directory with the filename `tasks-[prd-file-name].md`, where `[prd-file-name]` matches the base name of the input PRD file (e.g., if the input was `prd-user-profile-editing.md`, the output is `tasks-prd-user-profile-editing.md`).
## Output Format
The generated task list _must_ follow this structure:
```markdown
## Relevant Files
- `path/to/potential/file1.ts` - Brief description of why this file is relevant (e.g., Contains the main component for this feature).
- `path/to/file1.test.ts` - Unit tests for `file1.ts`.
- `path/to/another/file.tsx` - Brief description (e.g., API route handler for data submission).
- `path/to/another/file.test.tsx` - Unit tests for `another/file.tsx`.
- `lib/utils/helpers.ts` - Brief description (e.g., Utility functions needed for calculations).
- `lib/utils/helpers.test.ts` - Unit tests for `helpers.ts`.
### Notes
- Unit tests should typically be placed alongside the code files they are testing (e.g., `MyComponent.tsx` and `MyComponent.test.tsx` in the same directory).
- Use `npx jest [optional/path/to/test/file]` to run tests. Running without a path executes all tests found by the Jest configuration.
## Tasks
- [ ] 1.0 Parent Task Title
- [ ] 1.1 [Sub-task description 1.1]
- [ ] 1.2 [Sub-task description 1.2]
- [ ] 2.0 Parent Task Title
- [ ] 2.1 [Sub-task description 2.1]
- [ ] 3.0 Parent Task Title (may not require sub-tasks if purely structural or configuration)
```
## Interaction Model
The process explicitly requires a pause after generating parent tasks to get user confirmation ("Go") before proceeding to generate the detailed sub-tasks. This ensures the high-level plan aligns with user expectations before diving into details.
## Target Audience
Assume the primary reader of the task list is a **junior developer** who will implement the feature.

292
dev/mvp.md Normal file
View file

@ -0,0 +1,292 @@
# OnDeck (Horizon)
## Policy Renewal Workflow System
### Functional Requirements Review (With User Feedback)
---
## Purpose of This Document
This document describes what **OnDeck** will do for your team. It incorporates stakeholder feedback directly into the functional requirements so the final system reflects real operational needs.
Stakeholders were asked to:
- Answer questions highlighted in yellow
- Confirm decisions highlighted in green
- Suggest changes or additions
User responses are embedded inline below.
---
## What is OnDeck?
OnDeck is a web application that automatically creates and tracks renewal tasks for insurance policies. It integrates with **AMS360** to pull client, policy, and staff data, then generates tasks at the appropriate time based on configurable rules.
---
## Key Benefits
- Automatic task creation based on policy expiration dates
- Consistent renewal workflows across departments
- Clear visibility into task ownership and overdue work
- Full audit trail for compliance
- Microsoft 365 single sign-on
---
## 1. User Roles & Permissions
OnDeck supports four user roles with different access levels:
| Role | Capabilities |
|-----|-------------|
| **Admin** | Full system access: user management, configuration, reporting, audit logs, data sync |
| **Manager** | View all clients/tasks, assign work, manage templates, run reports, view audit logs |
| **Account Executive (AE)** | View/update assigned clients and tasks, mark tasks complete |
| **Claims** | View client info and manage claims-related tasks only |
> ❓ **QUESTION:** Are these four roles sufficient?
> 💬 **USER RESPONSE:**
> Yes, these four roles are sufficient for the MVP. Additional roles or more granular permissions can be evaluated after rollout once usage patterns are clearer.
---
> ❓ **QUESTION:** Who should have Admin access?
> 💬 **USER RESPONSE:**
> Likely Kristie Lulich and Tyler Lyster, though final determination should be deferred to Tyler.
---
> ✅ **DECISION NEEDED:** Role assignment method
> 💬 **USER RESPONSE:**
> Roles should be assigned based on Microsoft 365 security groups. The more automation, the better.
---
### Authentication
- Users authenticate using existing Microsoft 365 credentials
- No separate password required
- First login automatically creates an OnDeck account and links it to the AMS360 employee record
---
## 2. Departments
OnDeck organizes work by department. Each policy and task template is associated with a department.
### Default Departments
- Personal Lines
- Commercial Lines
- Claims
- Benefits
- Other
> ❓ **QUESTION:** Should all 13 AMS360 departments be synced?
> 💬 **USER RESPONSE:**
> Recommendation is to start with **Commercial Lines** and **Claims**. Personal Lines could be added later, but may not be needed initially due to other technologies in use.
---
> ❓ **QUESTION:** Should task templates be department-specific?
> 💬 **USER RESPONSE:**
> Yes initially. In the future, tasks should be usable across departments if needed.
---
## 3. Data from AMS360
OnDeck syncs data from AMS360 on a scheduled basis.
### Data Synced
| Data Type | Description |
|---------|-------------|
| Employees | Active employees become users with name, email, department |
| Clients | Active customers with contact and producer info |
| Policies | Expiration date, carrier, department, assigned reps (up to 2 reps + 2 execs) |
### Sync Schedule
- Nightly sync at **2:00 AM**
- Manual sync available to Admins
> ✅ **DECISION:** Is nightly sync acceptable?
> 💬 **USER RESPONSE:**
> Yes, no issues anticipated with a 2:00 AM sync.
---
> ❓ **QUESTION:** Should policy date ranges be restricted?
> 💬 **USER RESPONSE:**
> No. Tasks occur year-round. Ideally include all current policies. Historical policies may be useful in the future.
---
## 4. Task Templates
Task templates define renewal workflows and generate tasks automatically.
### Template Attributes
- Name
- Description
- Department
- Timing (pre/post renewal)
- Days offset
- Priority
- Optional client designation
### Example Templates
| Task | Timing | Offset | Priority | Department |
|----|------|--------|---------|-----------|
| Order Loss Runs | Pre | -90 | Medium | Commercial |
| Review Coverage | Pre | -60 | High | Commercial |
| Send Renewal Quote | Pre | -30 | High | Commercial |
| Confirm Renewal | Pre | -7 | Urgent | Commercial |
| Issue Certificates | Post | +3 | Medium | Commercial |
---
> ❓ **QUESTION:** Provide standard renewal templates
> 💬 **USER RESPONSE:**
> This information exists in the Claims database.
---
> ❓ **QUESTION:** Do client designations require different tasks?
> 💬 **USER RESPONSE:**
> Differences exist mainly for **SHAPE** and **SHAPE2**, primarily impacting Claims. AEs generally follow the same workflow. Future enhancement could support à la carte or tiered services.
---
## 5. Task Workflow
### Task Statuses
| Status | Meaning |
|------|--------|
| Not Started | Task created, work not begun |
| In Progress | Task actively being worked |
| Completed | Task finished (user + timestamp recorded) |
| Blocked | Waiting on external dependency |
| N/A | Not applicable (reason required) |
| Cancelled | Voided (reason required) |
> ❓ **QUESTION:** Are these statuses sufficient?
> 💬 **USER RESPONSE:**
> Generally yes, but preference is to avoid N/A and Cancelled by improving assignment accuracy at the policy/client level.
---
### Task Assignment
> ❓ **QUESTION:** How should tasks be initially assigned?
> 💬 **USER RESPONSE:**
> Automatically based on predefined parameters (policy producer/AE or claims owner). Managers must be able to reassign.
---
> ❓ **QUESTION:** Single owner or multiple assignees?
> 💬 **USER RESPONSE:**
> Single owner per task to ensure accountability. Managers can reassign as needed.
---
## 6. Multiple Policies per Client
Clients may have multiple policies renewing together or separately.
### Proposed Behavior
- Default: one task set per client
- Options:
- Group policies
- Split policies
- Exclude policies
> ✅ **DECISION:** Default task grouping
> 💬 **USER RESPONSE:**
> One task set per client is correct, with smarter assignment logic to minimize manual adjustments.
---
> ❓ **QUESTION:** Policy exclusion reasons?
> 💬 **USER RESPONSE:**
> Defer to Tyler. Generally unclear why a policy would be excluded.
---
> ❓ **QUESTION:** Should excluded policies remain visible?
> 💬 **USER RESPONSE:**
> Yes, with a visual indicator.
---
## 7. Notifications & Alerts
### Alert Types
- Overdue tasks
- Tasks due today
- New assignments
- System issues (Admins only)
### Delivery Methods
- In-app
- Email
- Microsoft Teams
> ✅ **DECISION:** Teams notification targeting
> 💬 **USER RESPONSE:**
> Notifications should be sent to **individual users**, not shared channels.
---
> ❓ **QUESTION:** Overdue reminder frequency?
> 💬 **USER RESPONSE:**
> Weekly consolidated summary to reduce notification fatigue.
---
> ❓ **QUESTION:** Additional notification triggers?
> 💬 **USER RESPONSE:**
> - Policy renewal completion with carrier
> - Client transition into SHAPE / SHAPE2 (notify management)
> Task creation for SHAPE/SHAPE2 should occur after management assignment.
---
## 8. Audit Trail & Compliance
### Logged Events
- User identity
- Before/after values
- Timestamp
- Sign-in attempts
Audit logs are immutable and exportable.
---
> ❓ **QUESTION:** Audit log retention period?
> 💬 **USER RESPONSE:**
> 18 months is sufficient. Can be extended later if required.
---
> ❓ **QUESTION:** Need specific compliance reports?
> 💬 **USER RESPONSE:**
> No dedicated compliance reports needed for MVP. Standard task/status reporting is sufficient.
---
## Summary of Confirmed Direction
- MVP roles confirmed
- Microsoft 365based role automation preferred
- Initial focus on Commercial + Claims
- Single-task ownership model
- Weekly overdue summaries
- One task set per client by default
- 18-month audit retention
The Shape designation is already in use by AMS360, Shape2 has yet to be defined in AMS but will be similar to Shape

View file

@ -0,0 +1,621 @@
# OnDeck - PRD Input Document
## Project Overview
**Project Name:** OnDeck - Policy Renewal Workflow Management System
**Client Type:** Insurance Brokerage
**Purpose:** Streamline policy renewal workflows by centralizing client and policy data from AMS360, enabling custom client classifications, personnel assignments, and department-specific task management tied to policy renewal dates.
---
## 1. Problem Statement
Insurance brokerages managing large policy portfolios face challenges coordinating renewal activities across departments. Currently:
- Client and policy data lives in AMS360 with no custom workflow layer
- No systematic way to assign internal designations or classifications to clients
- Renewal tasks are tracked manually or in disconnected systems
- No visibility into workload distribution or deadline compliance across teams
- Personnel assignments to clients aren't centrally managed
**Goal:** Create OnDeck - a unified web application that syncs AMS360 data, adds custom metadata layers, and provides renewal-based task management with dashboards for workload visibility.
---
## 2. User Personas & Roles
### 2.1 Role Definitions
| Role | Description | Key Permissions |
|------|-------------|-----------------|
| **Admin** | System administrators, IT staff | Full access: user management, sync configuration, all data, system settings |
| **Manager** | Department leads, supervisors | View all department data, assign tasks, manage personnel assignments, view all dashboards |
| **Account Executive** | Handles client relationships, renewals | View assigned clients, manage own tasks, update client info, view personal dashboard |
| **Claims** | Claims department staff | View assigned clients, manage claims-related tasks, limited client edit |
### 2.2 Authorization Model
**Dual-layer approach:**
1. **Entra ID Groups → Base Role Mapping**
- Entra Group membership determines initial role assignment
- Example: `SG-OnDeck-Admins` → Admin role
- MFA enforced via Entra Conditional Access policies
2. **In-App Granular Permissions**
- Fine-grained permissions within roles
- Client-level access assignments (primary/additional personnel)
- Department-based data filtering
- Custom permission overrides when needed
---
## 3. Functional Requirements
### 3.1 Authentication & Authorization
#### 3.1.1 Entra ID Integration
- **SSO via Entra ID** using MSAL.js or NextAuth.js Azure AD provider
- **Token claims must include:** `groups`, `preferred_username`, `name`, `oid`
- **Group-to-role mapping** configurable by Admin
- **Leverage existing MFA** - no additional MFA implementation needed
- **Session management:** Configurable timeout, refresh token handling
#### 3.1.2 In-App Permission System
- Permission sets per role (CRUD matrix)
- Ability to assign individuals to specific clients (overrides department-level access)
- Audit log of permission changes
- Admin UI for managing Entra Group → Role mappings
### 3.2 AMS360 Data Sync Engine
#### 3.2.1 Sync Configuration
- **Frequency:** Daily automated sync (configurable time window)
- **Method:** Direct SQL connection to AMS360 database
- **Direction:** One-way (AMS360 → Local Postgres)
- **Entities to sync:**
- Clients (customer master data)
- Policies (policy details, renewal dates, coverage info)
#### 3.2.2 Sync Behavior
- **Initial load:** Full sync of all active clients and policies
- **Incremental sync:** Based on `ModifiedDate` or equivalent timestamp fields
- **Conflict handling:** AMS360 is source of truth for synced fields
- **Sync logging:** Record sync runs, row counts, errors
- **Manual trigger:** Admin can initiate on-demand sync
#### 3.2.3 Data Mapping (AMS360 → Local)
```
AMS360 Client → local.clients
- CustomerID → ams_customer_id (unique key for sync)
- CustomerName → name
- Address fields → address_*
- Phone/Email → contact_*
- Producer → producer_code
- (other relevant fields TBD based on AMS360 schema)
AMS360 Policy → local.policies
- PolicyID → ams_policy_id (unique key for sync)
- CustomerID → client_id (FK to local.clients)
- PolicyNumber → policy_number
- EffectiveDate → effective_date
- ExpirationDate → expiration_date (THIS IS THE RENEWAL DATE)
- PolicyType → policy_type
- Carrier → carrier_name
- Premium → premium_amount
- Status → status
```
### 3.3 Client Management (Custom Data Layer)
#### 3.3.1 Custom Client Fields (Local Only)
These fields are NOT synced from AMS360 - they are managed entirely in the app:
| Field | Type | Description |
|-------|------|-------------|
| `shape` | FK to shapes table | Primary designation (user-defined) |
| `shape2` | FK to shapes table | Secondary designation (user-defined) |
| `primary_personnel_id` | FK to users | Primary account owner (Entra user) |
| `notes` | Text | Internal notes |
| `custom_fields` | JSONB | Extensible custom field storage |
#### 3.3.2 Shape/Designation Management
- Admin-configurable list of Shape values
- Each shape has: `id`, `name`, `description`, `color` (for UI), `active`
- Shapes can be deactivated but not deleted (preserve historical data)
- Clients can have one Shape and one Shape2 (both optional)
#### 3.3.3 Personnel Assignment
- **Primary Personnel:** One Entra user designated as primary owner
- **Additional Personnel:** Zero or more Entra users with access
- Personnel dropdown populated from Entra ID users (synced or on-demand lookup)
- Assignment changes logged for audit
### 3.4 Task/Action Management
#### 3.4.1 Task Definition
Tasks represent actions to be taken relative to policy renewal dates.
**Task Properties:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Primary key |
| `title` | String | Task name |
| `description` | Text | Detailed instructions |
| `department` | Enum | Personal Lines, Commercial Lines, Claims, etc. |
| `timing` | Enum | `PRE_RENEWAL` or `POST_RENEWAL` |
| `days_offset` | Integer | Days before (negative) or after (positive) renewal |
| `due_date` | Date | Calculated: policy.expiration_date + days_offset |
| `status` | Enum | `NOT_STARTED`, `IN_PROGRESS`, `COMPLETED`, `BLOCKED`, `CANCELLED` |
| `priority` | Enum | `LOW`, `MEDIUM`, `HIGH`, `URGENT` |
| `client_id` | FK | Associated client |
| `policy_id` | FK | Associated policy (optional - can be client-level) |
| `assigned_users` | Many-to-many | One or more Entra users |
| `created_by` | FK | User who created |
| `created_at` | Timestamp | |
| `updated_at` | Timestamp | |
| `completed_at` | Timestamp | When status changed to COMPLETED |
| `completed_by` | FK | User who completed |
#### 3.4.2 Task Templates
- **Department-based templates:** Predefined task sets per department
- Templates define: title, description, timing, days_offset, default priority
- When a policy approaches renewal, tasks can be auto-generated from templates
- Admin can manage templates per department
**Example Template:**
```
Department: Personal Lines
Template Name: "Standard Auto Renewal"
Tasks:
1. Review coverage limits (PRE_RENEWAL, -45 days)
2. Contact client for updates (PRE_RENEWAL, -30 days)
3. Submit renewal to carrier (PRE_RENEWAL, -21 days)
4. Send renewal docs to client (POST_RENEWAL, +3 days)
```
#### 3.4.3 Task Assignment
- Tasks can be assigned to one or more users
- Assignment can be manual or auto-assigned to client's primary personnel
- Bulk task assignment for efficiency
- Reassignment with audit trail
#### 3.4.4 Task Lifecycle
```
NOT_STARTED → IN_PROGRESS → COMPLETED
BLOCKED → IN_PROGRESS → COMPLETED
Any status → CANCELLED (with reason)
```
### 3.5 Dashboards & Reporting
#### 3.5.1 Personal Dashboard (Account Executive, Claims)
- **My Tasks:** Filtered to assigned tasks
- Overdue (red highlight)
- Due today
- Due this week
- Upcoming (next 30 days)
- **My Clients:** Quick access to assigned clients
- **Quick Stats:**
- Tasks completed this week/month
- Overdue task count
- Upcoming renewals count
#### 3.5.2 Manager Dashboard
- **Team Overview:**
- Workload distribution (tasks per team member)
- Completion rates by user
- Overdue tasks by user
- **Department Metrics:**
- Tasks by status (pie/bar chart)
- Renewal timeline (upcoming 90 days)
- SLA compliance (% tasks completed on time)
- **Drill-down capability:** Click to see individual user details
#### 3.5.3 Admin Dashboard
- All Manager dashboard features, plus:
- **System Health:**
- Last sync time, status, row counts
- Error logs
- User activity summary
- **Cross-department views**
#### 3.5.4 KPIs to Track
| KPI | Calculation | Visualization |
|-----|-------------|---------------|
| Tasks Completed | Count by period | Line chart (trend) |
| Tasks Overdue | Count where due_date < today AND status not COMPLETED | Number + list |
| Upcoming Renewals | Policies expiring in next 30/60/90 days | Count + calendar view |
| Workload per Person | Active tasks assigned per user | Bar chart |
| On-Time Completion Rate | (Completed on/before due_date) / Total completed | Percentage |
| Avg Days to Complete | Mean(completed_at - created_at) | Number |
---
## 4. Data Architecture
### 4.1 Database Schema (PostgreSQL)
```sql
-- Entra user cache (synced from Entra or populated on first login)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entra_oid VARCHAR(36) UNIQUE NOT NULL, -- Entra Object ID
email VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(255),
department VARCHAR(100),
is_active BOOLEAN DEFAULT true,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Role definitions
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL, -- Admin, Manager, Account Executive, Claims
description TEXT,
permissions JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- Entra Group to Role mapping
CREATE TABLE entra_group_role_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entra_group_id VARCHAR(36) NOT NULL, -- Entra Group Object ID
entra_group_name VARCHAR(255),
role_id UUID REFERENCES roles(id),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(entra_group_id)
);
-- User role assignments (can override group-based roles)
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id),
role_id UUID REFERENCES roles(id),
assigned_by UUID REFERENCES users(id),
assigned_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, role_id)
);
-- Shape/Designation definitions
CREATE TABLE shapes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(7), -- Hex color for UI
is_active BOOLEAN DEFAULT true,
display_order INTEGER,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Clients (synced from AMS360 + custom fields)
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- AMS360 synced fields
ams_customer_id VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),
state VARCHAR(50),
zip_code VARCHAR(20),
phone VARCHAR(50),
email VARCHAR(255),
producer_code VARCHAR(50),
ams_created_at TIMESTAMP,
ams_modified_at TIMESTAMP,
-- Custom fields (local only)
shape_id UUID REFERENCES shapes(id),
shape2_id UUID REFERENCES shapes(id),
primary_personnel_id UUID REFERENCES users(id),
notes TEXT,
custom_fields JSONB DEFAULT '{}',
-- Metadata
last_synced_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Additional personnel assignments for clients
CREATE TABLE client_personnel (
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
assigned_at TIMESTAMP DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (client_id, user_id)
);
-- Policies (synced from AMS360)
CREATE TABLE policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- AMS360 synced fields
ams_policy_id VARCHAR(50) UNIQUE NOT NULL,
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
policy_number VARCHAR(100),
policy_type VARCHAR(100),
effective_date DATE,
expiration_date DATE NOT NULL, -- This is the RENEWAL DATE
carrier_name VARCHAR(255),
premium_amount DECIMAL(12, 2),
status VARCHAR(50),
ams_created_at TIMESTAMP,
ams_modified_at TIMESTAMP,
-- Metadata
last_synced_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Index for renewal date queries
CREATE INDEX idx_policies_expiration ON policies(expiration_date);
CREATE INDEX idx_policies_client ON policies(client_id);
-- Department enum
CREATE TYPE department_type AS ENUM (
'PERSONAL_LINES',
'COMMERCIAL_LINES',
'CLAIMS',
'BENEFITS',
'OTHER'
);
-- Task timing enum
CREATE TYPE task_timing AS ENUM ('PRE_RENEWAL', 'POST_RENEWAL');
-- Task status enum
CREATE TYPE task_status AS ENUM (
'NOT_STARTED',
'IN_PROGRESS',
'COMPLETED',
'BLOCKED',
'CANCELLED'
);
-- Task priority enum
CREATE TYPE task_priority AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT');
-- Task templates (department-based)
CREATE TABLE task_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
department department_type NOT NULL,
timing task_timing NOT NULL,
days_offset INTEGER NOT NULL, -- Negative = before renewal, Positive = after
default_priority task_priority DEFAULT 'MEDIUM',
is_active BOOLEAN DEFAULT true,
display_order INTEGER,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Tasks (actual task instances)
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
description TEXT,
department department_type NOT NULL,
timing task_timing NOT NULL,
days_offset INTEGER NOT NULL,
due_date DATE NOT NULL,
status task_status DEFAULT 'NOT_STARTED',
priority task_priority DEFAULT 'MEDIUM',
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
policy_id UUID REFERENCES policies(id) ON DELETE SET NULL,
template_id UUID REFERENCES task_templates(id), -- If generated from template
created_by UUID REFERENCES users(id),
completed_at TIMESTAMP,
completed_by UUID REFERENCES users(id),
cancelled_reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Task assignments (many-to-many)
CREATE TABLE task_assignments (
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
assigned_at TIMESTAMP DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (task_id, user_id)
);
-- Indexes for task queries
CREATE INDEX idx_tasks_due_date ON tasks(due_date);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_client ON tasks(client_id);
CREATE INDEX idx_tasks_department ON tasks(department);
-- Sync log
CREATE TABLE sync_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sync_type VARCHAR(50) NOT NULL, -- 'clients', 'policies', 'full'
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
status VARCHAR(50) NOT NULL, -- 'running', 'completed', 'failed'
rows_processed INTEGER DEFAULT 0,
rows_inserted INTEGER DEFAULT 0,
rows_updated INTEGER DEFAULT 0,
error_message TEXT,
triggered_by UUID REFERENCES users(id) -- NULL if scheduled
);
-- Audit log
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_user ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at);
```
### 4.2 AMS360 Connection Requirements
- **Connection type:** SQL Server (typically)
- **Credentials:** Read-only service account recommended
- **Network:** May require VPN or firewall rules
- **Tables to query:** (Exact names depend on AMS360 version)
- Customer table
- Policy table
- (Reference AMS360 data dictionary for exact schema)
---
## 5. Technical Architecture
### 5.1 Recommended Stack
| Layer | Technology | Rationale |
|-------|------------|-----------|
| **Frontend** | React + TypeScript | Familiarity, strong ecosystem |
| **UI Framework** | Tailwind CSS + shadcn/ui | Modern, accessible components |
| **State Management** | TanStack Query | Server state caching, mutations |
| **Backend** | Node.js + Express or Next.js API Routes | JavaScript consistency |
| **ORM** | Prisma or Drizzle | Type-safe database access |
| **Database** | PostgreSQL | Robust, JSONB support |
| **Auth** | MSAL.js + NextAuth.js (Azure AD provider) | Native Entra integration |
| **Sync Engine** | Node.js service + node-mssql | AMS360 SQL connection |
| **Scheduler** | node-cron or external (systemd timer) | Daily sync trigger |
| **Hosting** | Docker on Linux server | Your standard deployment |
### 5.2 Authentication Flow
```
1. User navigates to app
2. App redirects to Entra ID login (MSAL.js)
3. User authenticates + MFA (per Entra CA policies)
4. Entra returns ID token + access token
5. Token includes group claims
6. App backend:
a. Validates token
b. Looks up entra_oid in users table (create if first login)
c. Resolves roles from group claims + user_roles table
d. Creates session
7. Frontend receives user context with permissions
```
### 5.3 Sync Engine Architecture
```
┌─────────────────┐ ┌──────────────────┐
│ AMS360 DB │ ──SQL── │ Sync Service │
│ (SQL Server) │ │ (Node.js) │
└─────────────────┘ └────────┬─────────┘
┌────────▼─────────┐
│ PostgreSQL │
│ (Local DB) │
└──────────────────┘
Sync Process:
1. Cron triggers sync at configured time
2. Sync service connects to AMS360 (read-only)
3. Queries records modified since last sync
4. Upserts into local Postgres (match on ams_*_id)
5. Logs sync results
6. Closes connections
```
---
## 6. Non-Functional Requirements
### 6.1 Security
- All traffic over HTTPS
- Entra ID MFA enforced via Conditional Access
- Database credentials in environment variables / secrets manager
- Read-only AMS360 connection
- RBAC enforced at API layer
- Audit logging for sensitive operations
- Session timeout configurable (default: 8 hours)
### 6.2 Performance
- Dashboard queries < 2 seconds
- Sync should complete within 1 hour for typical data volumes
- Pagination on all list endpoints (default 50, max 100)
- Database indexes on frequently queried fields
### 6.3 Availability
- Daily sync window during off-hours (e.g., 2 AM)
- Sync failures should not affect app availability
- Sync retry logic (3 attempts with backoff)
### 6.4 Data Integrity
- Foreign key constraints enforced
- Soft deletes where appropriate
- Sync never deletes local records (mark inactive if removed from AMS360)
---
## 7. MVP Scope vs. Future Phases
### Phase 1 - MVP
- [ ] Entra ID authentication with group-based roles
- [ ] AMS360 daily sync (Clients + Policies)
- [ ] Client list with custom fields (Shape, Shape2, Primary Personnel)
- [ ] Basic task management (CRUD, assignment, status)
- [ ] Personal dashboard (my tasks, overdue, upcoming)
- [ ] Admin: Shape management, sync trigger
### Phase 2
- [ ] Task templates per department
- [ ] Auto-generation of tasks from templates based on renewal dates
- [ ] Manager dashboard with team metrics
- [ ] Bulk operations (assign, update status)
- [ ] Email notifications for overdue tasks
### Phase 3
- [ ] Advanced reporting / export
- [ ] Calendar view for renewals
- [ ] Additional personnel assignments
- [ ] Custom fields beyond Shape/Shape2
- [ ] API for external integrations
---
## 8. Open Questions / Decisions Needed
1. **AMS360 access:** Who provides credentials? Any data restrictions?
2. **Departments:** Are the listed departments (Personal Lines, Commercial, Claims, Benefits) complete?
3. **Task auto-generation:** Should tasks auto-generate when policy enters renewal window, or manual trigger?
4. **Notifications:** Email? In-app only? Teams integration?
5. **Mobile:** Responsive web sufficient, or native app needed?
6. **Hosting:** On-prem server or cloud (Azure)?
---
## 9. Glossary
| Term | Definition |
|------|------------|
| **AMS360** | Agency Management System by Vertafore - source of client/policy data |
| **Shape/Shape2** | Custom client designations defined by the brokerage |
| **Renewal Date** | Policy expiration date - triggers workflow tasks |
| **Pre-Renewal Task** | Action to be completed before policy expires |
| **Post-Renewal Task** | Action to be completed after policy renews |
| **Primary Personnel** | Main user responsible for a client |
| **Entra ID** | Microsoft's identity platform (formerly Azure AD) |
---
*Document Version: 1.0*
*Created: January 2025*
*Last Updated: January 2025*

38
dev/process-task-list.mdc Normal file
View file

@ -0,0 +1,38 @@
---
description:
globs:
alwaysApply: false
---
# Task List Management
Guidelines for managing task lists in markdown files to track progress on completing a PRD
## Task Implementation
- **One sub-task at a time:** Do **NOT** start the next subtask until you ask the user for permission and they say “yes” or "y"
- **Completion protocol:**
1. When you finish a **subtask**, immediately mark it as completed by changing `[ ]` to `[x]`.
2. If **all** subtasks underneath a parent task are now `[x]`, also mark the **parent task** as completed.
- Stop after each subtask and wait for the users goahead.
## Task List Maintenance
1. **Update the task list as you work:**
- Mark tasks and subtasks as completed (`[x]`) per the protocol above.
- Add new tasks as they emerge.
2. **Maintain the “Relevant Files” section:**
- List every file created or modified.
- Give each file a oneline description of its purpose.
## AI Instructions
When working with task lists, the AI must:
1. Regularly update the task list file after finishing any significant work.
2. Follow the completion protocol:
- Mark each finished **subtask** `[x]`.
- Mark the **parent task** `[x]` once **all** its subtasks are `[x]`.
3. Add newly discovered tasks.
4. Keep “Relevant Files” accurate and up to date.
5. Before starting work, check which subtask is next.
6. After implementing a subtask, update the file and then pause for user approval.

17
dev/shapetasks.csv Normal file
View file

@ -0,0 +1,17 @@
Task,DaysAfterRenewal,DaysPriorToNextRenewal,AppliesTo,Active
SHAPE Onboarding Checklist (required after first renewal or when changing carriers),30,335,Shape,Yes
Claim Review,90,275,Shape,Yes
Review reserves and negotiate adjustments where applicable,120,245,Shape,Yes
Claim Review,180,185,Shape,Yes
Project experience modification factor; send to Account Executive,215,150,Shape,Yes
Request 120 day loss runs,245,120,Shape,Yes
Assist with captive claims worksheet (if applicable),245,120,Shape,Yes
Prepare loss summary/analysis for internal pre-renewal meeting,260,105,Shape,Yes
Request 90 day loss runs,275,90,Shape,Yes
Claim Review (can be included with client pre-renewal meeting if attending),275,90,Shape,Yes
SHAPE Onboarding Checklist (if requested or changing carriers),30,335,Shape2,Yes
Request 120 day loss runs,245,120,Shape2,Yes
Assist with captive claims worksheet (if applicable),245,120,Shape2,Yes
Prepare loss summary/analysis for internal pre-renewal meeting,260,105,Shape2,Yes
Claim Review in conjunction with 120 day loss summary.  Confirm if being marketed.,260,105,Shape2,Yes
Request 90 day loss runs (if being marketed),275,90,Shape2,Yes
1 Task DaysAfterRenewal DaysPriorToNextRenewal AppliesTo Active
2 SHAPE Onboarding Checklist (required after first renewal or when changing carriers) 30 335 Shape Yes
3 Claim Review 90 275 Shape Yes
4 Review reserves and negotiate adjustments where applicable 120 245 Shape Yes
5 Claim Review 180 185 Shape Yes
6 Project experience modification factor; send to Account Executive 215 150 Shape Yes
7 Request 120 day loss runs 245 120 Shape Yes
8 Assist with captive claims worksheet (if applicable) 245 120 Shape Yes
9 Prepare loss summary/analysis for internal pre-renewal meeting 260 105 Shape Yes
10 Request 90 day loss runs 275 90 Shape Yes
11 Claim Review (can be included with client pre-renewal meeting if attending) 275 90 Shape Yes
12 SHAPE Onboarding Checklist (if requested or changing carriers) 30 335 Shape2 Yes
13 Request 120 day loss runs 245 120 Shape2 Yes
14 Assist with captive claims worksheet (if applicable) 245 120 Shape2 Yes
15 Prepare loss summary/analysis for internal pre-renewal meeting 260 105 Shape2 Yes
16 Claim Review in conjunction with 120 day loss summary.  Confirm if being marketed. 260 105 Shape2 Yes
17 Request 90 day loss runs (if being marketed) 275 90 Shape2 Yes

View file

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npx prisma migrate:*)",
"Bash(npm run dev:*)",
"Bash(npx prisma generate:*)",
"Bash(curl:*)",
"Bash(npx tsx:*)",
"Bash(ls:*)"
]
}
}

43
ondeck/.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

56
ondeck/Dockerfile Normal file
View file

@ -0,0 +1,56 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Generate Prisma Client
RUN npx prisma generate
# Build Next.js application
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Set correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
ondeck/README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

22
ondeck/components.json Normal file
View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

53
ondeck/docker-compose.yml Normal file
View file

@ -0,0 +1,53 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://ondeck_user:ondeck_password_2026!@db:5432/ondeck
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AZURE_AD_CLIENT_ID=${AZURE_AD_CLIENT_ID}
- AZURE_AD_CLIENT_SECRET=${AZURE_AD_CLIENT_SECRET}
- AZURE_AD_TENANT_ID=${AZURE_AD_TENANT_ID}
- AFW_SERVER=${AFW_SERVER}
- AFW_DATABASE=${AFW_DATABASE}
- AFW_USER=${AFW_USER}
- AFW_PASSWORD=${AFW_PASSWORD}
depends_on:
- db
restart: unless-stopped
networks:
- ondeck-network
db:
image: postgres:17
environment:
- POSTGRES_DB=ondeck
- POSTGRES_USER=ondeck_user
- POSTGRES_PASSWORD=ondeck_password_2026!
ports:
- "5432:5432"
volumes:
- ondeck-pgdata:/var/lib/postgresql/data
restart: unless-stopped
networks:
- ondeck-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ondeck_user -d ondeck"]
interval: 10s
timeout: 5s
retries: 5
volumes:
ondeck-pgdata:
driver: local
networks:
ondeck-network:
driver: bridge

View file

@ -0,0 +1,150 @@
# Designation Admin Interface - User Guide
## Overview
The Designation Admin Interface allows administrators to create and manage client designations with automatic synchronization from AMS360/AFW.
## Key Concepts
### Designations are Created in OnDeck
**All designations are created and managed within the OnDeck application.** They are NOT imported from AFW. Instead:
1. **Create a designation** in OnDeck (e.g., "Shape", "Premium", "VIP")
2. **Optionally map it to an AFW ANotId** (a GUID that identifies customers in AMS360)
3. **Sync to automatically assign** clients based on their AFW data
### AFW ANotId Mapping
Each designation can have an **optional AFW ANotId** field:
- This is a GUID from AMS360/AFW (e.g., `13CF7DCB-F6AF-42C2-A7AB-26641A216A81`)
- It identifies a group of customers in the AFW database
- When you sync, clients with this ANotId in AFW get assigned to this designation in OnDeck
### Example Workflow
```
1. Admin creates "Shape" designation in OnDeck
- Name: Shape
- Color: Indigo
- AFW ANotId: 13CF7DCB-F6AF-42C2-A7AB-26641A216A81
2. Admin clicks "Preview" to see which AFW customers match
- Shows: 150 customers found in AFW with this ANotId
3. Admin saves the designation
4. Admin clicks "Sync" on the Shape designation
- OnDeck queries AFW for customers with ANotId 13CF7DCB-...
- Finds matching clients in local database
- Updates their designationId to "Shape"
- Result: 145 clients updated, 5 skipped (already had Shape)
5. Admin creates "Premium" designation
- Name: Premium
- Color: Gold
- AFW ANotId: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX (different GUID)
- Syncs separately from Shape
```
## Features
### Create/Edit Designations
- **Name**: Display name (e.g., "Shape", "Premium")
- **Description**: Purpose of the designation
- **Color**: Visual identifier in the UI
- **Display Order**: Sort order in lists
- **AFW ANotId**: Optional GUID for AFW sync
- **Active Status**: Enable/disable without deleting
### Preview Before Saving
- Enter an AFW ANotId
- Click the eye icon to preview
- See how many customers in AFW match
- View sample customer names
- Helps verify you have the correct GUID
### Individual Sync
- Click "Sync" on any designation with an ANotId
- Syncs only that designation
- Shows results: updated, skipped, not found
### Bulk Sync
- Click "Sync All Designations"
- Syncs all active designations that have an ANotId configured
- Shows summary results for each designation
### Uniqueness Constraints
- **Designation names** must be unique
- **AFW ANotId values** must be unique (one designation per ANotId)
- Prevents conflicts and duplicate mappings
## API Endpoints
### GET /api/admin/designations
List all designations with client counts
### POST /api/admin/designations
Create a new designation
### GET /api/admin/designations/[id]
Get a specific designation
### PATCH /api/admin/designations/[id]
Update a designation
### DELETE /api/admin/designations/[id]
Soft delete (deactivate) a designation
### POST /api/admin/designations/preview
Preview AFW customers for an ANotId
### POST /api/admin/sync-designations
Sync a single designation by ID or type
### POST /api/admin/sync-designations/bulk
Sync all active designations with ANotId configured
## Database Schema
```prisma
model Designation {
id String @id @default(cuid())
name String @unique
description String?
color String
rules String?
displayOrder Int
isActive Boolean @default(true)
afwAnotId String? @unique // Maps to AFW ANotId
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clientsDesignation1 Client[] @relation("ClientDesignation1")
clientsDesignation2 Client[] @relation("ClientDesignation2")
taskTemplates TaskTemplate[]
}
```
## Common Questions
**Q: Where do I find the AFW ANotId?**
A: Query the AFW database directly or ask your AMS360 administrator. It's a GUID that identifies customer groups.
**Q: Can I have multiple designations with the same ANotId?**
A: No, each ANotId must be unique to prevent conflicts.
**Q: What happens if I sync without an ANotId?**
A: The sync will fail with an error. ANotId is required for AFW sync.
**Q: Can I create designations without AFW sync?**
A: Yes! Leave the AFW ANotId field empty. You can manually assign clients to these designations.
**Q: What's the difference between Shape and other designations?**
A: Nothing! Shape is just a designation like any other. It happens to map to a specific ANotId in AFW.
**Q: Can I change the ANotId after creating a designation?**
A: Yes, edit the designation and update the ANotId field. Then sync again.
**Q: What happens to clients when I delete a designation?**
A: The designation is deactivated (soft delete). Clients keep their assignment, but the designation won't appear in new assignment lists.

18
ondeck/eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

28
ondeck/jest.config.js Normal file
View file

@ -0,0 +1,28 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}',
'!src/**/__tests__/**',
],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}',
],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

2
ondeck/jest.setup.js Normal file
View file

@ -0,0 +1,2 @@
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom'

9
ondeck/next.config.ts Normal file
View file

@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
output: 'standalone',
};
export default nextConfig;

16600
ondeck/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

70
ondeck/package.json Normal file
View file

@ -0,0 +1,70 @@
{
"name": "ondeck",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev:webpack": "next dev --webpack",
"dev:stop": "pkill -TERM -f 'next-server' ; pkill -TERM -f 'next dev' ; pkill -9 -f 'next-server' 2>/dev/null ; rm -f .next/dev/lock",
"dev:restart": "npm run dev:stop && sleep 2 && npm run dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"mssql": "^12.2.0",
"next": "16.1.4",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"pg": "^8.17.1",
"prisma": "^7.2.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/mssql": "^9.1.9",
"@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.4",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

15
ondeck/prisma.config.ts Normal file
View file

@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View file

@ -0,0 +1,401 @@
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('NOT_STARTED', 'IN_PROGRESS', 'COMPLETED', 'BLOCKED', 'NA', 'CANCELLED');
-- CreateEnum
CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT');
-- CreateEnum
CREATE TYPE "TaskTiming" AS ENUM ('PRE_RENEWAL', 'POST_RENEWAL');
-- CreateEnum
CREATE TYPE "DepartmentType" AS ENUM ('PERSONAL_LINES', 'COMMERCIAL_LINES', 'CLAIMS', 'BENEFITS', 'OTHER');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"entra_oid" TEXT NOT NULL,
"email" TEXT NOT NULL,
"display_name" TEXT,
"department" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"last_login_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"permissions" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"role_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "entra_group_role_mappings" (
"id" TEXT NOT NULL,
"entra_group_id" TEXT NOT NULL,
"role_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "entra_group_role_mappings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shapes" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"color" TEXT NOT NULL,
"rules" TEXT,
"display_order" INTEGER NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shapes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "clients" (
"id" TEXT NOT NULL,
"ams_customer_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address_line1" TEXT,
"address_line2" TEXT,
"city" TEXT,
"state" TEXT,
"zip_code" TEXT,
"phone" TEXT,
"email" TEXT,
"producer_code" TEXT,
"ams_created_at" TIMESTAMP(3),
"ams_modified_at" TIMESTAMP(3),
"shape_id" TEXT,
"shape2_id" TEXT,
"primary_personnel_id" TEXT,
"notes" TEXT,
"custom_fields" JSONB NOT NULL DEFAULT '{}',
"last_synced_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "client_personnel" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"employee_name" TEXT NOT NULL,
"employee_code" TEXT NOT NULL,
"role" TEXT,
"is_primary" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "client_personnel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "policies" (
"id" TEXT NOT NULL,
"ams_policy_id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"policy_number" TEXT,
"policy_type" TEXT,
"effective_date" TIMESTAMP(3),
"expiration_date" TIMESTAMP(3) NOT NULL,
"carrier_name" TEXT,
"premium_amount" DECIMAL(12,2),
"status" TEXT,
"ams_created_at" TIMESTAMP(3),
"ams_modified_at" TIMESTAMP(3),
"last_synced_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"department" "DepartmentType" NOT NULL,
"timing" "TaskTiming" NOT NULL,
"days_offset" INTEGER NOT NULL,
"default_priority" "TaskPriority" NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"display_order" INTEGER,
"created_by" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "task_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tasks" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"department" "DepartmentType" NOT NULL,
"timing" "TaskTiming" NOT NULL,
"days_offset" INTEGER NOT NULL,
"due_date" TIMESTAMP(3) NOT NULL,
"status" "TaskStatus" NOT NULL DEFAULT 'NOT_STARTED',
"priority" "TaskPriority" NOT NULL,
"client_id" TEXT NOT NULL,
"policy_id" TEXT,
"template_id" TEXT,
"created_by" TEXT,
"completed_at" TIMESTAMP(3),
"completed_by" TEXT,
"na_reason" TEXT,
"cancelled_reason" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_assignments" (
"id" TEXT NOT NULL,
"task_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "task_assignments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sync_logs" (
"id" TEXT NOT NULL,
"sync_type" TEXT NOT NULL,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"status" TEXT NOT NULL,
"rows_processed" INTEGER NOT NULL DEFAULT 0,
"rows_inserted" INTEGER NOT NULL DEFAULT 0,
"rows_updated" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT,
"triggered_by" TEXT,
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sync_config" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sync_config_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"action" TEXT NOT NULL,
"entity_type" TEXT NOT NULL,
"entity_id" TEXT,
"old_values" JSONB,
"new_values" JSONB,
"ip_address" TEXT,
"user_agent" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"related_entity_type" TEXT,
"related_entity_id" TEXT,
"is_read" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_preferences" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"notification_type" TEXT NOT NULL,
"in_app" BOOLEAN NOT NULL DEFAULT true,
"email" BOOLEAN NOT NULL DEFAULT false,
"teams" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_entra_oid_key" ON "users"("entra_oid");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_user_id_role_id_key" ON "user_roles"("user_id", "role_id");
-- CreateIndex
CREATE UNIQUE INDEX "entra_group_role_mappings_entra_group_id_role_id_key" ON "entra_group_role_mappings"("entra_group_id", "role_id");
-- CreateIndex
CREATE UNIQUE INDEX "shapes_name_key" ON "shapes"("name");
-- CreateIndex
CREATE UNIQUE INDEX "clients_ams_customer_id_key" ON "clients"("ams_customer_id");
-- CreateIndex
CREATE INDEX "clients_name_idx" ON "clients"("name");
-- CreateIndex
CREATE INDEX "clients_shape_id_idx" ON "clients"("shape_id");
-- CreateIndex
CREATE INDEX "clients_shape2_id_idx" ON "clients"("shape2_id");
-- CreateIndex
CREATE UNIQUE INDEX "client_personnel_client_id_employee_code_key" ON "client_personnel"("client_id", "employee_code");
-- CreateIndex
CREATE UNIQUE INDEX "policies_ams_policy_id_key" ON "policies"("ams_policy_id");
-- CreateIndex
CREATE INDEX "policies_client_id_idx" ON "policies"("client_id");
-- CreateIndex
CREATE INDEX "policies_expiration_date_idx" ON "policies"("expiration_date");
-- CreateIndex
CREATE INDEX "tasks_client_id_idx" ON "tasks"("client_id");
-- CreateIndex
CREATE INDEX "tasks_policy_id_idx" ON "tasks"("policy_id");
-- CreateIndex
CREATE INDEX "tasks_status_idx" ON "tasks"("status");
-- CreateIndex
CREATE INDEX "tasks_due_date_idx" ON "tasks"("due_date");
-- CreateIndex
CREATE INDEX "tasks_department_idx" ON "tasks"("department");
-- CreateIndex
CREATE UNIQUE INDEX "task_assignments_task_id_user_id_key" ON "task_assignments"("task_id", "user_id");
-- CreateIndex
CREATE INDEX "sync_logs_sync_type_idx" ON "sync_logs"("sync_type");
-- CreateIndex
CREATE INDEX "sync_logs_started_at_idx" ON "sync_logs"("started_at");
-- CreateIndex
CREATE UNIQUE INDEX "sync_config_key_key" ON "sync_config"("key");
-- CreateIndex
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
-- CreateIndex
CREATE INDEX "audit_logs_entity_type_idx" ON "audit_logs"("entity_type");
-- CreateIndex
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
-- CreateIndex
CREATE INDEX "notifications_user_id_is_read_idx" ON "notifications"("user_id", "is_read");
-- CreateIndex
CREATE INDEX "notifications_created_at_idx" ON "notifications"("created_at");
-- CreateIndex
CREATE UNIQUE INDEX "notification_preferences_user_id_notification_type_key" ON "notification_preferences"("user_id", "notification_type");
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "entra_group_role_mappings" ADD CONSTRAINT "entra_group_role_mappings_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_shape_id_fkey" FOREIGN KEY ("shape_id") REFERENCES "shapes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_shape2_id_fkey" FOREIGN KEY ("shape2_id") REFERENCES "shapes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "client_personnel" ADD CONSTRAINT "client_personnel_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "policies" ADD CONSTRAINT "policies_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_templates" ADD CONSTRAINT "task_templates_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_policy_id_fkey" FOREIGN KEY ("policy_id") REFERENCES "policies"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "task_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_completed_by_fkey" FOREIGN KEY ("completed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_assignments" ADD CONSTRAINT "task_assignments_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_assignments" ADD CONSTRAINT "task_assignments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sync_logs" ADD CONSTRAINT "sync_logs_triggered_by_fkey" FOREIGN KEY ("triggered_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "entra_oid" DROP NOT NULL;

View file

@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "policies" ADD COLUMN "additional_exec1" TEXT,
ADD COLUMN "additional_exec2" TEXT,
ADD COLUMN "additional_rep1" TEXT,
ADD COLUMN "additional_rep2" TEXT,
ADD COLUMN "bill_method" TEXT,
ADD COLUMN "business_type" TEXT,
ADD COLUMN "csr_name" TEXT,
ADD COLUMN "department" TEXT,
ADD COLUMN "executive_name" TEXT,
ADD COLUMN "writing_company_name" TEXT;
-- CreateIndex
CREATE INDEX "policies_department_idx" ON "policies"("department");

View file

@ -0,0 +1,5 @@
-- DropTable
DROP TABLE "client_personnel";
-- AlterTable: Remove primary_personnel_id from clients
ALTER TABLE "clients" DROP COLUMN IF EXISTS "primary_personnel_id";

View file

@ -0,0 +1,12 @@
-- Rename shapes table to designations
ALTER TABLE "shapes" RENAME TO "designations";
-- Rename foreign key columns in clients table
ALTER TABLE "clients" RENAME COLUMN "shape_id" TO "designation_id";
ALTER TABLE "clients" RENAME COLUMN "shape2_id" TO "designation2_id";
-- Drop old indexes and create new ones
DROP INDEX IF EXISTS "clients_shape_id_idx";
DROP INDEX IF EXISTS "clients_shape2_id_idx";
CREATE INDEX "clients_designation_id_idx" ON "clients"("designation_id");
CREATE INDEX "clients_designation2_id_idx" ON "clients"("designation2_id");

View file

@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "designations" RENAME CONSTRAINT "shapes_pkey" TO "designations_pkey";
-- AlterTable
ALTER TABLE "task_templates" ADD COLUMN "designation_id" TEXT;
-- CreateIndex
CREATE INDEX "task_templates_designation_id_idx" ON "task_templates"("designation_id");
-- RenameForeignKey
ALTER TABLE "clients" RENAME CONSTRAINT "clients_shape2_id_fkey" TO "clients_designation2_id_fkey";
-- RenameForeignKey
ALTER TABLE "clients" RENAME CONSTRAINT "clients_shape_id_fkey" TO "clients_designation_id_fkey";
-- AddForeignKey
ALTER TABLE "task_templates" ADD CONSTRAINT "task_templates_designation_id_fkey" FOREIGN KEY ("designation_id") REFERENCES "designations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "shapes_name_key" RENAME TO "designations_name_key";

View file

@ -0,0 +1,20 @@
-- Remove policies that don't have enriched data
-- Enriched policies have data from AFW lookups: department, executive_name, csr_name, status, etc.
-- Policies without enriched data are missing these key fields
-- First, unlink tasks from policies that will be deleted (set policy_id to NULL)
UPDATE tasks
SET policy_id = NULL
WHERE policy_id IN (
SELECT id FROM policies
WHERE department IS NULL
AND executive_name IS NULL
AND csr_name IS NULL
);
-- Delete policies without enriched data
-- A policy is considered non-enriched if it lacks department AND both personnel fields
DELETE FROM policies
WHERE department IS NULL
AND executive_name IS NULL
AND csr_name IS NULL;

View file

@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[afw_anot_id]` on the table `designations` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "designations" ADD COLUMN "afw_anot_id" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "designations_afw_anot_id_key" ON "designations"("afw_anot_id");

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

363
ondeck/prisma/schema.prisma Normal file
View file

@ -0,0 +1,363 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ============================================
// User Management & Authentication
// ============================================
model User {
id String @id @default(cuid())
entraOid String? @unique @map("entra_oid")
email String @unique
displayName String? @map("display_name")
department String?
isActive Boolean @default(true) @map("is_active")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userRoles UserRole[]
createdTasks Task[] @relation("TaskCreatedBy")
completedTasks Task[] @relation("TaskCompletedBy")
taskAssignments TaskAssignment[]
createdTemplates TaskTemplate[]
syncLogs SyncLog[]
auditLogs AuditLog[]
notifications Notification[]
notificationPrefs NotificationPreference[]
@@map("users")
}
model Role {
id String @id @default(cuid())
name String @unique
description String?
permissions Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
userRoles UserRole[]
entraGroupMappings EntraGroupRoleMapping[]
@@map("roles")
}
model UserRole {
id String @id @default(cuid())
userId String @map("user_id")
roleId String @map("role_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
@@map("user_roles")
}
model EntraGroupRoleMapping {
id String @id @default(cuid())
entraGroupId String @map("entra_group_id")
roleId String @map("role_id")
createdAt DateTime @default(now()) @map("created_at")
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([entraGroupId, roleId])
@@map("entra_group_role_mappings")
}
// ============================================
// Client & Policy Management
// ============================================
model Designation {
id String @id @default(cuid())
name String @unique
description String?
color String
rules String?
displayOrder Int @map("display_order")
isActive Boolean @default(true) @map("is_active")
afwAnotId String? @unique @map("afw_anot_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
clientsDesignation1 Client[] @relation("ClientDesignation1")
clientsDesignation2 Client[] @relation("ClientDesignation2")
taskTemplates TaskTemplate[]
@@map("designations")
}
model Client {
id String @id @default(cuid())
amsCustomerId String @unique @map("ams_customer_id")
name String
addressLine1 String? @map("address_line1")
addressLine2 String? @map("address_line2")
city String?
state String?
zipCode String? @map("zip_code")
phone String?
email String?
producerCode String? @map("producer_code")
amsCreatedAt DateTime? @map("ams_created_at")
amsModifiedAt DateTime? @map("ams_modified_at")
designationId String? @map("designation_id")
designation2Id String? @map("designation2_id")
notes String? @db.Text
customFields Json @default("{}") @map("custom_fields")
lastSyncedAt DateTime? @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
designation Designation? @relation("ClientDesignation1", fields: [designationId], references: [id])
designation2 Designation? @relation("ClientDesignation2", fields: [designation2Id], references: [id])
policies Policy[]
tasks Task[]
@@index([name])
@@index([designationId])
@@index([designation2Id])
@@map("clients")
}
model Policy {
id String @id @default(cuid())
amsPolicyId String @unique @map("ams_policy_id")
clientId String @map("client_id")
policyNumber String? @map("policy_number")
policyType String? @map("policy_type")
effectiveDate DateTime? @map("effective_date")
expirationDate DateTime @map("expiration_date")
carrierName String? @map("carrier_name")
writingCompanyName String? @map("writing_company_name")
premiumAmount Decimal? @map("premium_amount") @db.Decimal(12, 2)
status String?
billMethod String? @map("bill_method")
businessType String? @map("business_type")
department String?
executiveName String? @map("executive_name")
csrName String? @map("csr_name")
additionalRep1 String? @map("additional_rep1")
additionalRep2 String? @map("additional_rep2")
additionalExec1 String? @map("additional_exec1")
additionalExec2 String? @map("additional_exec2")
amsCreatedAt DateTime? @map("ams_created_at")
amsModifiedAt DateTime? @map("ams_modified_at")
lastSyncedAt DateTime? @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
tasks Task[]
@@index([clientId])
@@index([expirationDate])
@@index([department])
@@map("policies")
}
// ============================================
// Task Management
// ============================================
enum TaskStatus {
NOT_STARTED
IN_PROGRESS
COMPLETED
BLOCKED
NA
CANCELLED
}
enum TaskPriority {
LOW
MEDIUM
HIGH
URGENT
}
enum TaskTiming {
PRE_RENEWAL
POST_RENEWAL
}
enum DepartmentType {
PERSONAL_LINES
COMMERCIAL_LINES
CLAIMS
BENEFITS
OTHER
}
model TaskTemplate {
id String @id @default(cuid())
name String
description String? @db.Text
department DepartmentType
timing TaskTiming
daysOffset Int @map("days_offset")
defaultPriority TaskPriority @map("default_priority")
isActive Boolean @default(true) @map("is_active")
displayOrder Int? @map("display_order")
designationId String? @map("designation_id")
createdBy String? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
designation Designation? @relation(fields: [designationId], references: [id])
creator User? @relation(fields: [createdBy], references: [id])
tasks Task[]
@@index([designationId])
@@map("task_templates")
}
model Task {
id String @id @default(cuid())
title String
description String? @db.Text
department DepartmentType
timing TaskTiming
daysOffset Int @map("days_offset")
dueDate DateTime @map("due_date")
status TaskStatus @default(NOT_STARTED)
priority TaskPriority
clientId String @map("client_id")
policyId String? @map("policy_id")
templateId String? @map("template_id")
createdBy String? @map("created_by")
completedAt DateTime? @map("completed_at")
completedBy String? @map("completed_by")
naReason String? @map("na_reason") @db.Text
cancelledReason String? @map("cancelled_reason") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
policy Policy? @relation(fields: [policyId], references: [id])
template TaskTemplate? @relation(fields: [templateId], references: [id])
creator User? @relation("TaskCreatedBy", fields: [createdBy], references: [id])
completer User? @relation("TaskCompletedBy", fields: [completedBy], references: [id])
assignments TaskAssignment[]
@@index([clientId])
@@index([policyId])
@@index([status])
@@index([dueDate])
@@index([department])
@@map("tasks")
}
model TaskAssignment {
id String @id @default(cuid())
taskId String @map("task_id")
userId String @map("user_id")
assignedAt DateTime @default(now()) @map("assigned_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([taskId, userId])
@@map("task_assignments")
}
// ============================================
// Sync & System Management
// ============================================
model SyncLog {
id String @id @default(cuid())
syncType String @map("sync_type")
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
status String
rowsProcessed Int @default(0) @map("rows_processed")
rowsInserted Int @default(0) @map("rows_inserted")
rowsUpdated Int @default(0) @map("rows_updated")
errorMessage String? @map("error_message") @db.Text
triggeredBy String? @map("triggered_by")
user User? @relation(fields: [triggeredBy], references: [id])
@@index([syncType])
@@index([startedAt])
@@map("sync_logs")
}
model SyncConfig {
id String @id @default(cuid())
key String @unique
value String @db.Text
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sync_config")
}
model AuditLog {
id String @id @default(cuid())
userId String? @map("user_id")
action String
entityType String @map("entity_type")
entityId String? @map("entity_id")
oldValues Json? @map("old_values")
newValues Json? @map("new_values")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent") @db.Text
createdAt DateTime @default(now()) @map("created_at")
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([entityType])
@@index([createdAt])
@@map("audit_logs")
}
// ============================================
// Notifications
// ============================================
model Notification {
id String @id @default(cuid())
userId String @map("user_id")
type String
title String
message String @db.Text
relatedEntityType String? @map("related_entity_type")
relatedEntityId String? @map("related_entity_id")
isRead Boolean @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isRead])
@@index([createdAt])
@@map("notifications")
}
model NotificationPreference {
id String @id @default(cuid())
userId String @map("user_id")
notificationType String @map("notification_type")
inApp Boolean @default(true) @map("in_app")
email Boolean @default(false)
teams Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, notificationType])
@@map("notification_preferences")
}

279
ondeck/prisma/seed.ts Normal file
View file

@ -0,0 +1,279 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
import { ROLE_PERMISSIONS } from '../src/lib/auth/permissions'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
async function main() {
console.log('🌱 Starting database seed...')
// Create default roles
console.log('Creating roles...')
const roles = await Promise.all([
prisma.role.upsert({
where: { name: 'Admin' },
update: { permissions: ROLE_PERMISSIONS.Admin },
create: {
name: 'Admin',
description: 'Full system access with all permissions',
permissions: ROLE_PERMISSIONS.Admin,
},
}),
prisma.role.upsert({
where: { name: 'Manager' },
update: { permissions: ROLE_PERMISSIONS.Manager },
create: {
name: 'Manager',
description: 'Management access with task assignment and reporting',
permissions: ROLE_PERMISSIONS.Manager,
},
}),
prisma.role.upsert({
where: { name: 'Account Executive' },
update: { permissions: ROLE_PERMISSIONS['Account Executive'] },
create: {
name: 'Account Executive',
description: 'Client and task management access',
permissions: ROLE_PERMISSIONS['Account Executive'],
},
}),
prisma.role.upsert({
where: { name: 'Claims' },
update: { permissions: ROLE_PERMISSIONS.Claims },
create: {
name: 'Claims',
description: 'Claims department access',
permissions: ROLE_PERMISSIONS.Claims,
},
}),
])
console.log(`✅ Created ${roles.length} roles`)
// Create initial designations
console.log('Creating designations...')
const designations = await Promise.all([
prisma.designation.upsert({
where: { name: 'VIP' },
update: {},
create: {
name: 'VIP',
description: 'High-value clients requiring priority attention',
color: 'designation-purple',
displayOrder: 1,
isActive: true,
},
}),
prisma.designation.upsert({
where: { name: 'Standard' },
update: {},
create: {
name: 'Standard',
description: 'Standard service level clients',
color: 'designation-blue',
displayOrder: 2,
isActive: true,
},
}),
prisma.designation.upsert({
where: { name: 'New Client' },
update: {},
create: {
name: 'New Client',
description: 'Recently onboarded clients',
color: 'designation-green',
displayOrder: 3,
isActive: true,
},
}),
prisma.designation.upsert({
where: { name: 'At Risk' },
update: {},
create: {
name: 'At Risk',
description: 'Clients requiring special attention',
color: 'designation-red',
displayOrder: 4,
isActive: true,
},
}),
prisma.designation.upsert({
where: { name: 'Commercial' },
update: {},
create: {
name: 'Commercial',
description: 'Commercial lines clients',
color: 'designation-orange',
displayOrder: 5,
isActive: true,
},
}),
prisma.designation.upsert({
where: { name: 'Personal' },
update: {},
create: {
name: 'Personal',
description: 'Personal lines clients',
color: 'designation-teal',
displayOrder: 6,
isActive: true,
},
}),
])
console.log(`✅ Created ${designations.length} designations`)
// Create sample task templates
console.log('Creating task templates...')
const templates = await Promise.all([
prisma.taskTemplate.create({
data: {
name: 'Initial Renewal Review',
description: 'Review policy details and prepare renewal documentation',
department: 'PERSONAL_LINES',
timing: 'PRE_RENEWAL',
daysOffset: -60,
defaultPriority: 'MEDIUM',
displayOrder: 1,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Client Contact - Renewal Discussion',
description: 'Contact client to discuss renewal options and coverage needs',
department: 'PERSONAL_LINES',
timing: 'PRE_RENEWAL',
daysOffset: -45,
defaultPriority: 'HIGH',
displayOrder: 2,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Quote Preparation',
description: 'Prepare and submit renewal quotes',
department: 'PERSONAL_LINES',
timing: 'PRE_RENEWAL',
daysOffset: -30,
defaultPriority: 'HIGH',
displayOrder: 3,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Quote Review with Client',
description: 'Present quotes and review options with client',
department: 'PERSONAL_LINES',
timing: 'PRE_RENEWAL',
daysOffset: -21,
defaultPriority: 'HIGH',
displayOrder: 4,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Finalize Renewal',
description: 'Process final renewal paperwork and bind coverage',
department: 'PERSONAL_LINES',
timing: 'PRE_RENEWAL',
daysOffset: -7,
defaultPriority: 'URGENT',
displayOrder: 5,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Post-Renewal Follow-up',
description: 'Confirm client satisfaction and address any questions',
department: 'PERSONAL_LINES',
timing: 'POST_RENEWAL',
daysOffset: 7,
defaultPriority: 'LOW',
displayOrder: 6,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Commercial Lines Review',
description: 'Comprehensive review of commercial policy coverage',
department: 'COMMERCIAL_LINES',
timing: 'PRE_RENEWAL',
daysOffset: -90,
defaultPriority: 'HIGH',
displayOrder: 7,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Loss Run Analysis',
description: 'Review and analyze loss runs for renewal pricing',
department: 'COMMERCIAL_LINES',
timing: 'PRE_RENEWAL',
daysOffset: -75,
defaultPriority: 'MEDIUM',
displayOrder: 8,
isActive: true,
},
}),
prisma.taskTemplate.create({
data: {
name: 'Claims Review',
description: 'Review open and recent claims for policy',
department: 'CLAIMS',
timing: 'PRE_RENEWAL',
daysOffset: -45,
defaultPriority: 'MEDIUM',
displayOrder: 9,
isActive: true,
},
}),
])
console.log(`✅ Created ${templates.length} task templates`)
// Create sync configuration
console.log('Creating sync configuration...')
await prisma.syncConfig.upsert({
where: { key: 'sync_schedule_cron' },
update: { value: '0 2 * * *' },
create: {
key: 'sync_schedule_cron',
value: '0 2 * * *', // Daily at 2 AM
},
})
await prisma.syncConfig.upsert({
where: { key: 'sync_enabled' },
update: { value: 'true' },
create: {
key: 'sync_enabled',
value: 'true',
},
})
await prisma.syncConfig.upsert({
where: { key: 'policy_expiration_months_ahead' },
update: { value: '24' },
create: {
key: 'policy_expiration_months_ahead',
value: '24',
},
})
console.log('✅ Created sync configuration')
console.log('🎉 Seed completed successfully!')
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

1
ondeck/public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
ondeck/public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
ondeck/public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
ondeck/public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
ondeck/public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

13
ondeck/restart-dev.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
echo "Stopping dev server..."
pkill -TERM -f "next-server" 2>/dev/null
pkill -TERM -f "next dev" 2>/dev/null
pkill -TERM -f "webpack-loaders" 2>/dev/null
pkill -TERM -f "postcss.js" 2>/dev/null
sleep 2
# Force kill if still running
pkill -9 -f "next-server" 2>/dev/null
pkill -9 -f "next dev" 2>/dev/null
rm -f .next/dev/lock
echo "Starting dev server..."
npm run dev

View file

@ -0,0 +1,72 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
import { config } from 'dotenv'
config() // Load .env file
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
async function createAdminUser(email: string, displayName: string) {
console.log(`Creating admin user: ${email}`)
// Create or update the user
const user = await prisma.user.upsert({
where: { email },
update: {
displayName,
isActive: true,
},
create: {
email,
displayName,
isActive: true,
},
})
console.log(`✅ User created/updated: ${user.id}`)
// Find the Admin role
const adminRole = await prisma.role.findUnique({
where: { name: 'Admin' },
})
if (!adminRole) {
console.error('❌ Admin role not found. Run seed first: npx prisma db seed')
process.exit(1)
}
// Assign Admin role to user
await prisma.userRole.upsert({
where: {
userId_roleId: {
userId: user.id,
roleId: adminRole.id,
},
},
update: {},
create: {
userId: user.id,
roleId: adminRole.id,
},
})
console.log(`✅ Admin role assigned to ${email}`)
console.log('\n🎉 Admin user setup complete!')
console.log(` Email: ${email}`)
console.log(` User ID: ${user.id}`)
}
const email = process.argv[2] || 'lorentz@wulfconsulting.com'
const displayName = process.argv[3] || 'Lorentz'
createAdminUser(email, displayName)
.catch((e) => {
console.error('❌ Failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
await pool.end()
})

View file

@ -0,0 +1,131 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
import * as fs from 'fs'
import * as path from 'path'
import * as dotenv from 'dotenv'
// Load environment variables
dotenv.config()
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
interface CsvRow {
task: string
daysAfterRenewal: number
daysPriorToNextRenewal: number
appliesTo: string
active: boolean
}
function parseCsv(content: string): CsvRow[] {
const lines = content.trim().split('\n')
// Skip header row, handle BOM
const dataLines = lines.slice(1)
return dataLines.map((line) => {
const parts = line.split(',')
return {
task: parts[0].trim(),
daysAfterRenewal: parseInt(parts[1].trim()),
daysPriorToNextRenewal: parseInt(parts[2].trim()),
appliesTo: parts[3].trim(),
active: parts[4].trim().toLowerCase() === 'yes',
}
})
}
async function main() {
console.log('Importing SHAPE tasks from CSV...')
// Read CSV file
const csvPath = path.join(__dirname, '../../dev/shapetasks.csv')
const csvContent = fs.readFileSync(csvPath, 'utf-8')
const rows = parseCsv(csvContent)
console.log(`Found ${rows.length} tasks to import`)
// Ensure Shape and Shape2 designations exist
const shapeDesignation = await prisma.designation.upsert({
where: { name: 'Shape' },
update: {},
create: {
name: 'Shape',
description: 'SHAPE program - primary designation for claims workflow tasks',
color: 'designation-indigo',
displayOrder: 10,
isActive: true,
},
})
console.log(`Shape designation ID: ${shapeDesignation.id}`)
const shape2Designation = await prisma.designation.upsert({
where: { name: 'Shape2' },
update: {},
create: {
name: 'Shape2',
description: 'SHAPE program - secondary designation for claims workflow tasks',
color: 'designation-violet',
displayOrder: 11,
isActive: true,
},
})
console.log(`Shape2 designation ID: ${shape2Designation.id}`)
// Import task templates
let created = 0
let skipped = 0
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const designationId = row.appliesTo === 'Shape'
? shapeDesignation.id
: shape2Designation.id
// Check if template already exists with same name and designation
const existing = await prisma.taskTemplate.findFirst({
where: {
name: row.task,
designationId: designationId,
},
})
if (existing) {
console.log(`Skipping existing: "${row.task}" (${row.appliesTo})`)
skipped++
continue
}
// Create template using PRE_RENEWAL timing with negative offset
await prisma.taskTemplate.create({
data: {
name: row.task,
description: null,
department: 'CLAIMS',
timing: 'PRE_RENEWAL',
daysOffset: -row.daysPriorToNextRenewal, // Negative for pre-renewal
defaultPriority: 'MEDIUM',
isActive: row.active,
displayOrder: i + 1,
designationId: designationId,
},
})
console.log(`Created: "${row.task}" (${row.appliesTo}, ${row.daysPriorToNextRenewal} days before renewal)`)
created++
}
console.log(`\nImport complete: ${created} created, ${skipped} skipped`)
}
main()
.catch((e) => {
console.error('Import failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
await pool.end()
})

View file

@ -0,0 +1,22 @@
import 'dotenv/config'
import { runSync } from '../src/lib/sync/sync-engine'
async function main() {
console.log('Starting manual sync...')
const result = await runSync(undefined, false) // Full sync, not incremental
if (result.success) {
console.log('\n✅ Sync completed successfully!')
console.log('Stats:', JSON.stringify(result.stats, null, 2))
} else {
console.error('\n❌ Sync failed:', result.error)
console.log('Stats:', JSON.stringify(result.stats, null, 2))
process.exit(1)
}
}
main().catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})

View file

@ -0,0 +1,104 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { DesignationForm } from '@/components/admin/designation-form'
import { DesignationList } from '@/components/admin/designation-list'
interface Designation {
id: string
name: string
description: string | null
color: string
rules: string | null
displayOrder: number
isActive: boolean
afwAnotId: string | null
_count: {
clientsDesignation1: number
clientsDesignation2: number
}
}
export function DesignationsPageClient() {
const [designations, setDesignations] = useState<Designation[]>([])
const [loading, setLoading] = useState(true)
const [formOpen, setFormOpen] = useState(false)
const [editingDesignation, setEditingDesignation] = useState<Designation | undefined>(undefined)
const fetchDesignations = async () => {
try {
const response = await fetch('/api/admin/designations')
if (response.ok) {
const data = await response.json()
setDesignations(data.designations)
}
} catch (error) {
console.error('Failed to fetch designations:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchDesignations()
}, [])
const handleCreate = () => {
setEditingDesignation(undefined)
setFormOpen(true)
}
const handleEdit = (designation: Designation) => {
setEditingDesignation(designation)
setFormOpen(true)
}
const handleSuccess = () => {
fetchDesignations()
}
if (loading) {
return (
<div className="container mx-auto py-8">
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Loading designations...</p>
</div>
</div>
)
}
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Designation Management</h1>
<p className="text-muted-foreground mt-2">
Create client designations and optionally map them to AFW ANotId values for automatic sync.
Each designation can have a unique AFW identifier (e.g., Shape 13CF7DCB-F6AF-42C2-A7AB-26641A216A81).
</p>
</div>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
Create Designation
</Button>
</div>
</div>
<DesignationList
designations={designations}
onEdit={handleEdit}
onRefresh={fetchDesignations}
/>
<DesignationForm
open={formOpen}
onOpenChange={setFormOpen}
designation={editingDesignation}
onSuccess={handleSuccess}
/>
</div>
)
}

View file

@ -0,0 +1,19 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { DesignationsPageClient } from './page-client'
export default async function DesignationsPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
redirect('/dashboard')
}
return <DesignationsPageClient />
}

View file

@ -0,0 +1,104 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Shapes, FileText, Users, Database, Settings } from 'lucide-react'
export default async function AdminPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
redirect('/dashboard')
}
const adminSections = [
{
title: 'Designation Management',
description: 'Manage client designations and categories',
icon: Shapes,
href: '/admin/designations',
color: 'text-purple-600',
bgColor: 'bg-purple-100',
},
{
title: 'Task Templates',
description: 'Configure task templates for policy renewals',
icon: FileText,
href: '/admin/templates',
color: 'text-blue-600',
bgColor: 'bg-blue-100',
},
{
title: 'User Management',
description: 'Manage users and role assignments',
icon: Users,
href: '/admin/users',
color: 'text-green-600',
bgColor: 'bg-green-100',
},
{
title: 'Data Sync',
description: 'Configure and monitor AFW data synchronization',
icon: Database,
href: '/admin/sync',
color: 'text-orange-600',
bgColor: 'bg-orange-100',
},
{
title: 'System Settings',
description: 'Configure system-wide settings and preferences',
icon: Settings,
href: '/admin/settings',
color: 'text-gray-600',
bgColor: 'bg-gray-100',
},
]
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<p className="text-muted-foreground mt-2">
Manage system configuration and settings
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{adminSections.map((section) => {
const Icon = section.icon
return (
<Link key={section.href} href={section.href}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{section.title}</CardTitle>
</div>
<div className={`p-3 rounded-lg ${section.bgColor}`}>
<Icon className={`h-6 w-6 ${section.color}`} />
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{section.description}
</p>
<Button variant="link" className="mt-4 p-0">
Manage
</Button>
</CardContent>
</Card>
</Link>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,168 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Database, Clock, CheckCircle, XCircle } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { SyncTrigger } from '@/components/admin/sync-trigger'
export default async function SyncPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
redirect('/dashboard')
}
// Get sync configuration
const syncConfigs = await prisma.syncConfig.findMany()
const syncEnabled = syncConfigs.find(c => c.key === 'sync_enabled')?.value === 'true'
const syncSchedule = syncConfigs.find(c => c.key === 'sync_schedule_cron')?.value || '0 2 * * *'
// Get recent sync logs
const recentSyncs = await prisma.syncLog.findMany({
take: 10,
orderBy: { startedAt: 'desc' },
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
})
const lastSync = recentSyncs[0]
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Data Sync Management</h1>
<p className="text-muted-foreground mt-2">
Configure and monitor AFW data synchronization
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-6">
{/* Sync Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Sync Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status:</span>
<Badge variant={syncEnabled ? 'default' : 'secondary'}>
{syncEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Schedule:</span>
<span className="text-sm font-medium">{syncSchedule}</span>
</div>
{lastSync && (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last Sync:</span>
<span className="text-sm font-medium">
{formatDate(lastSync.startedAt)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Result:</span>
<Badge variant={lastSync.status === 'completed' ? 'default' : 'destructive'}>
{lastSync.status}
</Badge>
</div>
</>
)}
</CardContent>
</Card>
{/* Manual Sync */}
<SyncTrigger />
</div>
{/* Sync History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Sync History
</CardTitle>
</CardHeader>
<CardContent>
{recentSyncs.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
No sync history available
</p>
) : (
<div className="space-y-4">
{recentSyncs.map((sync) => (
<div
key={sync.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-4">
{sync.status === 'completed' ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : sync.status === 'failed' ? (
<XCircle className="h-5 w-5 text-red-600" />
) : (
<Clock className="h-5 w-5 text-blue-600" />
)}
<div>
<p className="font-medium">
{sync.syncType === 'full' ? 'Full Sync' : 'Incremental Sync'}
</p>
<p className="text-sm text-muted-foreground">
{formatDate(sync.startedAt)}
{sync.user && ` • by ${sync.user.displayName}`}
</p>
</div>
</div>
<div className="text-right">
<Badge variant={sync.status === 'completed' ? 'default' : 'destructive'}>
{sync.status}
</Badge>
{sync.rowsProcessed !== null && (
<p className="text-sm text-muted-foreground mt-1">
{sync.rowsProcessed} rows processed
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Configuration Note */}
<Card className="mt-6 border-blue-200 bg-blue-50">
<CardContent className="pt-6">
<h3 className="font-semibold mb-2">Configuration Required</h3>
<p className="text-sm text-muted-foreground mb-4">
To enable data synchronization, configure the following environment variables:
</p>
<ul className="text-sm space-y-1 text-muted-foreground">
<li> <code className="bg-white px-1 py-0.5 rounded">AFW_SERVER</code> - SQL Server address</li>
<li> <code className="bg-white px-1 py-0.5 rounded">AFW_DATABASE</code> - Database name</li>
<li> <code className="bg-white px-1 py-0.5 rounded">AFW_USER</code> - Database username</li>
<li> <code className="bg-white px-1 py-0.5 rounded">AFW_PASSWORD</code> - Database password</li>
</ul>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,50 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { TaskTemplateManager } from '@/components/admin/task-template-manager'
export default async function TaskTemplatesPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
redirect('/dashboard')
}
const [templates, designations] = await Promise.all([
prisma.taskTemplate.findMany({
orderBy: [{ department: 'asc' }, { displayOrder: 'asc' }, { name: 'asc' }],
include: {
designation: true,
_count: {
select: { tasks: true },
},
},
}),
prisma.designation.findMany({
where: { isActive: true },
orderBy: { displayOrder: 'asc' },
}),
])
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Task Templates</h1>
<p className="text-muted-foreground mt-2">
Manage task templates for policy renewal workflows
</p>
</div>
<TaskTemplateManager
initialTemplates={templates}
designations={designations}
/>
</div>
)
}

View file

@ -0,0 +1,55 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { UserManager } from '@/components/admin/user-manager'
export default async function UsersPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
redirect('/dashboard')
}
const [users, roles] = await Promise.all([
prisma.user.findMany({
orderBy: { displayName: 'asc' },
include: {
userRoles: {
include: {
role: true,
},
},
},
}),
prisma.role.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
description: true,
},
}),
])
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground mt-2">
Manage user accounts and role assignments
</p>
</div>
<UserManager
initialUsers={JSON.parse(JSON.stringify(users))}
roles={roles}
/>
</div>
)
}

View file

@ -0,0 +1,72 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { ClientDetail } from '@/components/clients/client-detail'
// Force dynamic rendering to avoid stale cached data
export const dynamic = 'force-dynamic'
export default async function ClientDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const { id } = await params
const client = await prisma.client.findUnique({
where: { id },
include: {
designation: true,
designation2: true,
policies: {
orderBy: { expirationDate: 'desc' },
},
tasks: {
include: {
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
orderBy: { dueDate: 'asc' },
},
},
})
if (!client) {
redirect('/clients')
}
// Convert Decimal types to numbers for client components
const clientData = {
...client,
policies: client.policies.map(policy => ({
...policy,
premiumAmount: policy.premiumAmount ? Number(policy.premiumAmount) : null,
})),
}
const designations = await prisma.designation.findMany({
where: { isActive: true },
orderBy: { displayOrder: 'asc' },
})
return (
<div className="container mx-auto py-8">
<ClientDetail client={clientData} designations={designations} />
</div>
)
}

View file

@ -0,0 +1,81 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { ClientList } from '@/components/clients/client-list'
export default async function ClientsPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
// Fetch designations for filter
const designations = await prisma.designation.findMany({
where: { isActive: true },
orderBy: { displayOrder: 'asc' },
})
// Fetch initial clients
const clientsRaw = await prisma.client.findMany({
take: 20,
orderBy: { name: 'asc' },
include: {
designation: true,
designation2: true,
policies: {
where: {
expirationDate: {
gte: new Date(),
},
},
orderBy: {
expirationDate: 'asc',
},
take: 1,
select: {
id: true,
policyNumber: true,
expirationDate: true,
policyType: true,
carrierName: true,
writingCompanyName: true,
department: true,
executiveName: true,
csrName: true,
status: true,
premiumAmount: true,
},
},
_count: {
select: {
policies: true,
tasks: true,
},
},
},
})
// Convert Decimal types to numbers for client components
const clients = clientsRaw.map(client => ({
...client,
policies: client.policies.map(policy => ({
...policy,
premiumAmount: policy.premiumAmount ? Number(policy.premiumAmount) : null,
})),
}))
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Clients</h1>
<p className="text-muted-foreground mt-2">
Manage your client portfolio and policy renewals
</p>
</div>
<ClientList initialClients={clients} designations={designations} />
</div>
)
}

View file

@ -0,0 +1,161 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Building2, FileText, CheckSquare, TrendingUp, ArrowUpRight, Calendar } from 'lucide-react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
export default async function DashboardPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
// Fetch real statistics
const [clientCount, policyCount, taskCount] = await Promise.all([
prisma.client.count(),
prisma.policy.count(),
prisma.task.count({ where: { status: { not: 'COMPLETED' } } }),
])
const completedTasks = await prisma.task.count({ where: { status: 'COMPLETED' } })
const totalTasks = await prisma.task.count()
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
return (
<div className="container mx-auto py-8 space-y-8">
{/* Header */}
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
Welcome back, {session.user.name}!
</h1>
<p className="text-muted-foreground text-lg">
Here's an overview of your policy renewal workflow
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="border-l-4 border-l-blue-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Clients</CardTitle>
<Building2 className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{clientCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Active client accounts
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Policies</CardTitle>
<FileText className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{policyCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Policies requiring renewal
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Tasks</CardTitle>
<CheckSquare className="h-5 w-5 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{taskCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Tasks awaiting completion
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion Rate</CardTitle>
<TrendingUp className="h-5 w-5 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{completionRate}%</div>
<p className="text-xs text-muted-foreground mt-1">
Tasks completed on time
</p>
</CardContent>
</Card>
</div>
{/* Quick Actions & Recent Activity */}
<div className="grid gap-6 md:grid-cols-2">
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">
Get started with these common tasks
</p>
<div className="space-y-2">
<Link href="/clients">
<Button variant="outline" className="w-full justify-between group">
View Clients
<ArrowUpRight className="h-4 w-4 group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform" />
</Button>
</Link>
<Link href="/tasks">
<Button variant="outline" className="w-full justify-between group">
My Tasks
<ArrowUpRight className="h-4 w-4 group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform" />
</Button>
</Link>
<Link href="/admin/sync">
<Button variant="outline" className="w-full justify-between group">
Sync AFW Data
<ArrowUpRight className="h-4 w-4 group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform" />
</Button>
</Link>
</div>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle>System Status</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Database</span>
<span className="flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full bg-green-500"></span>
Connected
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">AFW Sync</span>
<span className="flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full bg-green-500"></span>
Ready
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last Sync</span>
<span className="text-sm text-muted-foreground">
{clientCount > 0 ? 'Recently' : 'Never'}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View file

@ -0,0 +1,16 @@
import { NavBar } from '@/components/layout/nav-bar'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20">
<NavBar />
<main className="relative">
{children}
</main>
</div>
)
}

View file

@ -0,0 +1,260 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Users, CheckSquare, TrendingUp, AlertCircle, Clock } from 'lucide-react'
import { WorkloadKPIs } from '@/components/dashboard/workload-kpis'
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
export default async function ManagerPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const userRoles = (session.user as any)?.roles || []
const isManager = userRoles.includes('Manager') || userRoles.includes('Admin')
if (!isManager) {
redirect('/dashboard')
}
// Fetch team statistics
const [totalUsers, activeUsers, totalTasks, completedTasks, overdueTasks] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.task.count(),
prisma.task.count({ where: { status: 'COMPLETED' } }),
prisma.task.count({
where: {
dueDate: { lt: new Date() },
status: { not: 'COMPLETED' }
}
}),
])
// Fetch team members with their task counts
const teamMembers = await prisma.user.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
email: true,
department: true,
taskAssignments: {
where: {
task: {
status: { not: 'COMPLETED' }
}
},
select: {
task: {
select: {
status: true,
priority: true,
dueDate: true,
}
}
}
},
userRoles: {
include: {
role: {
select: {
name: true,
}
}
}
}
},
orderBy: [{ department: 'asc' }, { displayName: 'asc' }],
})
// Fetch recent tasks across the team
const recentTasks = await prisma.task.findMany({
take: 10,
orderBy: { createdAt: 'desc' },
include: {
client: {
select: {
name: true,
}
},
assignments: {
include: {
user: {
select: {
displayName: true,
}
}
}
}
}
})
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
return (
<div className="container mx-auto py-8 space-y-8">
{/* Header */}
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
Team Management
</h1>
<p className="text-muted-foreground text-lg">
Monitor team performance and task assignments
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card className="border-l-4 border-l-blue-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
<Users className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{activeUsers}</div>
<p className="text-xs text-muted-foreground mt-1">
Active users
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
<CheckSquare className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{totalTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
All team tasks
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<TrendingUp className="h-5 w-5 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{completedTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
{completionRate}% completion rate
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-red-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
<AlertCircle className="h-5 w-5 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">{overdueTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
Require attention
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500 hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
<Clock className="h-5 w-5 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{totalTasks - completedTasks}</div>
<p className="text-xs text-muted-foreground mt-1">
Active tasks
</p>
</CardContent>
</Card>
</div>
{/* Team Members & Recent Tasks */}
<div className="grid gap-6 md:grid-cols-2">
{/* Team Members */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team Members
</CardTitle>
</CardHeader>
<CardContent>
<TeamMembersByDepartment teamMembers={teamMembers} />
</CardContent>
</Card>
{/* Recent Tasks */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckSquare className="h-5 w-5" />
Recent Tasks
</CardTitle>
</CardHeader>
<CardContent>
{recentTasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No tasks created yet
</p>
) : (
<div className="space-y-3">
{recentTasks.map((task) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'COMPLETED':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
case 'IN_PROGRESS':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
case 'BLOCKED':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
}
}
return (
<div
key={task.id}
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">{task.title}</h4>
{task.client && (
<p className="text-xs text-muted-foreground mt-1">
{task.client.name}
</p>
)}
</div>
<Badge className={getStatusColor(task.status)}>
{task.status.replace('_', ' ')}
</Badge>
</div>
{task.assignments.length > 0 && (
<p className="text-xs text-muted-foreground mt-2">
Assigned to: {task.assignments.map(a => a.user.displayName).join(', ')}
</p>
)}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Workload KPIs Section */}
<WorkloadKPIs />
</div>
)
}

View file

@ -0,0 +1,62 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { PolicyDetail } from '@/components/policies/policy-detail'
export default async function PolicyDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const { id } = await params
const policy = await prisma.policy.findUnique({
where: { id },
include: {
client: {
include: {
designation: true,
designation2: true,
},
},
tasks: {
include: {
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
orderBy: { dueDate: 'asc' },
},
},
})
if (!policy) {
redirect('/clients')
}
// Convert Decimal types to numbers for client components
const policyData = {
...policy,
premiumAmount: policy.premiumAmount ? Number(policy.premiumAmount) : null,
}
return (
<div className="container mx-auto py-8">
<PolicyDetail policy={policyData} />
</div>
)
}

View file

@ -0,0 +1,237 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { CheckSquare, Clock, AlertCircle } from 'lucide-react'
import { formatDate } from '@/lib/utils'
export default async function TasksPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/signin')
}
const userId = (session.user as any)?.id
// Fetch tasks assigned to the current user
const myTasks = await prisma.task.findMany({
where: {
assignments: {
some: {
userId,
},
},
},
include: {
client: {
select: {
id: true,
name: true,
},
},
policy: {
select: {
id: true,
policyNumber: true,
expirationDate: true,
},
},
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
orderBy: { dueDate: 'asc' },
take: 50,
})
// Calculate task statistics
const now = new Date()
const overdueTasks = myTasks.filter(task => new Date(task.dueDate) < now && task.status !== 'COMPLETED')
const dueTodayTasks = myTasks.filter(task => {
const dueDate = new Date(task.dueDate)
return dueDate.toDateString() === now.toDateString() && task.status !== 'COMPLETED'
})
const upcomingTasks = myTasks.filter(task => {
const dueDate = new Date(task.dueDate)
return dueDate > now && task.status !== 'COMPLETED'
})
const getStatusColor = (status: string) => {
switch (status) {
case 'COMPLETED':
return 'bg-green-100 text-green-800'
case 'IN_PROGRESS':
return 'bg-blue-100 text-blue-800'
case 'NOT_STARTED':
return 'bg-gray-100 text-gray-800'
case 'BLOCKED':
return 'bg-red-100 text-red-800'
case 'CANCELLED':
return 'bg-gray-100 text-gray-500'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'HIGH':
return 'text-red-600'
case 'MEDIUM':
return 'text-yellow-600'
case 'LOW':
return 'text-green-600'
default:
return 'text-gray-600'
}
}
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">My Tasks</h1>
<p className="text-muted-foreground mt-2">
Manage your policy renewal tasks and assignments
</p>
</div>
{/* Task Statistics */}
<div className="grid gap-4 md:grid-cols-3 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
<AlertCircle className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{overdueTasks.length}</div>
<p className="text-xs text-muted-foreground">
Require immediate attention
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Due Today</CardTitle>
<Clock className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{dueTodayTasks.length}</div>
<p className="text-xs text-muted-foreground">
Tasks due today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Upcoming</CardTitle>
<CheckSquare className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{upcomingTasks.length}</div>
<p className="text-xs text-muted-foreground">
Future tasks
</p>
</CardContent>
</Card>
</div>
{/* Task List */}
<Card>
<CardHeader>
<CardTitle>All Tasks</CardTitle>
</CardHeader>
<CardContent>
{myTasks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No tasks assigned</p>
<p className="text-sm mt-2">Tasks will appear here when they are assigned to you</p>
</div>
) : (
<div className="space-y-4">
{myTasks.map((task) => {
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
return (
<div
key={task.id}
className={`p-4 border rounded-lg hover:shadow-md transition-shadow ${
isOverdue ? 'border-red-200 bg-red-50' : ''
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold text-lg">{task.title}</h3>
<Badge className={getStatusColor(task.status)}>
{task.status.replace('_', ' ')}
</Badge>
<span className={`text-sm font-medium ${getPriorityColor(task.priority)}`}>
{task.priority}
</span>
</div>
{task.description && (
<p className="text-sm text-muted-foreground mb-3">
{task.description}
</p>
)}
<div className="flex flex-wrap gap-4 text-sm">
{task.client && (
<div>
<span className="text-muted-foreground">Client:</span>{' '}
<span className="font-medium">{task.client.name}</span>
</div>
)}
{task.policy && (
<div>
<span className="text-muted-foreground">Policy:</span>{' '}
<span className="font-medium">{task.policy.policyNumber}</span>
</div>
)}
<div>
<span className="text-muted-foreground">Due:</span>{' '}
<span className={`font-medium ${isOverdue ? 'text-red-600' : ''}`}>
{formatDate(task.dueDate)}
</span>
</div>
</div>
{task.assignments.length > 0 && (
<div className="mt-3 text-sm">
<span className="text-muted-foreground">Assigned to:</span>{' '}
{task.assignments.map((a, i) => (
<span key={a.id}>
{a.user.displayName}
{i < task.assignments.length - 1 ? ', ' : ''}
</span>
))}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,220 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const designation = await prisma.designation.findUnique({
where: { id },
include: {
_count: {
select: {
clientsDesignation1: true,
clientsDesignation2: true,
},
},
},
})
if (!designation) {
return NextResponse.json({ error: 'Designation not found' }, { status: 404 })
}
return NextResponse.json({ designation })
} catch (error) {
console.error('Get designation error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const existing = await prisma.designation.findUnique({
where: { id },
})
if (!existing) {
return NextResponse.json({ error: 'Designation not found' }, { status: 404 })
}
const body = await request.json()
const { name, description, color, rules, displayOrder, isActive, afwAnotId } = body
// Validate GUID format if afwAnotId is provided
if (afwAnotId) {
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!guidRegex.test(afwAnotId)) {
return NextResponse.json({ error: 'Invalid GUID format for ANotId' }, { status: 400 })
}
// Check if ANotId is already in use by another designation
const existingWithAnotId = await prisma.designation.findFirst({
where: {
afwAnotId,
id: { not: id },
},
})
if (existingWithAnotId) {
return NextResponse.json(
{ error: 'This ANotId is already assigned to another designation' },
{ status: 409 }
)
}
}
// Check if name is already in use by another designation
if (name && name !== existing.name) {
const existingWithName = await prisma.designation.findFirst({
where: {
name,
id: { not: id },
},
})
if (existingWithName) {
return NextResponse.json(
{ error: 'A designation with this name already exists' },
{ status: 409 }
)
}
}
const designation = await prisma.designation.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(description !== undefined && { description }),
...(color !== undefined && { color }),
...(rules !== undefined && { rules }),
...(displayOrder !== undefined && { displayOrder }),
...(isActive !== undefined && { isActive }),
...(afwAnotId !== undefined && { afwAnotId }),
},
})
// Create audit log
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE_DESIGNATION',
entityType: 'Designation',
entityId: designation.id,
oldValues: existing,
newValues: {
name,
description,
color,
rules,
displayOrder,
isActive,
afwAnotId,
},
},
})
return NextResponse.json({ designation })
} catch (error) {
console.error('Update designation error:', error)
return NextResponse.json(
{ error: 'Failed to update designation', details: (error as Error).message },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const existing = await prisma.designation.findUnique({
where: { id },
include: {
_count: {
select: {
clientsDesignation1: true,
clientsDesignation2: true,
},
},
},
})
if (!existing) {
return NextResponse.json({ error: 'Designation not found' }, { status: 404 })
}
const totalClients = existing._count.clientsDesignation1 + existing._count.clientsDesignation2
// Soft delete by deactivating
const designation = await prisma.designation.update({
where: { id },
data: { isActive: false },
})
// Create audit log
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'DELETE_DESIGNATION',
entityType: 'Designation',
entityId: designation.id,
oldValues: existing,
newValues: { isActive: false },
},
})
return NextResponse.json({
designation,
message: totalClients > 0
? `Designation deactivated. ${totalClients} clients still have this designation assigned.`
: 'Designation deactivated successfully.',
})
} catch (error) {
console.error('Delete designation error:', error)
return NextResponse.json(
{ error: 'Failed to delete designation', details: (error as Error).message },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchAfwCustomersByAnotId } from '@/lib/sync/afw-queries'
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { anotId } = body
if (!anotId) {
return NextResponse.json({ error: 'ANotId is required' }, { status: 400 })
}
// Validate GUID format (8-4-4-4-12 hex pattern)
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!guidRegex.test(anotId)) {
return NextResponse.json({ error: 'Invalid GUID format for ANotId' }, { status: 400 })
}
// Fetch matching customers from AFW
const customers = await fetchAfwCustomersByAnotId(anotId)
return NextResponse.json({
success: true,
anotId,
customerCount: customers.length,
customers: customers.slice(0, 50), // Return first 50 for preview
})
} catch (error) {
console.error('Preview designation error:', error)
return NextResponse.json(
{ error: 'Failed to preview designation', details: (error as Error).message },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const designations = await prisma.designation.findMany({
orderBy: { displayOrder: 'asc' },
include: {
_count: {
select: {
clientsDesignation1: true,
clientsDesignation2: true,
},
},
},
})
return NextResponse.json({ designations })
} catch (error) {
console.error('Get designations error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { name, description, color, rules, displayOrder, isActive, afwAnotId } = body
if (!name || !color || displayOrder === undefined) {
return NextResponse.json(
{ error: 'Name, color, and displayOrder are required' },
{ status: 400 }
)
}
// Validate GUID format if afwAnotId is provided
if (afwAnotId) {
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!guidRegex.test(afwAnotId)) {
return NextResponse.json({ error: 'Invalid GUID format for ANotId' }, { status: 400 })
}
// Check if ANotId is already in use
const existingWithAnotId = await prisma.designation.findUnique({
where: { afwAnotId },
})
if (existingWithAnotId) {
return NextResponse.json(
{ error: 'This ANotId is already assigned to another designation' },
{ status: 409 }
)
}
}
// Check if name is already in use
const existingWithName = await prisma.designation.findUnique({
where: { name },
})
if (existingWithName) {
return NextResponse.json(
{ error: 'A designation with this name already exists' },
{ status: 409 }
)
}
const designation = await prisma.designation.create({
data: {
name,
description,
color,
rules,
displayOrder,
isActive: isActive !== undefined ? isActive : true,
afwAnotId,
},
})
// Create audit log
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CREATE_DESIGNATION',
entityType: 'Designation',
entityId: designation.id,
newValues: {
name,
description,
color,
rules,
displayOrder,
isActive,
afwAnotId,
},
},
})
return NextResponse.json({ designation }, { status: 201 })
} catch (error) {
console.error('Create designation error:', error)
return NextResponse.json(
{ error: 'Failed to create designation', details: (error as Error).message },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { fetchAfwCustomersByAnotId } from '@/lib/sync/afw-queries'
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Find all active designations with afwAnotId configured
const designations = await prisma.designation.findMany({
where: {
isActive: true,
afwAnotId: { not: null },
},
orderBy: { displayOrder: 'asc' },
})
if (designations.length === 0) {
return NextResponse.json({
success: true,
message: 'No designations with ANotId configured',
results: [],
})
}
const results = []
let totalUpdated = 0
let totalSkipped = 0
let totalErrors = 0
// Sync each designation sequentially
for (const designation of designations) {
try {
console.log(`Syncing designation: ${designation.name} (${designation.afwAnotId})`)
// Fetch customers from AFW
const afwCustomers = await fetchAfwCustomersByAnotId(designation.afwAnotId!)
const afwCustIds = afwCustomers.map(c => c.CustId)
// Find matching clients in local database
const matchingClients = await prisma.client.findMany({
where: {
amsCustomerId: { in: afwCustIds },
},
select: {
id: true,
amsCustomerId: true,
name: true,
designationId: true,
},
})
let updated = 0
let skipped = 0
// Update clients that don't already have this designation
for (const client of matchingClients) {
if (client.designationId === designation.id) {
skipped++
continue
}
await prisma.client.update({
where: { id: client.id },
data: { designationId: designation.id },
})
updated++
}
totalUpdated += updated
totalSkipped += skipped
results.push({
designationId: designation.id,
designationName: designation.name,
afwCustomersFound: afwCustomers.length,
matchingClients: matchingClients.length,
updated,
skipped,
success: true,
})
} catch (error) {
console.error(`Error syncing designation ${designation.name}:`, error)
totalErrors++
results.push({
designationId: designation.id,
designationName: designation.name,
success: false,
error: (error as Error).message,
})
}
}
// Create audit log
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'BULK_SYNC_DESIGNATIONS',
entityType: 'Designation',
newValues: {
designationsProcessed: designations.length,
totalUpdated,
totalSkipped,
totalErrors,
results,
},
},
})
return NextResponse.json({
success: true,
summary: {
designationsProcessed: designations.length,
totalUpdated,
totalSkipped,
totalErrors,
},
results,
})
} catch (error) {
console.error('Bulk sync designations error:', error)
return NextResponse.json(
{ error: 'Failed to bulk sync designations', details: (error as Error).message },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,195 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions, hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { fetchAfwShapeCustomers, fetchAfwCustomersByAnotId } from '@/lib/sync/afw-queries'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get designation stats
const [shapeDesignation, shape2Designation, clientStats] = await Promise.all([
prisma.designation.findUnique({ where: { name: 'Shape' } }),
prisma.designation.findUnique({ where: { name: 'Shape2' } }),
prisma.client.groupBy({
by: ['designationId'],
_count: { id: true },
}),
])
const shapeCount = clientStats.find(s => s.designationId === shapeDesignation?.id)?._count.id || 0
const shape2Count = clientStats.find(s => s.designationId === shape2Designation?.id)?._count.id || 0
return NextResponse.json({
designations: {
shape: shapeDesignation ? { ...shapeDesignation, clientCount: shapeCount } : null,
shape2: shape2Designation ? { ...shape2Designation, clientCount: shape2Count } : null,
},
})
} catch (error) {
console.error('Get designation stats error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any)?.roles || []
if (!userRoles.includes('Admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { designationType, designationId } = body
let designation
let afwCustomers
// Support both designationId and legacy designationType for backward compatibility
if (designationId) {
// Sync by designation ID (standard approach)
designation = await prisma.designation.findUnique({
where: { id: designationId },
})
if (!designation) {
return NextResponse.json({ error: 'Designation not found' }, { status: 404 })
}
if (!designation.afwAnotId) {
return NextResponse.json(
{ error: 'This designation does not have an ANotId configured' },
{ status: 400 }
)
}
console.log(`Syncing designation: ${designation.name} (${designation.afwAnotId})`)
afwCustomers = await fetchAfwCustomersByAnotId(designation.afwAnotId)
} else if (designationType && ['shape', 'shape2'].includes(designationType)) {
// Backward compatibility: Sync by designation type name
const designationName = designationType === 'shape' ? 'Shape' : 'Shape2'
designation = await prisma.designation.findUnique({
where: { name: designationName },
})
if (!designation) {
designation = await prisma.designation.create({
data: {
name: designationName,
description: `SHAPE program - ${designationType === 'shape' ? 'primary' : 'secondary'} designation for claims workflow tasks`,
color: designationType === 'shape' ? 'designation-indigo' : 'designation-violet',
displayOrder: designationType === 'shape' ? 10 : 11,
isActive: true,
},
})
}
console.log(`Syncing ${designationName} designation from AFW...`)
afwCustomers = await fetchAfwShapeCustomers()
} else {
return NextResponse.json(
{ error: 'Either designationId or designationType (shape/shape2) is required' },
{ status: 400 }
)
}
console.log(`Found ${afwCustomers.length} customers in AFW`)
// Get all CustIds from AFW results
const afwCustIds = afwCustomers.map(c => c.CustId)
// Find matching clients in our database
const matchingClients = await prisma.client.findMany({
where: {
amsCustomerId: { in: afwCustIds },
},
select: {
id: true,
amsCustomerId: true,
name: true,
designationId: true,
designation2Id: true,
},
})
console.log(`Found ${matchingClients.length} matching clients in local database`)
// Determine which field to update
// For new API, always update designationId (primary)
// For old API, use designationType to determine field
const fieldToUpdate = designationType === 'shape2' ? 'designation2Id' : 'designationId'
// Update clients that don't already have this designation
let updated = 0
let skipped = 0
for (const client of matchingClients) {
const currentValue = fieldToUpdate === 'designationId' ? client.designationId : client.designation2Id
if (currentValue === designation.id) {
skipped++
continue
}
await prisma.client.update({
where: { id: client.id },
data: { [fieldToUpdate]: designation.id },
})
updated++
}
// Create audit log
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'SYNC_DESIGNATION',
entityType: 'Client',
newValues: {
designationId: designation.id,
designationName: designation.name,
designationType,
afwCustomersFound: afwCustomers.length,
matchingClients: matchingClients.length,
updated,
skipped,
},
},
})
// Get list of AFW customers not found in local DB
const matchedCustIds = new Set(matchingClients.map(c => c.amsCustomerId))
const notFound = afwCustomers.filter(c => !matchedCustIds.has(c.CustId))
return NextResponse.json({
success: true,
stats: {
afwCustomersFound: afwCustomers.length,
matchingClients: matchingClients.length,
updated,
skipped,
notFoundInLocal: notFound.length,
},
notFoundCustomers: notFound.slice(0, 20), // Return first 20 for debugging
})
} catch (error) {
console.error('Sync designation error:', error)
return NextResponse.json(
{ error: 'Failed to sync designation', details: (error as Error).message },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,6 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View file

@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { hasPermission } from '@/lib/auth'
/**
* GET /api/clients/[id] - Get client details
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'clients.read')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const client = await prisma.client.findUnique({
where: { id },
include: {
designation: true,
designation2: true,
policies: {
orderBy: { expirationDate: 'desc' },
},
tasks: {
include: {
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
orderBy: { dueDate: 'asc' },
},
},
})
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
// RBAC check - non-managers can only view assigned clients
const userRoles = (session.user as any).roles || []
const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager')
if (!isManagerOrAdmin) {
// Check if user is assigned via policy personnel (exec or CSR)
const userName = (session.user as any).displayName || ''
const isAssigned = client.policies.some(
(p: any) =>
p.executiveName?.includes(userName) ||
p.csrName?.includes(userName) ||
p.additionalRep1?.includes(userName) ||
p.additionalRep2?.includes(userName) ||
p.additionalExec1?.includes(userName) ||
p.additionalExec2?.includes(userName)
)
if (!isAssigned) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
return NextResponse.json(client)
} catch (error) {
console.error('Client detail API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* PATCH /api/clients/[id] - Update client custom fields
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'clients.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const { designationId, designation2Id, notes, customFields } = body
const client = await prisma.client.update({
where: { id },
data: {
...(designationId !== undefined && { designationId }),
...(designation2Id !== undefined && { designation2Id }),
...(notes !== undefined && { notes }),
...(customFields !== undefined && { customFields }),
},
include: {
designation: true,
designation2: true,
},
})
// Create audit log
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE',
entityType: 'Client',
entityId: client.id,
newValues: { designationId, designation2Id, notes, customFields },
},
})
return NextResponse.json(client)
} catch (error) {
console.error('Client update API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { hasPermission } from '@/lib/auth'
/**
* GET /api/clients - List clients with pagination and filtering
*/
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'clients.read')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const search = searchParams.get('search') || ''
const designationId = searchParams.get('designationId') || ''
const designation2Id = searchParams.get('designation2Id') || ''
const department = searchParams.get('department') || ''
const sortBy = searchParams.get('sortBy') || 'name'
const sortOrder = searchParams.get('sortOrder') || 'asc'
const skip = (page - 1) * limit
// Build where clause
const where: any = {}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
]
}
if (designationId) {
where.designationId = designationId
}
if (designation2Id) {
where.designation2Id = designation2Id
}
// RBAC filtering - non-managers see only assigned clients
const userRoles = (session.user as any).roles || []
const isManagerOrAdmin = userRoles.includes('Admin') || userRoles.includes('Manager')
if (!isManagerOrAdmin) {
// Filter to clients where user is assigned via policy personnel
where.policies = {
some: {
OR: [
{ executiveName: { contains: (session.user as any).displayName || '' } },
{ csrName: { contains: (session.user as any).displayName || '' } },
],
},
}
}
// Build order by
const orderBy: any = {}
orderBy[sortBy] = sortOrder
const [clients, total] = await Promise.all([
prisma.client.findMany({
where,
skip,
take: limit,
orderBy,
include: {
designation: true,
designation2: true,
policies: {
where: {
expirationDate: {
gte: new Date(),
},
},
orderBy: {
expirationDate: 'asc',
},
take: 1,
},
_count: {
select: {
policies: true,
tasks: true,
},
},
},
}),
prisma.client.count({ where }),
])
return NextResponse.json({
clients,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Clients API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,245 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
// Fetch all data in parallel
const [
// Task counts by status
tasksByStatus,
// Task counts by priority
tasksByPriority,
// Task counts by department
tasksByDepartment,
// Overdue tasks
overdueTasks,
// Tasks due today
dueTodayTasks,
// Tasks due this week
dueThisWeekTasks,
// User workload (tasks per assignee)
userWorkload,
// Recently completed tasks (last 7 days)
recentlyCompleted,
// Tasks created in last 30 days
tasksCreatedLast30Days,
// Tasks completed in last 30 days
tasksCompletedLast30Days,
// Total tasks
totalTasks,
// Active users
activeUsers,
] = await Promise.all([
// Tasks by status
prisma.task.groupBy({
by: ['status'],
_count: { id: true },
}),
// Tasks by priority
prisma.task.groupBy({
by: ['priority'],
_count: { id: true },
}),
// Tasks by department
prisma.task.groupBy({
by: ['department'],
_count: { id: true },
}),
// Overdue tasks (past due, not completed)
prisma.task.count({
where: {
dueDate: { lt: today },
status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] },
},
}),
// Due today
prisma.task.count({
where: {
dueDate: {
gte: today,
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000),
},
status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] },
},
}),
// Due this week
prisma.task.count({
where: {
dueDate: {
gte: today,
lt: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000),
},
status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] },
},
}),
// User workload - tasks assigned per user
prisma.user.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
email: true,
department: true,
taskAssignments: {
include: {
task: {
select: {
id: true,
status: true,
priority: true,
dueDate: true,
},
},
},
},
},
}),
// Recently completed
prisma.task.count({
where: {
status: 'COMPLETED',
completedAt: { gte: weekAgo },
},
}),
// Tasks created last 30 days
prisma.task.count({
where: {
createdAt: { gte: monthAgo },
},
}),
// Tasks completed last 30 days
prisma.task.count({
where: {
status: 'COMPLETED',
completedAt: { gte: monthAgo },
},
}),
// Total tasks
prisma.task.count(),
// Active users with task assignments
prisma.user.count({
where: {
isActive: true,
taskAssignments: { some: {} },
},
}),
])
// Calculate user-level metrics
const userMetrics = userWorkload.map((user) => {
const tasks = user.taskAssignments.map((a) => a.task)
const activeTasks = tasks.filter(
(t) => !['COMPLETED', 'CANCELLED', 'NA'].includes(t.status)
)
const completedTasks = tasks.filter((t) => t.status === 'COMPLETED')
const overdue = activeTasks.filter(
(t) => new Date(t.dueDate) < today
)
const highPriority = activeTasks.filter(
(t) => t.priority === 'HIGH' || t.priority === 'URGENT'
)
return {
id: user.id,
name: user.displayName || user.email,
department: user.department,
totalAssigned: tasks.length,
activeTasks: activeTasks.length,
completedTasks: completedTasks.length,
overdueTasks: overdue.length,
highPriorityTasks: highPriority.length,
completionRate:
tasks.length > 0
? Math.round((completedTasks.length / tasks.length) * 100)
: 0,
}
})
// Sort users by active tasks (highest workload first)
userMetrics.sort((a, b) => b.activeTasks - a.activeTasks)
// Calculate summary metrics
const totalActive = tasksByStatus
.filter((s) => !['COMPLETED', 'CANCELLED', 'NA'].includes(s.status))
.reduce((sum, s) => sum + s._count.id, 0)
const totalCompleted = tasksByStatus.find((s) => s.status === 'COMPLETED')?._count.id || 0
const completionRate = totalTasks > 0 ? Math.round((totalCompleted / totalTasks) * 100) : 0
// Calculate workload distribution
const avgTasksPerUser = activeUsers > 0 ? Math.round(totalActive / activeUsers) : 0
const maxWorkload = Math.max(...userMetrics.map((u) => u.activeTasks), 0)
const minWorkload = Math.min(...userMetrics.filter((u) => u.activeTasks > 0).map((u) => u.activeTasks), 0)
// Format status breakdown
const statusBreakdown = {
notStarted: tasksByStatus.find((s) => s.status === 'NOT_STARTED')?._count.id || 0,
inProgress: tasksByStatus.find((s) => s.status === 'IN_PROGRESS')?._count.id || 0,
completed: totalCompleted,
blocked: tasksByStatus.find((s) => s.status === 'BLOCKED')?._count.id || 0,
na: tasksByStatus.find((s) => s.status === 'NA')?._count.id || 0,
cancelled: tasksByStatus.find((s) => s.status === 'CANCELLED')?._count.id || 0,
}
// Format priority breakdown
const priorityBreakdown = {
low: tasksByPriority.find((p) => p.priority === 'LOW')?._count.id || 0,
medium: tasksByPriority.find((p) => p.priority === 'MEDIUM')?._count.id || 0,
high: tasksByPriority.find((p) => p.priority === 'HIGH')?._count.id || 0,
urgent: tasksByPriority.find((p) => p.priority === 'URGENT')?._count.id || 0,
}
// Format department breakdown
const departmentBreakdown = tasksByDepartment.reduce(
(acc, d) => {
acc[d.department] = d._count.id
return acc
},
{} as Record<string, number>
)
return NextResponse.json({
summary: {
totalTasks,
activeTasks: totalActive,
completedTasks: totalCompleted,
completionRate,
overdueTasks,
dueTodayTasks,
dueThisWeekTasks,
activeUsers,
avgTasksPerUser,
maxWorkload,
minWorkload,
},
trends: {
recentlyCompleted,
tasksCreatedLast30Days,
tasksCompletedLast30Days,
velocity: tasksCompletedLast30Days, // Tasks completed per 30 days
},
breakdowns: {
status: statusBreakdown,
priority: priorityBreakdown,
department: departmentBreakdown,
},
userWorkload: userMetrics,
})
} catch (error) {
console.error('Workload KPI API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { hasPermission } from '@/lib/auth'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const designation = await prisma.designation.findUnique({
where: { id },
include: {
_count: {
select: {
clientsDesignation1: true,
clientsDesignation2: true,
},
},
},
})
if (!designation) {
return NextResponse.json({ error: 'Designation not found' }, { status: 404 })
}
return NextResponse.json(designation)
} catch (error) {
console.error('Designation detail API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'designations.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const designation = await prisma.designation.update({
where: { id },
data: body,
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE',
entityType: 'Designation',
entityId: designation.id,
newValues: body,
},
})
return NextResponse.json(designation)
} catch (error) {
console.error('Update designation API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'designations.delete')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const designation = await prisma.designation.findUnique({
where: { id },
include: {
_count: {
select: {
clientsDesignation1: true,
clientsDesignation2: true,
},
},
},
})
if (!designation) {
return NextResponse.json({ error: 'Designation not found' }, { status: 404 })
}
const inUse = designation._count.clientsDesignation1 > 0 || designation._count.clientsDesignation2 > 0
if (inUse) {
await prisma.designation.update({
where: { id },
data: { isActive: false },
})
return NextResponse.json({ message: 'Designation deactivated (in use)' })
} else {
await prisma.designation.delete({
where: { id },
})
return NextResponse.json({ message: 'Designation deleted' })
}
} catch (error) {
console.error('Delete designation API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { hasPermission } from '@/lib/auth'
/**
* GET /api/designations - List all designations
*/
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const activeOnly = searchParams.get('activeOnly') === 'true'
const designations = await prisma.designation.findMany({
where: activeOnly ? { isActive: true } : undefined,
orderBy: { displayOrder: 'asc' },
})
return NextResponse.json(designations)
} catch (error) {
console.error('Designations API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* POST /api/designations - Create new designation (Admin only)
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'designations.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { name, description, color, rules, displayOrder, isActive } = body
if (!name || !color) {
return NextResponse.json(
{ error: 'Name and color are required' },
{ status: 400 }
)
}
const designation = await prisma.designation.create({
data: {
name,
description,
color,
rules,
displayOrder: displayOrder || 999,
isActive: isActive !== false,
},
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CREATE',
entityType: 'Designation',
entityId: designation.id,
newValues: { name, description, color, rules, displayOrder, isActive },
},
})
return NextResponse.json(designation, { status: 201 })
} catch (error) {
console.error('Create designation API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const roles = await prisma.role.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
description: true,
},
})
return NextResponse.json(roles)
} catch (error) {
console.error('Roles API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { runSync } from '@/lib/sync/sync-engine'
import { hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/sync - Trigger manual sync
* Requires Admin permission
*/
export async function POST(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check permissions
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'sync.trigger')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Parse request body
const body = await request.json().catch(() => ({}))
const isIncremental = body.incremental !== false // Default to incremental
const startDate = body.startDate
const endDate = body.endDate
// Save date range to config if provided
if (startDate) {
await prisma.syncConfig.upsert({
where: { key: 'policy_start_date' },
create: { key: 'policy_start_date', value: startDate },
update: { value: startDate },
})
}
if (endDate) {
await prisma.syncConfig.upsert({
where: { key: 'policy_end_date' },
create: { key: 'policy_end_date', value: endDate },
update: { value: endDate },
})
}
// Trigger sync
const result = await runSync((session.user as any).id, isIncremental)
if (result.success) {
return NextResponse.json({
success: true,
syncLogId: result.syncLogId,
stats: result.stats,
})
} else {
return NextResponse.json(
{
success: false,
error: result.error,
syncLogId: result.syncLogId,
stats: result.stats,
},
{ status: 500 }
)
}
} catch (error) {
console.error('Sync API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* GET /api/sync - Get sync status and history
*/
export async function GET(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check permissions
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'sync.trigger')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get recent sync logs
const { prisma } = await import('@/lib/db')
const recentSyncs = await prisma.syncLog.findMany({
take: 10,
orderBy: { startedAt: 'desc' },
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
})
return NextResponse.json({ syncs: recentSyncs })
} catch (error) {
console.error('Sync status API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasPermission } from '@/lib/auth'
import { getSchedulerStatus } from '@/lib/sync/scheduler'
/**
* GET /api/sync/status - Get sync scheduler status
* Requires sync.trigger permission
*/
export async function GET(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check permissions
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'sync.trigger')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get scheduler status
const status = await getSchedulerStatus()
return NextResponse.json(status)
} catch (error) {
console.error('Sync status API error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { hasPermission } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const clientId = searchParams.get('clientId')
const assignedToMe = searchParams.get('assignedToMe') === 'true'
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
const where: any = {}
if (status) where.status = status
if (clientId) where.clientId = clientId
if (assignedToMe) {
where.assignments = {
some: {
userId: (session.user as any).id,
},
}
}
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
skip: (page - 1) * limit,
take: limit,
include: {
client: {
select: {
id: true,
name: true,
},
},
policy: {
select: {
id: true,
policyNumber: true,
expirationDate: true,
},
},
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
orderBy: { dueDate: 'asc' },
}),
prisma.task.count({ where }),
])
return NextResponse.json({
tasks,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Tasks API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'tasks.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { clientId, policyId, templateId, assignedUserIds, ...taskData } = body
const task = await prisma.task.create({
data: {
...taskData,
clientId,
policyId,
templateId,
createdBy: (session.user as any).id,
assignments: assignedUserIds
? {
create: assignedUserIds.map((userId: string) => ({
userId,
})),
}
: undefined,
},
include: {
assignments: {
include: {
user: {
select: {
displayName: true,
email: true,
},
},
},
},
},
})
return NextResponse.json(task, { status: 201 })
} catch (error) {
console.error('Create task API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { hasPermission } from '@/lib/auth'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const template = await prisma.taskTemplate.findUnique({
where: { id },
include: {
designation: true,
_count: {
select: { tasks: true },
},
},
})
if (!template) {
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
return NextResponse.json(template)
} catch (error) {
console.error('Template detail API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'templates.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const template = await prisma.taskTemplate.update({
where: { id },
data: body,
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE',
entityType: 'TaskTemplate',
entityId: template.id,
newValues: body,
},
})
return NextResponse.json(template)
} catch (error) {
console.error('Update template API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'templates.delete')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const template = await prisma.taskTemplate.findUnique({
where: { id },
include: {
_count: {
select: { tasks: true },
},
},
})
if (!template) {
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
if (template._count.tasks > 0) {
// Soft delete - deactivate if in use
await prisma.taskTemplate.update({
where: { id },
data: { isActive: false },
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'DEACTIVATE',
entityType: 'TaskTemplate',
entityId: id,
},
})
return NextResponse.json({ message: 'Template deactivated (in use by tasks)' })
} else {
// Hard delete if not in use
await prisma.taskTemplate.delete({
where: { id },
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'DELETE',
entityType: 'TaskTemplate',
entityId: id,
},
})
return NextResponse.json({ message: 'Template deleted' })
}
} catch (error) {
console.error('Delete template API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { hasPermission } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const department = searchParams.get('department')
const templates = await prisma.taskTemplate.findMany({
where: {
isActive: true,
...(department && { department: department as any }),
},
orderBy: [{ department: 'asc' }, { displayOrder: 'asc' }],
})
return NextResponse.json(templates)
} catch (error) {
console.error('Templates API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'templates.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const template = await prisma.taskTemplate.create({
data: {
...body,
createdBy: (session.user as any).id,
},
})
return NextResponse.json(template, { status: 201 })
} catch (error) {
console.error('Create template API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,246 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions, hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'users.read')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const user = await prisma.user.findUnique({
where: { id },
include: {
userRoles: {
include: {
role: true,
},
},
},
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json(user)
} catch (error) {
console.error('User detail API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'users.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const { email, displayName, department, isActive, roleIds } = body
// Check if email is being changed and if it conflicts
if (email) {
const existingUser = await prisma.user.findFirst({
where: {
email,
NOT: { id },
},
})
if (existingUser) {
return NextResponse.json(
{ error: 'A user with this email already exists' },
{ status: 400 }
)
}
}
// Get old values for audit
const oldUser = await prisma.user.findUnique({
where: { id },
include: { userRoles: true },
})
if (!oldUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Update user and roles in a transaction
const user = await prisma.$transaction(async (tx) => {
// Update user
const updatedUser = await tx.user.update({
where: { id },
data: {
...(email && { email }),
...(displayName && { displayName }),
...(department !== undefined && { department: department || null }),
...(isActive !== undefined && { isActive }),
},
})
// Update roles if provided
if (roleIds !== undefined) {
// Remove existing roles
await tx.userRole.deleteMany({
where: { userId: id },
})
// Add new roles
if (roleIds.length > 0) {
await tx.userRole.createMany({
data: roleIds.map((roleId: string) => ({
userId: id,
roleId,
})),
})
}
}
// Return updated user with roles
return tx.user.findUnique({
where: { id },
include: {
userRoles: {
include: {
role: true,
},
},
},
})
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'UPDATE',
entityType: 'User',
entityId: id,
oldValues: {
email: oldUser.email,
displayName: oldUser.displayName,
department: oldUser.department,
isActive: oldUser.isActive,
roleIds: oldUser.userRoles.map((ur) => ur.roleId),
},
newValues: { email, displayName, department, isActive, roleIds },
},
})
return NextResponse.json(user)
} catch (error) {
console.error('Update user API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'users.delete')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
// Prevent deleting yourself
if ((session.user as any).id === id) {
return NextResponse.json(
{ error: 'You cannot delete your own account' },
{ status: 400 }
)
}
const user = await prisma.user.findUnique({
where: { id },
include: {
_count: {
select: {
assignedTasks: true,
createdTasks: true,
},
},
},
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// If user has associated data, soft delete by deactivating
if (user._count.assignedTasks > 0 || user._count.createdTasks > 0) {
await prisma.user.update({
where: { id },
data: { isActive: false },
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'DEACTIVATE',
entityType: 'User',
entityId: id,
},
})
return NextResponse.json({ message: 'User deactivated (has associated data)' })
}
// Hard delete if no associated data
await prisma.$transaction(async (tx) => {
// Delete user roles first
await tx.userRole.deleteMany({
where: { userId: id },
})
// Delete user
await tx.user.delete({
where: { id },
})
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'DELETE',
entityType: 'User',
entityId: id,
},
})
return NextResponse.json({ message: 'User deleted' })
} catch (error) {
console.error('Delete user API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions, hasPermission } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'users.read')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const search = searchParams.get('search') || ''
const roleId = searchParams.get('roleId')
const isActive = searchParams.get('isActive')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
const skip = (page - 1) * limit
const where: any = {}
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ displayName: { contains: search, mode: 'insensitive' } },
{ department: { contains: search, mode: 'insensitive' } },
]
}
if (roleId) {
where.userRoles = {
some: { roleId },
}
}
if (isActive !== null && isActive !== undefined && isActive !== '') {
where.isActive = isActive === 'true'
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { displayName: 'asc' },
include: {
userRoles: {
include: {
role: true,
},
},
},
}),
prisma.user.count({ where }),
])
return NextResponse.json({
users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Users API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermissions = (session.user as any).permissions || {}
if (!hasPermission(userPermissions, 'users.write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { email, displayName, department, isActive, roleIds } = body
if (!email || !displayName) {
return NextResponse.json(
{ error: 'Email and display name are required' },
{ status: 400 }
)
}
// Check if email already exists
const existingUser = await prisma.user.findUnique({
where: { email },
})
if (existingUser) {
return NextResponse.json(
{ error: 'A user with this email already exists' },
{ status: 400 }
)
}
const user = await prisma.user.create({
data: {
email,
displayName,
department: department || null,
isActive: isActive ?? true,
userRoles: roleIds?.length
? {
create: roleIds.map((roleId: string) => ({ roleId })),
}
: undefined,
},
include: {
userRoles: {
include: {
role: true,
},
},
},
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
newValues: { email, displayName, department, roleIds },
},
})
return NextResponse.json(user, { status: 201 })
} catch (error) {
console.error('Create user API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,36 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { AlertCircle } from 'lucide-react'
export default function AuthErrorPage({
searchParams,
}: {
searchParams: { error?: string }
}) {
const error = searchParams.error
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-md w-full">
<div className="bg-white shadow-xl rounded-lg p-8">
<div className="text-center mb-8">
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900">Authentication Error</h1>
<p className="text-gray-600 mt-2">
{error === 'Configuration' && 'There is a problem with the server configuration.'}
{error === 'AccessDenied' && 'You do not have permission to sign in.'}
{error === 'Verification' && 'The sign in link is no longer valid.'}
{!error && 'An error occurred during authentication.'}
</p>
</div>
<Link href="/auth/signin">
<Button className="w-full">
Try Again
</Button>
</Link>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,22 @@
import { SignInForm } from '@/components/auth/signin-form'
export default function SignInPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-md w-full">
<div className="bg-white shadow-xl rounded-lg p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">OnDeck</h1>
<p className="text-gray-600 mt-2">Policy Renewal Workflow Management</p>
</div>
<SignInForm />
</div>
<p className="text-center text-sm text-gray-600 mt-4">
Sign in with your local account or Microsoft account
</p>
</div>
</div>
)
}

BIN
ondeck/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

153
ondeck/src/app/globals.css Normal file
View file

@ -0,0 +1,153 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-shape-blue: var(--shape-blue);
--color-shape-green: var(--shape-green);
--color-shape-yellow: var(--shape-yellow);
--color-shape-red: var(--shape-red);
--color-shape-purple: var(--shape-purple);
--color-shape-orange: var(--shape-orange);
--color-shape-teal: var(--shape-teal);
--color-shape-pink: var(--shape-pink);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
/* Shape designation colors */
--shape-blue: oklch(0.6 0.15 250);
--shape-green: oklch(0.65 0.15 140);
--shape-yellow: oklch(0.75 0.15 90);
--shape-red: oklch(0.6 0.2 25);
--shape-purple: oklch(0.6 0.15 300);
--shape-orange: oklch(0.7 0.18 50);
--shape-teal: oklch(0.6 0.15 180);
--shape-pink: oklch(0.7 0.18 350);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
/* Shape designation colors - dark mode */
--shape-blue: oklch(0.65 0.18 250);
--shape-green: oklch(0.7 0.18 140);
--shape-yellow: oklch(0.8 0.18 90);
--shape-red: oklch(0.65 0.22 25);
--shape-purple: oklch(0.65 0.18 300);
--shape-orange: oklch(0.75 0.2 50);
--shape-teal: oklch(0.65 0.18 180);
--shape-pink: oklch(0.75 0.2 350);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

45
ondeck/src/app/layout.tsx Normal file
View file

@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { SessionProvider } from "@/components/providers/session-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "OnDeck - Policy Renewal Workflow Management",
description: "Streamline your insurance policy renewal workflow with OnDeck",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider>
{children}
</SessionProvider>
</ThemeProvider>
</body>
</html>
);
}

5
ondeck/src/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/dashboard')
}

View file

@ -0,0 +1,306 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Eye, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
interface DesignationFormProps {
open: boolean
onOpenChange: (open: boolean) => void
designation?: {
id: string
name: string
description: string | null
color: string
rules: string | null
displayOrder: number
isActive: boolean
afwAnotId: string | null
}
onSuccess: () => void
}
interface PreviewCustomer {
CustId: string
CustNo: number
FirmNameCust: string
}
const COLOR_OPTIONS = [
{ value: 'designation-blue', label: 'Blue', class: 'bg-blue-500' },
{ value: 'designation-indigo', label: 'Indigo', class: 'bg-indigo-500' },
{ value: 'designation-violet', label: 'Violet', class: 'bg-violet-500' },
{ value: 'designation-purple', label: 'Purple', class: 'bg-purple-500' },
{ value: 'designation-fuchsia', label: 'Fuchsia', class: 'bg-fuchsia-500' },
{ value: 'designation-pink', label: 'Pink', class: 'bg-pink-500' },
{ value: 'designation-rose', label: 'Rose', class: 'bg-rose-500' },
{ value: 'designation-red', label: 'Red', class: 'bg-red-500' },
{ value: 'designation-orange', label: 'Orange', class: 'bg-orange-500' },
{ value: 'designation-amber', label: 'Amber', class: 'bg-amber-500' },
{ value: 'designation-yellow', label: 'Yellow', class: 'bg-yellow-500' },
{ value: 'designation-lime', label: 'Lime', class: 'bg-lime-500' },
{ value: 'designation-green', label: 'Green', class: 'bg-green-500' },
{ value: 'designation-emerald', label: 'Emerald', class: 'bg-emerald-500' },
{ value: 'designation-teal', label: 'Teal', class: 'bg-teal-500' },
{ value: 'designation-cyan', label: 'Cyan', class: 'bg-cyan-500' },
{ value: 'designation-sky', label: 'Sky', class: 'bg-sky-500' },
{ value: 'designation-slate', label: 'Slate', class: 'bg-slate-500' },
{ value: 'designation-gray', label: 'Gray', class: 'bg-gray-500' },
{ value: 'designation-zinc', label: 'Zinc', class: 'bg-zinc-500' },
]
export function DesignationForm({ open, onOpenChange, designation, onSuccess }: DesignationFormProps) {
const isEdit = !!designation
const [loading, setLoading] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [previewData, setPreviewData] = useState<{ count: number; customers: PreviewCustomer[] } | null>(null)
const [formData, setFormData] = useState({
name: designation?.name || '',
description: designation?.description || '',
color: designation?.color || 'designation-blue',
rules: designation?.rules || '',
displayOrder: designation?.displayOrder || 0,
isActive: designation?.isActive ?? true,
afwAnotId: designation?.afwAnotId || '',
})
const handlePreview = async () => {
if (!formData.afwAnotId) {
toast.error('Please enter an ANotId to preview')
return
}
setPreviewing(true)
setPreviewData(null)
try {
const response = await fetch('/api/admin/designations/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ anotId: formData.afwAnotId }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Preview failed')
}
setPreviewData({
count: data.customerCount,
customers: data.customers,
})
toast.success(`Found ${data.customerCount} matching customers in AFW`)
} catch (error) {
toast.error(`Preview failed: ${(error as Error).message}`)
} finally {
setPreviewing(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const url = isEdit
? `/api/admin/designations/${designation.id}`
: '/api/admin/designations'
const response = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
afwAnotId: formData.afwAnotId || null,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to save designation')
}
toast.success(isEdit ? 'Designation updated successfully' : 'Designation created successfully')
onSuccess()
onOpenChange(false)
} catch (error) {
toast.error((error as Error).message)
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Designation' : 'Create New Designation'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the designation details. Add an AFW ANotId to enable automatic client sync from AMS360.'
: 'Create a designation to categorize clients. Add an AFW ANotId (e.g., 13CF7DCB-...) to enable automatic sync from AMS360.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Premium, VIP, Shape3"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe the purpose of this designation"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color *</Label>
<div className="grid grid-cols-5 gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setFormData({ ...formData, color: color.value })}
className={`flex items-center gap-2 p-2 rounded border-2 transition-all ${
formData.color === color.value
? 'border-primary ring-2 ring-primary/20'
: 'border-transparent hover:border-gray-300'
}`}
>
<div className={`w-4 h-4 rounded ${color.class}`} />
<span className="text-xs">{color.label}</span>
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rules">Rules/Criteria</Label>
<Textarea
id="rules"
value={formData.rules}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData({ ...formData, rules: e.target.value })}
placeholder="Describe the criteria for assigning this designation"
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="displayOrder">Display Order *</Label>
<Input
id="displayOrder"
type="number"
value={formData.displayOrder}
onChange={(e) => setFormData({ ...formData, displayOrder: parseInt(e.target.value) || 0 })}
required
/>
<p className="text-xs text-muted-foreground">Lower numbers appear first in lists</p>
</div>
<div className="space-y-2">
<Label htmlFor="afwAnotId">AFW ANotId (Optional)</Label>
<div className="flex gap-2">
<Input
id="afwAnotId"
value={formData.afwAnotId}
onChange={(e) => {
setFormData({ ...formData, afwAnotId: e.target.value })
setPreviewData(null)
}}
placeholder="13CF7DCB-F6AF-42C2-A7AB-26641A216A81"
pattern="[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
/>
<Button
type="button"
variant="outline"
onClick={handlePreview}
disabled={!formData.afwAnotId || previewing}
>
{previewing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Enter the ANotId from AMS360/AFW (e.g., the GUID that identifies Shape customers in AFW).
Click the eye icon to preview which customers will be synced.
</p>
</div>
{previewData && (
<div className="p-4 border rounded-lg bg-muted/50">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold">Preview Results</h4>
<Badge variant="default">{previewData.count} customers</Badge>
</div>
{previewData.customers.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1">
{previewData.customers.map((customer) => (
<div key={customer.CustId} className="text-sm flex justify-between">
<span>{customer.FirmNameCust}</span>
<span className="text-muted-foreground">#{customer.CustNo}</span>
</div>
))}
{previewData.count > previewData.customers.length && (
<p className="text-xs text-muted-foreground italic">
...and {previewData.count - previewData.customers.length} more
</p>
)}
</div>
)}
</div>
)}
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked: boolean) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive">Active</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>{isEdit ? 'Update' : 'Create'} Designation</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,332 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { RefreshCw, Edit, Trash2, Users, Database } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
interface Designation {
id: string
name: string
description: string | null
color: string
rules: string | null
displayOrder: number
isActive: boolean
afwAnotId: string | null
_count: {
clientsDesignation1: number
clientsDesignation2: number
}
}
interface DesignationListProps {
designations: Designation[]
onEdit: (designation: Designation) => void
onRefresh: () => void
}
interface BulkSyncResult {
designationId: string
designationName: string
afwCustomersFound?: number
matchingClients?: number
updated?: number
skipped?: number
success: boolean
error?: string
}
export function DesignationList({ designations, onEdit, onRefresh }: DesignationListProps) {
const [syncing, setSyncing] = useState<string | null>(null)
const [bulkSyncing, setBulkSyncing] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [designationToDelete, setDesignationToDelete] = useState<Designation | null>(null)
const [bulkResults, setBulkResults] = useState<BulkSyncResult[] | null>(null)
const handleSync = async (designation: Designation) => {
if (!designation.afwAnotId) {
toast.error('This designation does not have an ANotId configured')
return
}
setSyncing(designation.id)
try {
const response = await fetch('/api/admin/sync-designations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ designationId: designation.id }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Sync failed')
}
toast.success(`Synced ${designation.name}: ${data.stats.updated} clients updated`)
onRefresh()
} catch (error) {
toast.error(`Sync failed: ${(error as Error).message}`)
} finally {
setSyncing(null)
}
}
const handleBulkSync = async () => {
setBulkSyncing(true)
setBulkResults(null)
try {
const response = await fetch('/api/admin/sync-designations/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Bulk sync failed')
}
setBulkResults(data.results)
toast.success(
`Bulk sync completed: ${data.summary.totalUpdated} clients updated across ${data.summary.designationsProcessed} designations`
)
onRefresh()
} catch (error) {
toast.error(`Bulk sync failed: ${(error as Error).message}`)
} finally {
setBulkSyncing(false)
}
}
const handleDelete = async () => {
if (!designationToDelete) return
try {
const response = await fetch(`/api/admin/designations/${designationToDelete.id}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete designation')
}
toast.success(data.message || 'Designation deactivated successfully')
onRefresh()
} catch (error) {
toast.error((error as Error).message)
} finally {
setDeleteDialogOpen(false)
setDesignationToDelete(null)
}
}
const syncableDesignations = designations.filter(d => d.isActive && d.afwAnotId)
return (
<div className="space-y-6">
{/* Bulk Sync Section */}
{syncableDesignations.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Bulk Sync
</CardTitle>
<Button
onClick={handleBulkSync}
disabled={bulkSyncing || syncing !== null}
>
{bulkSyncing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Syncing All...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Sync All Designations
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Sync all {syncableDesignations.length} active designation(s) with configured ANotId from AFW
</p>
{bulkResults && (
<div className="space-y-2">
<h4 className="font-semibold text-sm">Last Bulk Sync Results:</h4>
{bulkResults.map((result) => (
<div
key={result.designationId}
className={`p-3 rounded border ${
result.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">{result.designationName}</span>
{result.success ? (
<Badge variant="default" className="bg-green-600">
{result.updated} updated, {result.skipped} skipped
</Badge>
) : (
<Badge variant="destructive">Failed</Badge>
)}
</div>
{result.error && (
<p className="text-xs text-red-600 mt-1">{result.error}</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Designations Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{designations.map((designation) => {
const totalClients = designation._count.clientsDesignation1 + designation._count.clientsDesignation2
const isSyncing = syncing === designation.id
return (
<Card key={designation.id} className={!designation.isActive ? 'opacity-60' : ''}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded bg-${designation.color}`} />
<CardTitle className="text-lg">{designation.name}</CardTitle>
</div>
<Badge variant={designation.isActive ? 'default' : 'secondary'}>
{designation.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{designation.description && (
<p className="text-sm text-muted-foreground">{designation.description}</p>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
<Users className="h-4 w-4" />
Clients:
</span>
<span className="font-medium">{totalClients}</span>
</div>
{designation.afwAnotId && (
<div className="text-xs">
<span className="text-muted-foreground">ANotId: </span>
<code className="bg-muted px-1 py-0.5 rounded text-xs">
{designation.afwAnotId.substring(0, 8)}...
</code>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Display Order:</span>
<span className="font-medium">{designation.displayOrder}</span>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(designation)}
className="flex-1"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
{designation.afwAnotId && designation.isActive && (
<Button
variant="default"
size="sm"
onClick={() => handleSync(designation)}
disabled={isSyncing || bulkSyncing}
className="flex-1"
>
{isSyncing ? (
<>
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Sync
</>
)}
</Button>
)}
{designation.isActive && (
<Button
variant="destructive"
size="sm"
onClick={() => {
setDesignationToDelete(designation)
setDeleteDialogOpen(true)
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate Designation</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to deactivate &quot;{designationToDelete?.name}&quot;?
{designationToDelete && (
<>
{' '}
This designation is currently assigned to{' '}
{designationToDelete._count.clientsDesignation1 +
designationToDelete._count.clientsDesignation2}{' '}
client(s). The designation will be deactivated but existing assignments will remain.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Deactivate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -0,0 +1,250 @@
'use client'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { RefreshCw, Database, Users, CheckCircle, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface DesignationInfo {
id: string
name: string
clientCount: number
}
interface SyncStats {
afwCustomersFound: number
matchingClients: number
updated: number
skipped: number
notFoundInLocal: number
}
interface NotFoundCustomer {
CustId: string
CustNo: number
FirmNameCust: string
}
export function ShapeDesignationSync() {
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState<string | null>(null)
const [designations, setDesignations] = useState<{
shape: DesignationInfo | null
shape2: DesignationInfo | null
}>({ shape: null, shape2: null })
const [lastSyncStats, setLastSyncStats] = useState<SyncStats | null>(null)
const [notFoundCustomers, setNotFoundCustomers] = useState<NotFoundCustomer[]>([])
useEffect(() => {
fetchDesignationStats()
}, [])
const fetchDesignationStats = async () => {
setLoading(true)
try {
const response = await fetch('/api/admin/sync-designations')
if (response.ok) {
const data = await response.json()
setDesignations(data.designations)
}
} catch (error) {
console.error('Failed to fetch designation stats:', error)
} finally {
setLoading(false)
}
}
const handleSync = async (designationType: 'shape' | 'shape2') => {
setSyncing(designationType)
setLastSyncStats(null)
setNotFoundCustomers([])
try {
const response = await fetch('/api/admin/sync-designations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ designationType }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Sync failed')
}
setLastSyncStats(data.stats)
setNotFoundCustomers(data.notFoundCustomers || [])
toast.success(
`Synced ${designationType === 'shape' ? 'Shape' : 'Shape2'}: ${data.stats.updated} clients updated`
)
fetchDesignationStats()
} catch (error) {
toast.error(`Sync failed: ${(error as Error).message}`)
} finally {
setSyncing(null)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
SHAPE Designation Sync
</CardTitle>
<CardDescription>
Sync Shape designations from AFW to local clients using the ANotId identifier
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Designation Cards */}
<div className="grid gap-4 md:grid-cols-2">
{/* Shape Card */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Shape (Primary)</CardTitle>
{designations.shape ? (
<Badge variant="default">Active</Badge>
) : (
<Badge variant="secondary">Not Created</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span>
{designations.shape?.clientCount || 0} clients assigned
</span>
</div>
</div>
<Button
onClick={() => handleSync('shape')}
disabled={syncing !== null}
className="w-full"
>
{syncing === 'shape' ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Sync from AFW
</>
)}
</Button>
</CardContent>
</Card>
{/* Shape2 Card */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Shape2 (Secondary)</CardTitle>
{designations.shape2 ? (
<Badge variant="default">Active</Badge>
) : (
<Badge variant="secondary">Not Created</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span>
{designations.shape2?.clientCount || 0} clients assigned
</span>
</div>
</div>
<Button
onClick={() => handleSync('shape2')}
disabled={syncing !== null}
variant="outline"
className="w-full"
>
{syncing === 'shape2' ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Sync from AFW
</>
)}
</Button>
</CardContent>
</Card>
</div>
{/* Sync Results */}
{lastSyncStats && (
<Card className="bg-muted/50">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-600" />
Last Sync Results
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div>
<div className="text-muted-foreground">AFW Customers</div>
<div className="text-xl font-semibold">{lastSyncStats.afwCustomersFound}</div>
</div>
<div>
<div className="text-muted-foreground">Matched Locally</div>
<div className="text-xl font-semibold">{lastSyncStats.matchingClients}</div>
</div>
<div>
<div className="text-muted-foreground">Updated</div>
<div className="text-xl font-semibold text-green-600">{lastSyncStats.updated}</div>
</div>
<div>
<div className="text-muted-foreground">Already Set</div>
<div className="text-xl font-semibold text-muted-foreground">{lastSyncStats.skipped}</div>
</div>
<div>
<div className="text-muted-foreground">Not in Local DB</div>
<div className="text-xl font-semibold text-orange-600">{lastSyncStats.notFoundInLocal}</div>
</div>
</div>
{notFoundCustomers.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center gap-2 text-sm text-orange-600 mb-2">
<AlertCircle className="h-4 w-4" />
<span>Customers in AFW but not synced locally ({notFoundCustomers.length}):</span>
</div>
<div className="text-xs text-muted-foreground space-y-1 max-h-32 overflow-y-auto">
{notFoundCustomers.map((c) => (
<div key={c.CustId} className="flex justify-between">
<span>{c.FirmNameCust}</span>
<span className="text-muted-foreground">#{c.CustNo}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
<p className="text-xs text-muted-foreground">
This queries AFW for customers with ANotId = &apos;13CF7DCB-F6AF-42C2-A7AB-26641A216A81&apos;
and applies the selected designation to matching clients in the local database.
</p>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,176 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PlayCircle, Loader2, CheckCircle, XCircle, Calendar } from 'lucide-react'
export function SyncTrigger() {
const [syncing, setSyncing] = useState(false)
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle')
const [result, setResult] = useState<any>(null)
const [startDate, setStartDate] = useState('2026-01-01')
const [endDate, setEndDate] = useState('2026-12-31')
const handleSync = async () => {
setSyncing(true)
setStatus('syncing')
setProgress(0)
setResult(null)
// Simulate progress updates
const progressInterval = setInterval(() => {
setProgress(prev => {
if (prev >= 90) return prev
return prev + 10
})
}, 1000)
try {
const response = await fetch('/api/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate,
endDate,
}),
})
const data = await response.json()
clearInterval(progressInterval)
setProgress(100)
if (data.success) {
setStatus('success')
setResult(data)
setTimeout(() => {
window.location.reload()
}, 2000)
} else {
setStatus('error')
setResult(data)
}
} catch (error) {
clearInterval(progressInterval)
setStatus('error')
setResult({ error: error instanceof Error ? error.message : 'Unknown error' })
} finally {
setSyncing(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PlayCircle className="h-5 w-5" />
Manual Sync
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Trigger a manual data synchronization from the AFW database.
</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="startDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Policy Start Date
</Label>
<Input
id="startDate"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={syncing}
/>
</div>
<div className="space-y-2">
<Label htmlFor="endDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Policy End Date
</Label>
<Input
id="endDate"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={syncing}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Only policies expiring between these dates will be synced
</p>
{status === 'syncing' && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-blue-600">
<Loader2 className="h-4 w-4 animate-spin" />
Syncing data from AFW...
</div>
<Progress value={progress} className="h-2" />
<p className="text-xs text-muted-foreground">
This may take a minute depending on data volume
</p>
</div>
)}
{status === 'success' && result && (
<div className="space-y-2 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-700 font-medium">
<CheckCircle className="h-5 w-5" />
Sync completed successfully!
</div>
<div className="text-sm text-green-600 space-y-1">
<p> Customers: {result.stats.customersInserted} inserted, {result.stats.customersUpdated} updated</p>
<p> Policies: {result.stats.policiesInserted} inserted, {result.stats.policiesUpdated} updated</p>
<p> Personnel: {result.stats.personnelInserted} inserted, {result.stats.personnelUpdated} updated</p>
</div>
<p className="text-xs text-green-600 mt-2">Refreshing page...</p>
</div>
)}
{status === 'error' && result && (
<div className="space-y-2 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 text-red-700 font-medium">
<XCircle className="h-5 w-5" />
Sync failed
</div>
<p className="text-sm text-red-600">{result.error}</p>
</div>
)}
<Button
onClick={handleSync}
disabled={syncing}
className="w-full"
>
{syncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Syncing...
</>
) : (
<>
<PlayCircle className="mr-2 h-4 w-4" />
Run Sync Now
</>
)}
</Button>
<p className="text-xs text-muted-foreground">
Note: Configure AFW database credentials in environment variables before running sync.
</p>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,639 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Plus,
Search,
Pencil,
Trash2,
Clock,
Calendar,
Building2,
Tag,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Label } from '@/components/ui/label'
type DepartmentType = 'PERSONAL_LINES' | 'COMMERCIAL_LINES' | 'CLAIMS' | 'BENEFITS' | 'OTHER'
type TaskTiming = 'PRE_RENEWAL' | 'POST_RENEWAL'
type TaskPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'
interface Designation {
id: string
name: string
color: string
}
interface TaskTemplate {
id: string
name: string
description: string | null
department: DepartmentType
timing: TaskTiming
daysOffset: number
defaultPriority: TaskPriority
isActive: boolean
displayOrder: number | null
designationId: string | null
designation: Designation | null
_count: { tasks: number }
}
interface TaskTemplateManagerProps {
initialTemplates: TaskTemplate[]
designations: Designation[]
}
const DEPARTMENTS: { value: DepartmentType; label: string }[] = [
{ value: 'PERSONAL_LINES', label: 'Personal Lines' },
{ value: 'COMMERCIAL_LINES', label: 'Commercial Lines' },
{ value: 'CLAIMS', label: 'Claims' },
{ value: 'BENEFITS', label: 'Benefits' },
{ value: 'OTHER', label: 'Other' },
]
const TIMINGS: { value: TaskTiming; label: string }[] = [
{ value: 'PRE_RENEWAL', label: 'Pre-Renewal' },
{ value: 'POST_RENEWAL', label: 'Post-Renewal' },
]
const PRIORITIES: { value: TaskPriority; label: string; color: string }[] = [
{ value: 'LOW', label: 'Low', color: 'bg-slate-100 text-slate-700' },
{ value: 'MEDIUM', label: 'Medium', color: 'bg-blue-100 text-blue-700' },
{ value: 'HIGH', label: 'High', color: 'bg-orange-100 text-orange-700' },
{ value: 'URGENT', label: 'Urgent', color: 'bg-red-100 text-red-700' },
]
const emptyTemplate = {
name: '',
description: '',
department: 'CLAIMS' as DepartmentType,
timing: 'PRE_RENEWAL' as TaskTiming,
daysOffset: -90,
defaultPriority: 'MEDIUM' as TaskPriority,
isActive: true,
displayOrder: null as number | null,
designationId: null as string | null,
}
export function TaskTemplateManager({
initialTemplates,
designations,
}: TaskTemplateManagerProps) {
const [templates, setTemplates] = useState<TaskTemplate[]>(initialTemplates)
const [searchQuery, setSearchQuery] = useState('')
const [departmentFilter, setDepartmentFilter] = useState<string>('all')
const [timingFilter, setTimingFilter] = useState<string>('all')
const [designationFilter, setDesignationFilter] = useState<string>('all')
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<TaskTemplate | null>(null)
const [formData, setFormData] = useState(emptyTemplate)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const filteredTemplates = templates.filter((template) => {
const matchesSearch =
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesDepartment =
departmentFilter === 'all' || template.department === departmentFilter
const matchesTiming =
timingFilter === 'all' || template.timing === timingFilter
const matchesDesignation =
designationFilter === 'all' ||
(designationFilter === 'none' && !template.designationId) ||
template.designationId === designationFilter
return matchesSearch && matchesDepartment && matchesTiming && matchesDesignation
})
const handleOpenCreate = () => {
setEditingTemplate(null)
setFormData(emptyTemplate)
setIsDialogOpen(true)
}
const handleOpenEdit = (template: TaskTemplate) => {
setEditingTemplate(template)
setFormData({
name: template.name,
description: template.description || '',
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
defaultPriority: template.defaultPriority,
isActive: template.isActive,
displayOrder: template.displayOrder,
designationId: template.designationId,
})
setIsDialogOpen(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
try {
const url = editingTemplate
? `/api/templates/${editingTemplate.id}`
: '/api/templates'
const method = editingTemplate ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
description: formData.description || null,
displayOrder: formData.displayOrder || null,
designationId: formData.designationId || null,
}),
})
if (!response.ok) {
throw new Error('Failed to save template')
}
const savedTemplate = await response.json()
if (editingTemplate) {
setTemplates((prev) =>
prev.map((t) =>
t.id === savedTemplate.id
? { ...savedTemplate, _count: t._count, designation: designations.find(d => d.id === savedTemplate.designationId) || null }
: t
)
)
toast.success('Template updated successfully')
} else {
setTemplates((prev) => [
...prev,
{ ...savedTemplate, _count: { tasks: 0 }, designation: designations.find(d => d.id === savedTemplate.designationId) || null },
])
toast.success('Template created successfully')
}
setIsDialogOpen(false)
} catch (error) {
toast.error('Failed to save template')
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async (id: string) => {
try {
const response = await fetch(`/api/templates/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete template')
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
toast.success('Template deleted successfully')
} catch (error) {
toast.error('Failed to delete template')
} finally {
setDeleteConfirmId(null)
}
}
const formatDaysOffset = (timing: TaskTiming, daysOffset: number) => {
const absDays = Math.abs(daysOffset)
if (timing === 'PRE_RENEWAL') {
return `${absDays} days before renewal`
}
return `${absDays} days after renewal`
}
const getPriorityBadge = (priority: TaskPriority) => {
const config = PRIORITIES.find((p) => p.value === priority)
return (
<Badge variant="secondary" className={config?.color}>
{config?.label}
</Badge>
)
}
return (
<div className="space-y-6">
{/* Filters and Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-4 md:flex-row md:items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search templates..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{DEPARTMENTS.map((dept) => (
<SelectItem key={dept.value} value={dept.value}>
{dept.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={timingFilter} onValueChange={setTimingFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Timing" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Timing</SelectItem>
{TIMINGS.map((timing) => (
<SelectItem key={timing.value} value={timing.value}>
{timing.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={designationFilter} onValueChange={setDesignationFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Designation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Designations</SelectItem>
<SelectItem value="none">No Designation</SelectItem>
{designations.map((des) => (
<SelectItem key={des.id} value={des.id}>
{des.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Template
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>
{editingTemplate ? 'Edit Template' : 'Create Template'}
</DialogTitle>
<DialogDescription>
{editingTemplate
? 'Update the task template details.'
: 'Create a new task template for renewal workflows.'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="e.g., Claim Review"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Optional description"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Department</Label>
<Select
value={formData.department}
onValueChange={(value: DepartmentType) =>
setFormData((prev) => ({ ...prev, department: value }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPARTMENTS.map((dept) => (
<SelectItem key={dept.value} value={dept.value}>
{dept.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Timing</Label>
<Select
value={formData.timing}
onValueChange={(value: TaskTiming) =>
setFormData((prev) => ({ ...prev, timing: value }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMINGS.map((timing) => (
<SelectItem key={timing.value} value={timing.value}>
{timing.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="daysOffset">Days Offset</Label>
<Input
id="daysOffset"
type="number"
value={formData.daysOffset}
onChange={(e) =>
setFormData((prev) => ({
...prev,
daysOffset: parseInt(e.target.value) || 0,
}))
}
/>
<p className="text-xs text-muted-foreground">
Negative for pre-renewal, positive for post-renewal
</p>
</div>
<div className="grid gap-2">
<Label>Priority</Label>
<Select
value={formData.defaultPriority}
onValueChange={(value: TaskPriority) =>
setFormData((prev) => ({
...prev,
defaultPriority: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITIES.map((priority) => (
<SelectItem key={priority.value} value={priority.value}>
{priority.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Designation</Label>
<Select
value={formData.designationId || 'none'}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
designationId: value === 'none' ? null : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Select designation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (All clients)</SelectItem>
{designations.map((des) => (
<SelectItem key={des.id} value={des.id}>
{des.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="displayOrder">Display Order</Label>
<Input
id="displayOrder"
type="number"
value={formData.displayOrder ?? ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
displayOrder: e.target.value
? parseInt(e.target.value)
: null,
}))
}
placeholder="Optional"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) =>
setFormData((prev) => ({
...prev,
isActive: e.target.checked,
}))
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="isActive">Active</Label>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingTemplate
? 'Update'
: 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
{/* Templates Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Templates ({filteredTemplates.length})</span>
</CardTitle>
</CardHeader>
<CardContent>
{filteredTemplates.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No templates found. Create one to get started.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Department</TableHead>
<TableHead>Timing</TableHead>
<TableHead>Designation</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Status</TableHead>
<TableHead>Tasks</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTemplates.map((template) => (
<TableRow key={template.id}>
<TableCell>
<div>
<div className="font-medium">{template.name}</div>
{template.description && (
<div className="text-sm text-muted-foreground">
{template.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Building2 className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">
{DEPARTMENTS.find((d) => d.value === template.department)
?.label || template.department}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">
{formatDaysOffset(template.timing, template.daysOffset)}
</span>
</div>
</TableCell>
<TableCell>
{template.designation ? (
<Badge variant="outline" className="flex items-center gap-1 w-fit">
<Tag className="h-3 w-3" />
{template.designation.name}
</Badge>
) : (
<span className="text-sm text-muted-foreground">All</span>
)}
</TableCell>
<TableCell>{getPriorityBadge(template.defaultPriority)}</TableCell>
<TableCell>
<Badge variant={template.isActive ? 'default' : 'secondary'}>
{template.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{template._count.tasks}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(template)}
>
<Pencil className="h-4 w-4" />
</Button>
{deleteConfirmId === template.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(template.id)}
>
Confirm
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(template.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,501 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Plus,
Search,
Pencil,
Trash2,
Users,
Mail,
Building2,
Shield,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Label } from '@/components/ui/label'
import { formatRelativeTime } from '@/lib/utils'
interface Role {
id: string
name: string
description: string | null
}
interface UserRole {
id: string
roleId: string
role: Role
}
interface User {
id: string
email: string
displayName: string | null
department: string | null
isActive: boolean
lastLoginAt: string | null
createdAt: string
userRoles: UserRole[]
}
interface UserManagerProps {
initialUsers: User[]
roles: Role[]
}
const emptyUser = {
email: '',
displayName: '',
department: '',
isActive: true,
roleIds: [] as string[],
}
export function UserManager({ initialUsers, roles }: UserManagerProps) {
const [users, setUsers] = useState<User[]>(initialUsers)
const [searchQuery, setSearchQuery] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [formData, setFormData] = useState(emptyUser)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const filteredUsers = users.filter((user) => {
const matchesSearch =
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.department?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesRole =
roleFilter === 'all' ||
user.userRoles.some((ur) => ur.roleId === roleFilter)
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'active' && user.isActive) ||
(statusFilter === 'inactive' && !user.isActive)
return matchesSearch && matchesRole && matchesStatus
})
const handleOpenCreate = () => {
setEditingUser(null)
setFormData(emptyUser)
setIsDialogOpen(true)
}
const handleOpenEdit = (user: User) => {
setEditingUser(user)
setFormData({
email: user.email,
displayName: user.displayName || '',
department: user.department || '',
isActive: user.isActive,
roleIds: user.userRoles.map((ur) => ur.roleId),
})
setIsDialogOpen(true)
}
const handleRoleToggle = (roleId: string) => {
setFormData((prev) => ({
...prev,
roleIds: prev.roleIds.includes(roleId)
? prev.roleIds.filter((id) => id !== roleId)
: [...prev.roleIds, roleId],
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
try {
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users'
const method = editingUser ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
displayName: formData.displayName || null,
department: formData.department || null,
isActive: formData.isActive,
roleIds: formData.roleIds,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save user')
}
const savedUser = await response.json()
if (editingUser) {
setUsers((prev) =>
prev.map((u) => (u.id === savedUser.id ? savedUser : u))
)
toast.success('User updated successfully')
} else {
setUsers((prev) => [...prev, savedUser])
toast.success('User created successfully')
}
setIsDialogOpen(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save user')
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async (id: string) => {
try {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete user')
}
const result = await response.json()
if (result.message?.includes('deactivated')) {
// User was deactivated, update in list
setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, isActive: false } : u))
)
toast.success('User deactivated')
} else {
// User was deleted, remove from list
setUsers((prev) => prev.filter((u) => u.id !== id))
toast.success('User deleted')
}
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete user')
} finally {
setDeleteConfirmId(null)
}
}
return (
<div className="space-y-6">
{/* Filters and Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-4 md:flex-row md:items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search users..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>
{editingUser ? 'Edit User' : 'Create User'}
</DialogTitle>
<DialogDescription>
{editingUser
? 'Update user details and role assignments.'
: 'Create a new user account. They can log in via Azure AD using this email.'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
placeholder="user@example.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
value={formData.displayName}
onChange={(e) =>
setFormData((prev) => ({
...prev,
displayName: e.target.value,
}))
}
placeholder="John Doe"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={formData.department}
onChange={(e) =>
setFormData((prev) => ({
...prev,
department: e.target.value,
}))
}
placeholder="Optional"
/>
</div>
<div className="grid gap-2">
<Label>Roles</Label>
<div className="flex flex-wrap gap-2">
{roles.map((role) => (
<Badge
key={role.id}
variant={
formData.roleIds.includes(role.id)
? 'default'
: 'outline'
}
className="cursor-pointer"
onClick={() => handleRoleToggle(role.id)}
>
{role.name}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
Click to toggle role assignment
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) =>
setFormData((prev) => ({
...prev,
isActive: e.target.checked,
}))
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="isActive">Active</Label>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingUser
? 'Update'
: 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
{/* Users Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
<span>Users ({filteredUsers.length})</span>
</CardTitle>
</CardHeader>
<CardContent>
{filteredUsers.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No users found. Create one to get started.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Department</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div>
<div className="font-medium">
{user.displayName || 'No name'}
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{user.email}
</div>
</div>
</TableCell>
<TableCell>
{user.department ? (
<div className="flex items-center gap-1">
<Building2 className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{user.department}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.userRoles.length > 0 ? (
user.userRoles.map((ur) => (
<Badge
key={ur.id}
variant="secondary"
className="flex items-center gap-1"
>
<Shield className="h-3 w-3" />
{ur.role.name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
No roles
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{user.lastLoginAt
? formatRelativeTime(new Date(user.lastLoginAt))
: 'Never'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(user)}
>
<Pencil className="h-4 w-4" />
</Button>
{deleteConfirmId === user.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(user.id)}
>
Confirm
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(user.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,103 @@
'use client'
import { usePermissions } from '@/lib/hooks/use-auth'
import { ReactNode } from 'react'
interface RequirePermissionProps {
permission: string | string[]
requireAll?: boolean
fallback?: ReactNode
children: ReactNode
}
/**
* Component that conditionally renders children based on user permissions
*/
export function RequirePermission({
permission,
requireAll = false,
fallback = null,
children,
}: RequirePermissionProps) {
const { hasPermission, hasAllPermissions, hasAnyPermission } = usePermissions()
const permissions = Array.isArray(permission) ? permission : [permission]
const hasAccess = requireAll
? hasAllPermissions(permissions)
: hasAnyPermission(permissions)
if (!hasAccess) {
return <>{fallback}</>
}
return <>{children}</>
}
interface RequireRoleProps {
role: string | string[]
requireAll?: boolean
fallback?: ReactNode
children: ReactNode
}
/**
* Component that conditionally renders children based on user roles
*/
export function RequireRole({
role,
requireAll = false,
fallback = null,
children,
}: RequireRoleProps) {
const { hasRole, hasAllRoles } = useRoles()
const roles = Array.isArray(role) ? role : [role]
const hasAccess = requireAll
? hasAllRoles(...roles)
: hasRole(...roles)
if (!hasAccess) {
return <>{fallback}</>
}
return <>{children}</>
}
/**
* Higher-order component to protect components with permission checks
*/
export function withPermission<P extends object>(
Component: React.ComponentType<P>,
permission: string | string[],
requireAll = false
) {
return function PermissionProtectedComponent(props: P) {
return (
<RequirePermission permission={permission} requireAll={requireAll}>
<Component {...props} />
</RequirePermission>
)
}
}
/**
* Higher-order component to protect components with role checks
*/
export function withRole<P extends object>(
Component: React.ComponentType<P>,
role: string | string[],
requireAll = false
) {
return function RoleProtectedComponent(props: P) {
return (
<RequireRole role={role} requireAll={requireAll}>
<Component {...props} />
</RequireRole>
)
}
}
// Re-export useRoles for convenience
import { useRoles } from '@/lib/hooks/use-auth'

View file

@ -0,0 +1,108 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Building2, User } from 'lucide-react'
export function SignInForm() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleLocalSignIn = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
const result = await signIn('credentials', {
username,
password,
redirect: false,
})
if (result?.error) {
setError('Invalid username or password')
setLoading(false)
} else if (result?.ok) {
window.location.href = '/dashboard'
}
}
const handleMicrosoftSignIn = async () => {
await signIn('azure-ad', { callbackUrl: '/dashboard' })
}
return (
<div className="space-y-4">
{/* Local Account Login */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Local Account
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLocalSignIn} className="space-y-4">
<div>
<Input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
/>
</div>
<div>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="mt-4 p-3 bg-blue-50 rounded text-xs text-blue-800">
<p className="font-semibold mb-1">Development Accounts:</p>
<p> admin / admin123 (Admin)</p>
<p> manager / manager123 (Manager)</p>
<p> ae / ae123 (Account Executive)</p>
</div>
</CardContent>
</Card>
{/* Microsoft Login (if configured) */}
<Card>
<CardContent className="pt-6">
<Button
onClick={handleMicrosoftSignIn}
variant="outline"
className="w-full"
size="lg"
>
<Building2 className="mr-2 h-5 w-5" />
Sign in with Microsoft
</Button>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,161 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Building2, Calendar, FileText, CheckSquare } from 'lucide-react'
import { formatDate, daysUntil } from '@/lib/utils'
interface ClientCardProps {
client: {
id: string
name: string
city: string | null
state: string | null
designation: {
name: string
color: string
} | null
designation2: {
name: string
color: string
} | null
policies: Array<{
id: string
policyNumber: string | null
expirationDate: Date
policyType: string | null
carrierName: string | null
writingCompanyName: string | null
department: string | null
executiveName: string | null
csrName: string | null
status: string | null
}>
_count: {
policies: number
tasks: number
}
}
}
export function ClientCard({ client }: ClientCardProps) {
const nextPolicy = client.policies[0]
const daysToExpiration = nextPolicy ? daysUntil(nextPolicy.expirationDate) : null
return (
<Link href={`/clients/${client.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{client.name}</CardTitle>
{(client.city || client.state) && (
<p className="text-sm text-muted-foreground mt-1">
{[client.city, client.state].filter(Boolean).join(', ')}
</p>
)}
</div>
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Designation Badges */}
<div className="flex gap-2 flex-wrap">
{client.designation && (
<Badge
variant="outline"
className={`bg-${client.designation.color} bg-opacity-10`}
>
{client.designation.name}
</Badge>
)}
{client.designation2 && (
<Badge
variant="outline"
className={`bg-${client.designation2.color} bg-opacity-10`}
>
{client.designation2.name}
</Badge>
)}
</div>
{/* Next Policy Expiration */}
{nextPolicy && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Next expiration:</span>
<span className="font-medium">
{formatDate(nextPolicy.expirationDate)}
</span>
{daysToExpiration !== null && daysToExpiration <= 90 && (
<Badge
variant={daysToExpiration <= 30 ? 'destructive' : 'secondary'}
className="ml-auto"
>
{daysToExpiration} days
</Badge>
)}
</div>
<div className="space-y-2 pl-6 pt-2">
{/* Policy Type - Prominent */}
{nextPolicy.policyType && (
<div className="text-sm font-semibold text-foreground">
{nextPolicy.policyType}
</div>
)}
{/* Policy Details Grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{nextPolicy.policyNumber && (
<div>
<span className="text-muted-foreground">Policy:</span>{' '}
<span className="font-medium text-foreground">{nextPolicy.policyNumber}</span>
</div>
)}
{nextPolicy.department && (
<div>
<span className="text-muted-foreground">Dept:</span>{' '}
<span className="font-medium text-foreground">{nextPolicy.department}</span>
</div>
)}
{nextPolicy.writingCompanyName && (
<div className="col-span-2">
<span className="text-muted-foreground">Carrier:</span>{' '}
<span className="font-medium text-foreground">{nextPolicy.writingCompanyName}</span>
</div>
)}
{nextPolicy.executiveName && (
<div className="col-span-2">
<span className="text-muted-foreground">Executive:</span>{' '}
<span className="font-medium text-foreground">{nextPolicy.executiveName}</span>
</div>
)}
{nextPolicy.csrName && (
<div className="col-span-2">
<span className="text-muted-foreground">CSR:</span>{' '}
<span className="font-medium text-foreground">{nextPolicy.csrName}</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Stats */}
<div className="flex gap-4 pt-2 border-t">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
<span>{client._count.policies} policies</span>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<CheckSquare className="h-4 w-4" />
<span>{client._count.tasks} tasks</span>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}

View file

@ -0,0 +1,255 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Building2, MapPin, Phone, Mail, FileText, CheckSquare } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface ClientDetailProps {
client: any
designations: any[]
}
export function ClientDetail({ client, designations }: ClientDetailProps) {
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false)
const handleDesignationUpdate = async () => {
setSaving(true)
try {
await fetch(`/api/clients/${client.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
designationId: selectedDesignation || null,
designation2Id: selectedDesignation2 || null,
}),
})
} catch (error) {
console.error('Failed to update designations:', error)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Building2 className="h-8 w-8" />
{client.name}
</h1>
<div className="flex gap-4 mt-3 text-sm text-muted-foreground">
{client.city && client.state && (
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{client.city}, {client.state}
</div>
)}
{client.phone && (
<div className="flex items-center gap-1">
<Phone className="h-4 w-4" />
{client.phone}
</div>
)}
{client.email && (
<div className="flex items-center gap-1">
<Mail className="h-4 w-4" />
{client.email}
</div>
)}
</div>
</div>
</div>
{/* Designation Assignment */}
<Card>
<CardHeader>
<CardTitle>Designations</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium mb-2 block">Primary Designation</label>
<Select value={selectedDesignation || undefined} onValueChange={setSelectedDesignation}>
<SelectTrigger>
<SelectValue placeholder="Select designation" />
</SelectTrigger>
<SelectContent>
{designations.map((designation) => (
<SelectItem key={designation.id} value={designation.id}>
{designation.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Secondary Designation</label>
<Select value={selectedDesignation2 || undefined} onValueChange={setSelectedDesignation2}>
<SelectTrigger>
<SelectValue placeholder="Select designation" />
</SelectTrigger>
<SelectContent>
{designations.map((designation) => (
<SelectItem key={designation.id} value={designation.id}>
{designation.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button onClick={handleDesignationUpdate} disabled={saving}>
{saving ? 'Saving...' : 'Update Designations'}
</Button>
</CardContent>
</Card>
{/* Tabs */}
<Tabs defaultValue="policies">
<TabsList>
<TabsTrigger value="policies">
<FileText className="h-4 w-4 mr-2" />
Policies ({client.policies.length})
</TabsTrigger>
<TabsTrigger value="tasks">
<CheckSquare className="h-4 w-4 mr-2" />
Tasks ({client.tasks.length})
</TabsTrigger>
</TabsList>
<TabsContent value="policies" className="space-y-4">
{client.policies.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
No policies found
</CardContent>
</Card>
) : (
client.policies.map((policy: any) => (
<Link key={policy.id} href={`/policies/${policy.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="pt-6">
<div className="space-y-3">
{/* Header with Policy Type and Expiration */}
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="text-lg font-bold">{policy.policyType || 'Policy'}</h3>
<p className="text-sm text-muted-foreground mt-1">
Policy #: {policy.policyNumber || 'N/A'}
</p>
</div>
<Badge variant={new Date(policy.expirationDate) < new Date() ? 'destructive' : 'default'}>
<span suppressHydrationWarning>
Expires {formatDate(policy.expirationDate)}
</span>
</Badge>
</div>
{/* Policy Details Grid */}
<div className="grid grid-cols-2 gap-4 pt-3 border-t">
{policy.writingCompanyName && (
<div>
<p className="text-xs text-muted-foreground">Writing Company</p>
<p className="text-sm font-medium">{policy.writingCompanyName}</p>
</div>
)}
{policy.department && (
<div>
<p className="text-xs text-muted-foreground">Department</p>
<p className="text-sm font-medium">{policy.department}</p>
</div>
)}
{policy.executiveName && (
<div>
<p className="text-xs text-muted-foreground">Executive</p>
<p className="text-sm font-medium">{policy.executiveName}</p>
</div>
)}
{policy.csrName && (
<div>
<p className="text-xs text-muted-foreground">CSR</p>
<p className="text-sm font-medium">{policy.csrName}</p>
</div>
)}
{policy.billMethod && (
<div>
<p className="text-xs text-muted-foreground">Bill Method</p>
<p className="text-sm font-medium">{policy.billMethod}</p>
</div>
)}
{policy.status && (
<div>
<p className="text-xs text-muted-foreground">Status</p>
<p className="text-sm font-medium">{policy.status}</p>
</div>
)}
</div>
{/* Additional Personnel */}
{(policy.additionalRep1 || policy.additionalRep2 || policy.additionalExec1 || policy.additionalExec2) && (
<div className="pt-3 border-t">
<p className="text-xs text-muted-foreground mb-2">Additional Personnel</p>
<div className="flex flex-wrap gap-2">
{policy.additionalRep1 && (
<Badge variant="outline">Rep: {policy.additionalRep1}</Badge>
)}
{policy.additionalRep2 && (
<Badge variant="outline">Rep: {policy.additionalRep2}</Badge>
)}
{policy.additionalExec1 && (
<Badge variant="outline">Exec: {policy.additionalExec1}</Badge>
)}
{policy.additionalExec2 && (
<Badge variant="outline">Exec: {policy.additionalExec2}</Badge>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</Link>
))
)}
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
{client.tasks.map((task: any) => (
<Card key={task.id}>
<CardContent className="pt-6">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold">{task.title}</h3>
<p className="text-sm text-muted-foreground">{task.description}</p>
</div>
<div className="text-right">
<Badge>{task.status}</Badge>
<p className="text-sm text-muted-foreground mt-1">
Due: {formatDate(task.dueDate)}
</p>
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
</Tabs>
</div>
)
}

View file

@ -0,0 +1,169 @@
'use client'
import { useState, useEffect } from 'react'
import { Client, Designation } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ClientCard } from './client-card'
import { ClientTable } from './client-table'
import { Search, Filter, LayoutGrid, List } from 'lucide-react'
type ViewMode = 'cards' | 'table'
interface ClientWithRelations extends Client {
designation: Designation | null
designation2: Designation | null
policies: any[]
_count: {
policies: number
tasks: number
}
}
interface ClientListProps {
initialClients?: ClientWithRelations[]
designations?: Designation[]
}
export function ClientList({ initialClients = [], designations = [] }: ClientListProps) {
const [clients, setClients] = useState<ClientWithRelations[]>(initialClients)
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [designationFilter, setDesignationFilter] = useState<string>('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const fetchClients = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page: page.toString(),
limit: '20',
...(search && { search }),
...(designationFilter && { designationId: designationFilter }),
})
const response = await fetch(`/api/clients?${params}`)
const data = await response.json()
setClients(data.clients)
setTotalPages(data.pagination.totalPages)
} catch (error) {
console.error('Failed to fetch clients:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchClients()
}, [page, search, designationFilter])
const handleSearchChange = (value: string) => {
setSearch(value)
setPage(1)
}
const handleDesignationFilterChange = (value: string) => {
setDesignationFilter(value)
setPage(1)
}
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search clients..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={designationFilter || undefined} onValueChange={handleDesignationFilterChange}>
<SelectTrigger className="w-[200px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="All Designations" />
</SelectTrigger>
<SelectContent>
{designations.map((designation) => (
<SelectItem key={designation.id} value={designation.id}>
{designation.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex border rounded-md">
<Button
variant={viewMode === 'cards' ? 'default' : 'ghost'}
size="icon"
onClick={() => setViewMode('cards')}
title="Card view"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'table' ? 'default' : 'ghost'}
size="icon"
onClick={() => setViewMode('table')}
title="Table view"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
{/* Client List */}
{loading ? (
<div className="text-center py-8">Loading...</div>
) : clients.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No clients found
</div>
) : viewMode === 'cards' ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{clients.map((client) => (
<ClientCard key={client.id} client={client} />
))}
</div>
) : (
<ClientTable clients={clients} />
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</Button>
<span className="flex items-center px-4">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
</Button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,153 @@
'use client'
import Link from 'next/link'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { formatDate, daysUntil } from '@/lib/utils'
interface ClientTableProps {
clients: Array<{
id: string
name: string
city: string | null
state: string | null
designation: {
name: string
color: string
} | null
designation2: {
name: string
color: string
} | null
policies: Array<{
id: string
policyNumber: string | null
expirationDate: Date
policyType: string | null
carrierName: string | null
writingCompanyName: string | null
department: string | null
executiveName: string | null
csrName: string | null
status: string | null
}>
_count: {
policies: number
tasks: number
}
}>
}
export function ClientTable({ clients }: ClientTableProps) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead>Location</TableHead>
<TableHead>Designation</TableHead>
<TableHead>Next Expiration</TableHead>
<TableHead>Policy Type</TableHead>
<TableHead>Carrier</TableHead>
<TableHead>Executive</TableHead>
<TableHead className="text-center">Policies</TableHead>
<TableHead className="text-center">Tasks</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clients.map((client) => {
const nextPolicy = client.policies[0]
const daysToExpiration = nextPolicy
? daysUntil(nextPolicy.expirationDate)
: null
return (
<TableRow key={client.id} className="cursor-pointer hover:bg-muted/50">
<TableCell>
<Link
href={`/clients/${client.id}`}
className="font-medium hover:underline"
>
{client.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{[client.city, client.state].filter(Boolean).join(', ') || '-'}
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{client.designation && (
<Badge
variant="outline"
className={`bg-${client.designation.color} bg-opacity-10 text-xs`}
>
{client.designation.name}
</Badge>
)}
{client.designation2 && (
<Badge
variant="outline"
className={`bg-${client.designation2.color} bg-opacity-10 text-xs`}
>
{client.designation2.name}
</Badge>
)}
{!client.designation && !client.designation2 && (
<span className="text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell>
{nextPolicy ? (
<div className="flex items-center gap-2">
<span>{formatDate(nextPolicy.expirationDate)}</span>
{daysToExpiration !== null && daysToExpiration <= 90 && (
<Badge
variant={daysToExpiration <= 30 ? 'destructive' : 'secondary'}
className="text-xs"
>
{daysToExpiration}d
</Badge>
)}
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{nextPolicy?.policyType || (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{nextPolicy?.writingCompanyName || (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{nextPolicy?.executiveName || (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center">
{client._count.policies}
</TableCell>
<TableCell className="text-center">
{client._count.tasks}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}

View file

@ -0,0 +1,444 @@
'use client'
import { useState, useEffect } from 'react'
import {
Users,
CheckCircle2,
Clock,
AlertTriangle,
TrendingUp,
BarChart3,
RefreshCw,
Calendar,
Target,
Activity,
} from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
interface WorkloadData {
summary: {
totalTasks: number
activeTasks: number
completedTasks: number
completionRate: number
overdueTasks: number
dueTodayTasks: number
dueThisWeekTasks: number
activeUsers: number
avgTasksPerUser: number
maxWorkload: number
minWorkload: number
}
trends: {
recentlyCompleted: number
tasksCreatedLast30Days: number
tasksCompletedLast30Days: number
velocity: number
}
breakdowns: {
status: {
notStarted: number
inProgress: number
completed: number
blocked: number
na: number
cancelled: number
}
priority: {
low: number
medium: number
high: number
urgent: number
}
department: Record<string, number>
}
userWorkload: Array<{
id: string
name: string
department: string | null
totalAssigned: number
activeTasks: number
completedTasks: number
overdueTasks: number
highPriorityTasks: number
completionRate: number
}>
}
const DEPARTMENT_LABELS: Record<string, string> = {
PERSONAL_LINES: 'Personal Lines',
COMMERCIAL_LINES: 'Commercial Lines',
CLAIMS: 'Claims',
BENEFITS: 'Benefits',
OTHER: 'Other',
}
export function WorkloadKPIs() {
const [data, setData] = useState<WorkloadData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/dashboard/workload')
if (!response.ok) throw new Error('Failed to fetch workload data')
const result = await response.json()
setData(result)
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Workload KPIs</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader className="pb-2">
<div className="h-4 bg-muted rounded w-24" />
</CardHeader>
<CardContent>
<div className="h-8 bg-muted rounded w-16" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (error || !data) {
return (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">Failed to load workload data: {error}</p>
<Button onClick={fetchData} variant="outline" className="mt-4">
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</Button>
</CardContent>
</Card>
)
}
const { summary, trends, breakdowns, userWorkload } = data
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Workload KPIs</h2>
<p className="text-muted-foreground">Team performance and task distribution</p>
</div>
<Button onClick={fetchData} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Active Tasks</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summary.activeTasks}</div>
<p className="text-xs text-muted-foreground">
{summary.avgTasksPerUser} avg per user
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Completion Rate</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summary.completionRate}%</div>
<Progress value={summary.completionRate} className="mt-2" />
</CardContent>
</Card>
<Card className={summary.overdueTasks > 0 ? 'border-orange-200 bg-orange-50/50' : ''}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
<AlertTriangle className={`h-4 w-4 ${summary.overdueTasks > 0 ? 'text-orange-600' : 'text-muted-foreground'}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${summary.overdueTasks > 0 ? 'text-orange-600' : ''}`}>
{summary.overdueTasks}
</div>
<p className="text-xs text-muted-foreground">
{summary.dueTodayTasks} due today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summary.activeUsers}</div>
<p className="text-xs text-muted-foreground">
with assigned tasks
</p>
</CardContent>
</Card>
</div>
{/* Velocity & Trends */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
30-Day Velocity
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-600">{trends.velocity}</div>
<p className="text-xs text-muted-foreground">tasks completed</p>
<div className="mt-2 text-sm">
<span className="text-muted-foreground">Created: </span>
<span className="font-medium">{trends.tasksCreatedLast30Days}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="h-4 w-4" />
Upcoming Work
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Due Today</span>
<Badge variant={summary.dueTodayTasks > 0 ? 'default' : 'secondary'}>
{summary.dueTodayTasks}
</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Due This Week</span>
<Badge variant="outline">{summary.dueThisWeekTasks}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Recently Completed</span>
<Badge variant="secondary" className="bg-green-100 text-green-700">
{trends.recentlyCompleted}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Workload Range
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Highest Load</span>
<span className="font-medium">{summary.maxWorkload} tasks</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Lowest Load</span>
<span className="font-medium">{summary.minWorkload} tasks</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Average</span>
<span className="font-medium">{summary.avgTasksPerUser} tasks</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Breakdowns */}
<div className="grid gap-4 md:grid-cols-2">
{/* Status Breakdown */}
<Card>
<CardHeader>
<CardTitle className="text-base">Task Status Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-slate-400" />
<span className="text-sm">Not Started</span>
</div>
<span className="font-medium">{breakdowns.status.notStarted}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-sm">In Progress</span>
</div>
<span className="font-medium">{breakdowns.status.inProgress}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm">Completed</span>
</div>
<span className="font-medium">{breakdowns.status.completed}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm">Blocked</span>
</div>
<span className="font-medium">{breakdowns.status.blocked}</span>
</div>
</div>
</CardContent>
</Card>
{/* Priority Breakdown */}
<Card>
<CardHeader>
<CardTitle className="text-base">Priority Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="bg-red-100 text-red-700">Urgent</Badge>
<span className="font-medium">{breakdowns.priority.urgent}</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="bg-orange-100 text-orange-700">High</Badge>
<span className="font-medium">{breakdowns.priority.high}</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="bg-blue-100 text-blue-700">Medium</Badge>
<span className="font-medium">{breakdowns.priority.medium}</span>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="bg-slate-100 text-slate-700">Low</Badge>
<span className="font-medium">{breakdowns.priority.low}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Department Breakdown */}
{Object.keys(breakdowns.department).length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Tasks by Department</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-5">
{Object.entries(breakdowns.department).map(([dept, count]) => (
<div key={dept} className="text-center p-3 rounded-lg bg-muted/50">
<div className="text-2xl font-bold">{count}</div>
<div className="text-xs text-muted-foreground">
{DEPARTMENT_LABELS[dept] || dept}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* User Workload Table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Team Workload</CardTitle>
<CardDescription>Individual task assignments and performance</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 font-medium">Team Member</th>
<th className="text-center py-2 font-medium">Active</th>
<th className="text-center py-2 font-medium">Completed</th>
<th className="text-center py-2 font-medium">Overdue</th>
<th className="text-center py-2 font-medium">High Priority</th>
<th className="text-center py-2 font-medium">Completion %</th>
</tr>
</thead>
<tbody>
{userWorkload.slice(0, 10).map((user) => (
<tr key={user.id} className="border-b last:border-0">
<td className="py-3">
<div className="font-medium">{user.name}</div>
{user.department && (
<div className="text-xs text-muted-foreground">{user.department}</div>
)}
</td>
<td className="text-center py-3">
<Badge variant="outline">{user.activeTasks}</Badge>
</td>
<td className="text-center py-3">
<span className="text-green-600">{user.completedTasks}</span>
</td>
<td className="text-center py-3">
{user.overdueTasks > 0 ? (
<Badge variant="destructive">{user.overdueTasks}</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
<td className="text-center py-3">
{user.highPriorityTasks > 0 ? (
<Badge variant="secondary" className="bg-orange-100 text-orange-700">
{user.highPriorityTasks}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
<td className="text-center py-3">
<div className="flex items-center justify-center gap-2">
<Progress value={user.completionRate} className="w-16 h-2" />
<span className="text-xs">{user.completionRate}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
{userWorkload.length === 0 && (
<p className="text-center py-8 text-muted-foreground">
No users with assigned tasks
</p>
)}
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,105 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Building2, Users, CheckSquare, Settings, LogOut, User, LayoutDashboard, Shapes } from 'lucide-react'
import { ThemeToggle } from '@/components/theme-toggle'
export function NavBar() {
const pathname = usePathname()
const { data: session } = useSession()
const userRoles = (session?.user as any)?.roles || []
const isAdmin = userRoles.includes('Admin')
const isManager = userRoles.includes('Manager') || isAdmin
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/clients', label: 'Clients', icon: Building2 },
{ href: '/tasks', label: 'Tasks', icon: CheckSquare },
]
if (isManager) {
navItems.push({ href: '/manager', label: 'Manager', icon: Users })
}
if (isAdmin) {
navItems.push({ href: '/admin', label: 'Admin', icon: Settings })
}
return (
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-8">
<Link href="/dashboard" className="flex items-center gap-2 font-bold text-xl">
<Shapes className="h-6 w-6 text-primary" />
<span className="bg-gradient-to-r from-primary to-blue-600 bg-clip-text text-transparent">
OnDeck
</span>
</Link>
<div className="hidden md:flex gap-1">
{navItems.map((item) => {
const Icon = item.icon
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
)
})}
</div>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<User className="h-4 w-4" />
{session?.user?.name || 'User'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span>{session?.user?.name}</span>
<span className="text-xs text-muted-foreground">{session?.user?.email}</span>
{userRoles.length > 0 && (
<span className="text-xs text-primary mt-1">{userRoles.join(', ')}</span>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/auth/signin' })}>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</nav>
)
}

View file

@ -0,0 +1,144 @@
'use client'
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { ChevronDown, ChevronRight, Users } from 'lucide-react'
interface TeamMember {
id: string
displayName: string | null
email: string
department: string | null
taskAssignments: {
task: {
status: string
priority: string
dueDate: Date | null
}
}[]
userRoles: {
role: {
name: string
}
}[]
}
interface TeamMembersByDepartmentProps {
teamMembers: TeamMember[]
}
export function TeamMembersByDepartment({ teamMembers }: TeamMembersByDepartmentProps) {
// Group members by department
const groupedByDepartment = teamMembers.reduce((acc, member) => {
const dept = member.department || 'Unassigned'
if (!acc[dept]) {
acc[dept] = []
}
acc[dept].push(member)
return acc
}, {} as Record<string, TeamMember[]>)
// Sort departments alphabetically, but keep "Unassigned" at the end
const sortedDepartments = Object.keys(groupedByDepartment).sort((a, b) => {
if (a === 'Unassigned') return 1
if (b === 'Unassigned') return -1
return a.localeCompare(b)
})
// Track which departments are expanded (all expanded by default)
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(
new Set(sortedDepartments)
)
const toggleDepartment = (dept: string) => {
setExpandedDepts(prev => {
const next = new Set(prev)
if (next.has(dept)) {
next.delete(dept)
} else {
next.add(dept)
}
return next
})
}
if (teamMembers.length === 0) {
return (
<p className="text-sm text-muted-foreground text-center py-8">
No team members found
</p>
)
}
return (
<div className="space-y-3">
{sortedDepartments.map((dept) => {
const members = groupedByDepartment[dept]
const isExpanded = expandedDepts.has(dept)
return (
<div key={dept} className="border rounded-lg overflow-hidden">
<button
onClick={() => toggleDepartment(dept)}
className="w-full flex items-center justify-between p-3 bg-muted/50 hover:bg-muted transition-colors text-left"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{dept}</span>
</div>
<Badge variant="secondary" className="text-xs">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Badge>
</button>
{isExpanded && (
<div className="divide-y">
{members.map((member) => {
const activeTasks = member.taskAssignments.length
const overdue = member.taskAssignments.filter(
a => a.task.dueDate && new Date(a.task.dueDate) < new Date()
).length
const roles = member.userRoles.map(ur => ur.role.name).join(', ')
return (
<div
key={member.id}
className="flex items-center justify-between p-4 hover:bg-accent/50 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{member.displayName || member.email}</h3>
{roles && (
<Badge variant="outline" className="text-xs">
{roles}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{member.email}</p>
</div>
<div className="text-right space-y-1">
<div className="text-sm font-medium">
{activeTasks} active {activeTasks === 1 ? 'task' : 'tasks'}
</div>
{overdue > 0 && (
<div className="text-xs text-red-600">
{overdue} overdue
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}

View file

@ -0,0 +1,289 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
ArrowLeft,
Building2,
Calendar,
DollarSign,
FileText,
Users,
CheckSquare,
Briefcase,
CreditCard,
Shield
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface PolicyDetailProps {
policy: any
}
export function PolicyDetail({ policy }: PolicyDetailProps) {
const isExpired = new Date(policy.expirationDate) < new Date()
const daysUntilExpiration = Math.ceil(
(new Date(policy.expirationDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Link href={`/clients/${policy.client.id}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to {policy.client.name}
</Button>
</Link>
</div>
<h1 className="text-3xl font-bold">{policy.policyType || 'Type LOB'}</h1>
<p className="text-muted-foreground">
Policy #{policy.policyNumber || 'N/A'}
</p>
</div>
<div className="text-right">
<Badge
variant={isExpired ? 'destructive' : daysUntilExpiration <= 30 ? 'destructive' : daysUntilExpiration <= 90 ? 'secondary' : 'default'}
className="text-sm"
>
{isExpired ? 'Expired' : `Expires in ${daysUntilExpiration} days`}
</Badge>
<p className="text-sm text-muted-foreground mt-2">
<span suppressHydrationWarning>{formatDate(policy.expirationDate)}</span>
</p>
</div>
</div>
{/* Client Info Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Client Information
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Client Name</p>
<p className="font-medium">{policy.client.name}</p>
</div>
{policy.client.city && (
<div>
<p className="text-sm text-muted-foreground">Location</p>
<p className="font-medium">
{[policy.client.city, policy.client.state].filter(Boolean).join(', ')}
</p>
</div>
)}
{policy.client.designation && (
<div>
<p className="text-sm text-muted-foreground">Primary Designation</p>
<Badge variant="outline">{policy.client.designation.name}</Badge>
</div>
)}
</div>
</CardContent>
</Card>
{/* Policy Details Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* Coverage Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Coverage Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">Type LOB</p>
<p className="font-medium text-lg">{policy.policyType || 'N/A'}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Effective Date</p>
<p className="font-medium">
<span suppressHydrationWarning>
{policy.effectiveDate ? formatDate(policy.effectiveDate) : 'N/A'}
</span>
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expiration Date</p>
<p className="font-medium">
<span suppressHydrationWarning>
{formatDate(policy.expirationDate)}
</span>
</p>
</div>
</div>
{policy.status && (
<div>
<p className="text-sm text-muted-foreground">Status</p>
<Badge>{policy.status}</Badge>
</div>
)}
{policy.businessType && (
<div>
<p className="text-sm text-muted-foreground">Business Type</p>
<p className="font-medium">{policy.businessType}</p>
</div>
)}
</CardContent>
</Card>
{/* Carrier & Financial */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="h-5 w-5" />
Carrier & Financial
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{policy.writingCompanyName && (
<div>
<p className="text-sm text-muted-foreground">Writing Company</p>
<p className="font-medium">{policy.writingCompanyName}</p>
</div>
)}
{policy.carrierName && (
<div>
<p className="text-sm text-muted-foreground">Parent Company</p>
<p className="font-medium">{policy.carrierName}</p>
</div>
)}
{policy.billMethod && (
<div>
<p className="text-sm text-muted-foreground">Billing Method</p>
<p className="font-medium">{policy.billMethod}</p>
</div>
)}
{policy.premiumAmount && (
<div>
<p className="text-sm text-muted-foreground">Premium Amount</p>
<p className="font-medium text-lg">
${policy.premiumAmount.toLocaleString()}
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Personnel Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Assigned Personnel
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
{policy.department && (
<div>
<p className="text-sm text-muted-foreground">Department</p>
<Badge variant="outline" className="mt-1">{policy.department}</Badge>
</div>
)}
{policy.executiveName && (
<div>
<p className="text-sm text-muted-foreground">Executive</p>
<p className="font-medium">{policy.executiveName}</p>
</div>
)}
{policy.csrName && (
<div>
<p className="text-sm text-muted-foreground">CSR</p>
<p className="font-medium">{policy.csrName}</p>
</div>
)}
</div>
{/* Additional Personnel */}
{(policy.additionalRep1 || policy.additionalRep2 || policy.additionalExec1 || policy.additionalExec2) && (
<div className="mt-6 pt-6 border-t">
<p className="text-sm text-muted-foreground mb-3">Additional Personnel</p>
<div className="flex flex-wrap gap-2">
{policy.additionalRep1 && (
<Badge variant="secondary">Rep: {policy.additionalRep1}</Badge>
)}
{policy.additionalRep2 && (
<Badge variant="secondary">Rep: {policy.additionalRep2}</Badge>
)}
{policy.additionalExec1 && (
<Badge variant="secondary">Exec: {policy.additionalExec1}</Badge>
)}
{policy.additionalExec2 && (
<Badge variant="secondary">Exec: {policy.additionalExec2}</Badge>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Tasks Tab */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckSquare className="h-5 w-5" />
Renewal Tasks ({policy.tasks.length})
</CardTitle>
</CardHeader>
<CardContent>
{policy.tasks.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No tasks assigned to this policy
</p>
) : (
<div className="space-y-3">
{policy.tasks.map((task: any) => (
<div key={task.id} className="flex items-start justify-between p-4 border rounded-lg">
<div className="flex-1">
<h4 className="font-semibold">{task.title}</h4>
{task.description && (
<p className="text-sm text-muted-foreground mt-1">{task.description}</p>
)}
{task.assignments.length > 0 && (
<div className="flex gap-2 mt-2">
{task.assignments.map((assignment: any) => (
<Badge key={assignment.id} variant="outline" className="text-xs">
{assignment.user.displayName}
</Badge>
))}
</div>
)}
</div>
<div className="text-right ml-4">
<Badge variant={
task.status === 'COMPLETED' ? 'default' :
task.status === 'IN_PROGRESS' ? 'secondary' :
task.status === 'BLOCKED' ? 'destructive' : 'outline'
}>
{task.status}
</Badge>
{task.dueDate && (
<p className="text-xs text-muted-foreground mt-1">
Due: <span suppressHydrationWarning>{formatDate(task.dueDate)}</span>
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,12 @@
'use client'
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'
import { ReactNode } from 'react'
interface SessionProviderProps {
children: ReactNode
}
export function SessionProvider({ children }: SessionProviderProps) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
}

Some files were not shown because too many files have changed in this diff Show more