Initial commit: OnDeck project
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
b036a93da2
139 changed files with 33198 additions and 0 deletions
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ssh-keygen:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git add:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
dev/.postgres
Normal file
16
dev/.postgres
Normal 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
4
dev/.sql
Normal 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
416
dev/DATABASE_REFERENCE.md
Normal 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
61
dev/create-prd.mdc
Normal 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
64
dev/generate-tasks.mdc
Normal 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
292
dev/mvp.md
Normal 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 365–based 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
|
||||
621
dev/prd-input-policy-renewal-system.md
Normal file
621
dev/prd-input-policy-renewal-system.md
Normal 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
38
dev/process-task-list.mdc
Normal 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 sub‑task until you ask the user for permission and they say “yes” or "y"
|
||||
- **Completion protocol:**
|
||||
1. When you finish a **sub‑task**, 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 sub‑task and wait for the user’s go‑ahead.
|
||||
|
||||
## 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 one‑line 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 **sub‑task** `[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 sub‑task is next.
|
||||
6. After implementing a sub‑task, update the file and then pause for user approval.
|
||||
17
dev/shapetasks.csv
Normal file
17
dev/shapetasks.csv
Normal 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
|
||||
|
12
ondeck/.claude/settings.local.json
Normal file
12
ondeck/.claude/settings.local.json
Normal 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
43
ondeck/.gitignore
vendored
Normal 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
56
ondeck/Dockerfile
Normal 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
36
ondeck/README.md
Normal 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
22
ondeck/components.json
Normal 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
53
ondeck/docker-compose.yml
Normal 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
|
||||
150
ondeck/docs/designation-admin-guide.md
Normal file
150
ondeck/docs/designation-admin-guide.md
Normal 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
18
ondeck/eslint.config.mjs
Normal 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
28
ondeck/jest.config.js
Normal 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
2
ondeck/jest.setup.js
Normal 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
9
ondeck/next.config.ts
Normal 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
16600
ondeck/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
70
ondeck/package.json
Normal file
70
ondeck/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
ondeck/postcss.config.mjs
Normal file
7
ondeck/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
ondeck/prisma.config.ts
Normal file
15
ondeck/prisma.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
401
ondeck/prisma/migrations/20260120023729_init/migration.sql
Normal file
401
ondeck/prisma/migrations/20260120023729_init/migration.sql
Normal 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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "entra_oid" DROP NOT NULL;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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";
|
||||
|
|
@ -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");
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
3
ondeck/prisma/migrations/migration_lock.toml
Normal file
3
ondeck/prisma/migrations/migration_lock.toml
Normal 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
363
ondeck/prisma/schema.prisma
Normal 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
279
ondeck/prisma/seed.ts
Normal 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
1
ondeck/public/file.svg
Normal 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
1
ondeck/public/globe.svg
Normal 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
1
ondeck/public/next.svg
Normal 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
1
ondeck/public/vercel.svg
Normal 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
1
ondeck/public/window.svg
Normal 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
13
ondeck/restart-dev.sh
Executable 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
|
||||
72
ondeck/scripts/create-admin-user.ts
Normal file
72
ondeck/scripts/create-admin-user.ts
Normal 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()
|
||||
})
|
||||
131
ondeck/scripts/import-shape-tasks.ts
Normal file
131
ondeck/scripts/import-shape-tasks.ts
Normal 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()
|
||||
})
|
||||
22
ondeck/scripts/run-sync.ts
Normal file
22
ondeck/scripts/run-sync.ts
Normal 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)
|
||||
})
|
||||
104
ondeck/src/app/(dashboard)/admin/designations/page-client.tsx
Normal file
104
ondeck/src/app/(dashboard)/admin/designations/page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
ondeck/src/app/(dashboard)/admin/designations/page.tsx
Normal file
19
ondeck/src/app/(dashboard)/admin/designations/page.tsx
Normal 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 />
|
||||
}
|
||||
104
ondeck/src/app/(dashboard)/admin/page.tsx
Normal file
104
ondeck/src/app/(dashboard)/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
ondeck/src/app/(dashboard)/admin/sync/page.tsx
Normal file
168
ondeck/src/app/(dashboard)/admin/sync/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
ondeck/src/app/(dashboard)/admin/templates/page.tsx
Normal file
50
ondeck/src/app/(dashboard)/admin/templates/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
ondeck/src/app/(dashboard)/admin/users/page.tsx
Normal file
55
ondeck/src/app/(dashboard)/admin/users/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
ondeck/src/app/(dashboard)/clients/[id]/page.tsx
Normal file
72
ondeck/src/app/(dashboard)/clients/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
ondeck/src/app/(dashboard)/clients/page.tsx
Normal file
81
ondeck/src/app/(dashboard)/clients/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
ondeck/src/app/(dashboard)/dashboard/page.tsx
Normal file
161
ondeck/src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
ondeck/src/app/(dashboard)/layout.tsx
Normal file
16
ondeck/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
260
ondeck/src/app/(dashboard)/manager/page.tsx
Normal file
260
ondeck/src/app/(dashboard)/manager/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
ondeck/src/app/(dashboard)/policies/[id]/page.tsx
Normal file
62
ondeck/src/app/(dashboard)/policies/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
ondeck/src/app/(dashboard)/tasks/page.tsx
Normal file
237
ondeck/src/app/(dashboard)/tasks/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
ondeck/src/app/api/admin/designations/[id]/route.ts
Normal file
220
ondeck/src/app/api/admin/designations/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
47
ondeck/src/app/api/admin/designations/preview/route.ts
Normal file
47
ondeck/src/app/api/admin/designations/preview/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
130
ondeck/src/app/api/admin/designations/route.ts
Normal file
130
ondeck/src/app/api/admin/designations/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
137
ondeck/src/app/api/admin/sync-designations/bulk/route.ts
Normal file
137
ondeck/src/app/api/admin/sync-designations/bulk/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
195
ondeck/src/app/api/admin/sync-designations/route.ts
Normal file
195
ondeck/src/app/api/admin/sync-designations/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
6
ondeck/src/app/api/auth/[...nextauth]/route.ts
Normal file
6
ondeck/src/app/api/auth/[...nextauth]/route.ts
Normal 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 }
|
||||
144
ondeck/src/app/api/clients/[id]/route.ts
Normal file
144
ondeck/src/app/api/clients/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
120
ondeck/src/app/api/clients/route.ts
Normal file
120
ondeck/src/app/api/clients/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
245
ondeck/src/app/api/dashboard/workload/route.ts
Normal file
245
ondeck/src/app/api/dashboard/workload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
133
ondeck/src/app/api/designations/[id]/route.ts
Normal file
133
ondeck/src/app/api/designations/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
89
ondeck/src/app/api/designations/route.ts
Normal file
89
ondeck/src/app/api/designations/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
27
ondeck/src/app/api/roles/route.ts
Normal file
27
ondeck/src/app/api/roles/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
117
ondeck/src/app/api/sync/route.ts
Normal file
117
ondeck/src/app/api/sync/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
36
ondeck/src/app/api/sync/status/route.ts
Normal file
36
ondeck/src/app/api/sync/status/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
132
ondeck/src/app/api/tasks/route.ts
Normal file
132
ondeck/src/app/api/tasks/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
148
ondeck/src/app/api/templates/[id]/route.ts
Normal file
148
ondeck/src/app/api/templates/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
57
ondeck/src/app/api/templates/route.ts
Normal file
57
ondeck/src/app/api/templates/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
246
ondeck/src/app/api/users/[id]/route.ts
Normal file
246
ondeck/src/app/api/users/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
148
ondeck/src/app/api/users/route.ts
Normal file
148
ondeck/src/app/api/users/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
36
ondeck/src/app/auth/error/page.tsx
Normal file
36
ondeck/src/app/auth/error/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
ondeck/src/app/auth/signin/page.tsx
Normal file
22
ondeck/src/app/auth/signin/page.tsx
Normal 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
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
153
ondeck/src/app/globals.css
Normal 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
45
ondeck/src/app/layout.tsx
Normal 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
5
ondeck/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
306
ondeck/src/components/admin/designation-form.tsx
Normal file
306
ondeck/src/components/admin/designation-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
332
ondeck/src/components/admin/designation-list.tsx
Normal file
332
ondeck/src/components/admin/designation-list.tsx
Normal 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 "{designationToDelete?.name}"?
|
||||
{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>
|
||||
)
|
||||
}
|
||||
250
ondeck/src/components/admin/shape-designation-sync.tsx
Normal file
250
ondeck/src/components/admin/shape-designation-sync.tsx
Normal 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 = '13CF7DCB-F6AF-42C2-A7AB-26641A216A81'
|
||||
and applies the selected designation to matching clients in the local database.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
ondeck/src/components/admin/sync-trigger.tsx
Normal file
176
ondeck/src/components/admin/sync-trigger.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
639
ondeck/src/components/admin/task-template-manager.tsx
Normal file
639
ondeck/src/components/admin/task-template-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
501
ondeck/src/components/admin/user-manager.tsx
Normal file
501
ondeck/src/components/admin/user-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
ondeck/src/components/auth/require-permission.tsx
Normal file
103
ondeck/src/components/auth/require-permission.tsx
Normal 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'
|
||||
108
ondeck/src/components/auth/signin-form.tsx
Normal file
108
ondeck/src/components/auth/signin-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
ondeck/src/components/clients/client-card.tsx
Normal file
161
ondeck/src/components/clients/client-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
ondeck/src/components/clients/client-detail.tsx
Normal file
255
ondeck/src/components/clients/client-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
ondeck/src/components/clients/client-list.tsx
Normal file
169
ondeck/src/components/clients/client-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
ondeck/src/components/clients/client-table.tsx
Normal file
153
ondeck/src/components/clients/client-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
444
ondeck/src/components/dashboard/workload-kpis.tsx
Normal file
444
ondeck/src/components/dashboard/workload-kpis.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
ondeck/src/components/layout/nav-bar.tsx
Normal file
105
ondeck/src/components/layout/nav-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
ondeck/src/components/manager/team-members-by-department.tsx
Normal file
144
ondeck/src/components/manager/team-members-by-department.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
289
ondeck/src/components/policies/policy-detail.tsx
Normal file
289
ondeck/src/components/policies/policy-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
ondeck/src/components/providers/session-provider.tsx
Normal file
12
ondeck/src/components/providers/session-provider.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue