mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:12:09 +01:00
Compare commits
16 Commits
a17b66c078
...
a360f461ab
| Author | SHA1 | Date | |
|---|---|---|---|
| a360f461ab | |||
| bbcdff0ffc | |||
| 940b416563 | |||
| cb86d26786 | |||
| a265f3236c | |||
| be63dba540 | |||
| 01f4dd60f9 | |||
|
9fad25e5f9
|
|||
|
13d0f8ee8d
|
|||
|
303226e3a9
|
|||
|
cbbdc8a1dc
|
|||
|
8dcb892ae9
|
|||
|
f005b2ec0a
|
|||
|
ed6e5b0c36
|
|||
|
efb5261c7d
|
|||
|
e3134aa451
|
@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1 @@
|
||||
* @kjanat
|
||||
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@ -9,18 +9,30 @@ updates:
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "03:00"
|
||||
timezone: "Europe/Amsterdam"
|
||||
|
||||
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "03:00"
|
||||
timezone: "Europe/Amsterdam"
|
||||
|
||||
- package-ecosystem: "docker" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "03:00"
|
||||
timezone: "Europe/Amsterdam"
|
||||
|
||||
- package-ecosystem: "docker-compose" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "03:00"
|
||||
timezone: "Europe/Amsterdam"
|
||||
|
||||
29
.github/workflows/playwright.yml
vendored
Normal file
29
.github/workflows/playwright.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build dashboard
|
||||
run: npm run build
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -255,3 +255,9 @@ Thumbs.db
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 80,
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -2,6 +2,7 @@
|
||||
"recommendations": [
|
||||
"prisma.prisma",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"rvest.vs-code-prettier-eslint"
|
||||
"rvest.vs-code-prettier-eslint",
|
||||
"ms-playwright.playwright"
|
||||
]
|
||||
}
|
||||
|
||||
115
README.md
Normal file
115
README.md
Normal file
@ -0,0 +1,115 @@
|
||||
# LiveDash-Node
|
||||
|
||||
A real-time analytics dashboard for monitoring user sessions and interactions with interactive data visualizations and detailed metrics.
|
||||
|
||||
.*%22&replace=%24%3Cversion%3E&logo=nextdotjs&label=Nextjs&color=%23000000)
|
||||
.*%22&replace=%24%3Cversion%3E&logo=react&label=React&color=%2361DAFB)
|
||||
.*%22&replace=%24%3Cversion%3E&logo=typescript&label=TypeScript&color=%233178C6)
|
||||
.*%22&replace=%24%3Cversion%3E&logo=prisma&label=Prisma&color=%232D3748)
|
||||
.*%22&replace=%24%3Cversion%3E&logo=tailwindcss&label=TailwindCSS&color=%2306B6D4)
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Session Monitoring**: Track and analyze user sessions as they happen
|
||||
- **Interactive Visualizations**: Geographic maps, response time distributions, and more
|
||||
- **Advanced Analytics**: Detailed metrics and insights about user behavior
|
||||
- **User Management**: Secure authentication with role-based access control
|
||||
- **Customizable Dashboard**: Filter and sort data based on your specific needs
|
||||
- **Session Details**: In-depth analysis of individual user sessions
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React 19, Next.js 15, TailwindCSS 4
|
||||
- **Backend**: Next.js API Routes, Node.js
|
||||
- **Database**: Prisma ORM with SQLite (default), compatible with PostgreSQL
|
||||
- **Authentication**: NextAuth.js
|
||||
- **Visualization**: Chart.js, D3.js, React Leaflet
|
||||
- **Data Processing**: Node-cron for scheduled tasks
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (LTS version recommended)
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone this repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kjanat/livedash-node.git
|
||||
cd livedash-node
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Set up the database:
|
||||
|
||||
```bash
|
||||
npm run prisma:generate
|
||||
npm run prisma:migrate
|
||||
npm run prisma:seed
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open your browser and navigate to <http://localhost:3000>
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Create a `.env` file in the root directory with the following variables:
|
||||
|
||||
```env
|
||||
DATABASE_URL="file:./dev.db"
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=your-secret-here
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `app/`: Next.js App Router components and pages
|
||||
- `components/`: Reusable React components
|
||||
- `lib/`: Utility functions and shared code
|
||||
- `pages/`: API routes and server-side code
|
||||
- `prisma/`: Database schema and migrations
|
||||
- `public/`: Static assets
|
||||
- `docs/`: Project documentation
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run dev`: Start the development server
|
||||
- `npm run build`: Build the application for production
|
||||
- `npm run start`: Run the production build
|
||||
- `npm run lint`: Run ESLint
|
||||
- `npm run format`: Format code with Prettier
|
||||
- `npm run prisma:studio`: Open Prisma Studio to view database
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch: `git checkout -b feature/my-new-feature`
|
||||
3. Commit your changes: `git commit -am 'Add some feature'`
|
||||
4. Push to the branch: `git push origin feature/my-new-feature`
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is not licensed for commercial use without explicit permission. Free to use for educational or personal projects.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [Next.js](https://nextjs.org/)
|
||||
- [Prisma](https://prisma.io/)
|
||||
- [TailwindCSS](https://tailwindcss.com/)
|
||||
- [Chart.js](https://www.chartjs.org/)
|
||||
- [D3.js](https://d3js.org/)
|
||||
- [React Leaflet](https://react-leaflet.js.org/)
|
||||
125
TODO.md
125
TODO.md
@ -1,45 +1,96 @@
|
||||
# Application Improvement TODOs
|
||||
# TODO.md
|
||||
|
||||
This file lists general areas for improvement and tasks that are broader in scope or don't map to a single specific file.
|
||||
## Dashboard Integration
|
||||
|
||||
## General Enhancements & Features
|
||||
|
||||
- [ ] **Real-time Updates:** Implement real-time updates for the dashboard and session list (e.g., using WebSockets or Server-Sent Events).
|
||||
- [ ] **Data Export:** Provide functionality for users (especially admins) to export session data (e.g., to CSV).
|
||||
- [ ] **Customizable Dashboard:** Allow users to customize their dashboard view, choosing which metrics or charts are most important to them.
|
||||
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation:** The `docs/dashboard-components.md` mentions these use simulated data. Investigate integrating real data sources.
|
||||
|
||||
## Robustness and Maintainability
|
||||
|
||||
- [ ] **Comprehensive Testing:**
|
||||
- [ ] Implement unit tests (e.g., for utility functions, API logic).
|
||||
- [ ] Implement integration tests (e.g., for API endpoints with the database).
|
||||
- [ ] Implement end-to-end tests (e.g., for user flows using Playwright or Cypress).
|
||||
- [ ] **Error Monitoring and Logging:** Integrate a robust error monitoring service (like Sentry) and enhance server-side logging.
|
||||
- [ ] **Accessibility (a11y):** Review and improve the application's accessibility according to WCAG guidelines (keyboard navigation, screen reader compatibility, color contrast).
|
||||
|
||||
## Security Enhancements
|
||||
|
||||
- [ ] **Password Reset Functionality:** Implement a secure password reset mechanism. (Related: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts` - ensure these are robust and secure if already implemented).
|
||||
- [ ] **Two-Factor Authentication (2FA):** Consider adding 2FA, especially for admin accounts.
|
||||
- [ ] **Input Validation and Sanitization:** Rigorously review and ensure all user inputs (API request bodies, query parameters) are validated and sanitized.
|
||||
|
||||
## Code Quality and Development Practices
|
||||
|
||||
- [ ] **Code Reviews:** Enforce code reviews for all changes.
|
||||
- [ ] **Environment Configuration:** Ensure secure and effective management of environment-specific configurations.
|
||||
- [ ] **Dependency Review:** Periodically review dependencies for vulnerabilities or updates.
|
||||
- [ ] **Documentation:**
|
||||
- Ensure `docs/dashboard-components.md` is up-to-date with actual component implementations.
|
||||
- Verify that "Dashboard Enhancements" (Improved Layout, Visual Hierarchies, Color Coding) are consistently applied.
|
||||
- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation**
|
||||
- Investigate integrating real data sources with server-side analytics
|
||||
- Replace simulated data mentioned in `docs/dashboard-components.md`
|
||||
|
||||
## Component Specific
|
||||
|
||||
- [ ] **`components/SessionDetails.tsx.new`:** Review, complete TODOs within the file, and integrate as the primary `SessionDetails.tsx` component, removing/archiving older versions (`SessionDetails.tsx`, `SessionDetails.tsx.bak`).
|
||||
- [ ] **`components/GeographicMap.tsx`:** Check if `GeographicMap.tsx.bak` is still needed or can be removed.
|
||||
- [ ] **`app/dashboard/sessions/page.tsx`:** Implement pagination, advanced filtering, and sorting.
|
||||
- [ ] **`pages/api/dashboard/users.ts`:** Implement robust emailing of temporary passwords.
|
||||
- [ ] **Implement robust emailing of temporary passwords**
|
||||
- File: `pages/api/dashboard/users.ts`
|
||||
- Set up proper email service integration
|
||||
|
||||
- [x] **Session page improvements** ✅
|
||||
- File: `app/dashboard/sessions/page.tsx`
|
||||
- Implemented pagination, advanced filtering, and sorting
|
||||
|
||||
## File Cleanup
|
||||
|
||||
- [ ] Review and remove `.bak` and `.new` files once changes are integrated (e.g., `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`).
|
||||
- [x] **Remove backup files** ✅
|
||||
- Reviewed and removed `.bak` and `.new` files after integration
|
||||
- Cleaned up `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`
|
||||
|
||||
## Database Schema Improvements
|
||||
|
||||
- [ ] **Update EndTime field**
|
||||
- Make `endTime` field nullable in Prisma schema to match TypeScript interfaces
|
||||
|
||||
- [ ] **Add database indices**
|
||||
- Add appropriate indices to improve query performance
|
||||
- Focus on dashboard metrics and session listing queries
|
||||
|
||||
- [ ] **Implement production email service**
|
||||
- Replace console logging in `lib/sendEmail.ts`
|
||||
- Consider providers: Nodemailer, SendGrid, AWS SES
|
||||
|
||||
## General Enhancements & Features
|
||||
|
||||
- [ ] **Real-time updates**
|
||||
- Implement for dashboard and session list
|
||||
- Consider WebSockets or Server-Sent Events
|
||||
|
||||
- [ ] **Data export functionality**
|
||||
- Allow users (especially admins) to export session data
|
||||
- Support CSV format initially
|
||||
|
||||
- [ ] **Customizable dashboard**
|
||||
- Allow users to customize dashboard view
|
||||
- Let users choose which metrics/charts are most important
|
||||
|
||||
## Testing & Quality Assurance
|
||||
|
||||
- [ ] **Comprehensive testing suite**
|
||||
- [ ] Unit tests for utility functions and API logic
|
||||
- [ ] Integration tests for API endpoints with database
|
||||
- [ ] End-to-end tests for user flows (Playwright or Cypress)
|
||||
|
||||
- [ ] **Error monitoring and logging**
|
||||
- Integrate robust error monitoring service (Sentry)
|
||||
- Enhance server-side logging
|
||||
|
||||
- [ ] **Accessibility improvements**
|
||||
- Review application against WCAG guidelines
|
||||
- Improve keyboard navigation and screen reader compatibility
|
||||
- Check color contrast ratios
|
||||
|
||||
## Security Enhancements
|
||||
|
||||
- [x] **Password reset functionality** ✅
|
||||
- Implemented secure password reset mechanism
|
||||
- Files: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts`
|
||||
|
||||
- [ ] **Two-Factor Authentication (2FA)**
|
||||
- Consider adding 2FA, especially for admin accounts
|
||||
|
||||
- [ ] **Input validation and sanitization**
|
||||
- Review all user inputs (API request bodies, query parameters)
|
||||
- Ensure proper validation and sanitization
|
||||
|
||||
## Code Quality & Development
|
||||
|
||||
- [ ] **Code review process**
|
||||
- Enforce code reviews for all changes
|
||||
|
||||
- [ ] **Environment configuration**
|
||||
- Ensure secure management of environment-specific configurations
|
||||
|
||||
- [ ] **Dependency management**
|
||||
- Periodically review dependencies for vulnerabilities
|
||||
- Keep dependencies updated
|
||||
|
||||
- [ ] **Documentation updates**
|
||||
- [ ] Ensure `docs/dashboard-components.md` reflects actual implementations
|
||||
- [ ] Verify "Dashboard Enhancements" are consistently applied
|
||||
- [ ] Update documentation for improved layout and visual hierarchies
|
||||
|
||||
183
app/dashboard/company/page.tsx
Normal file
183
app/dashboard/company/page.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Company } from "../../../lib/types";
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
// We store the full company object for future use and updates after save operations
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||
const [csvUsername, setCsvUsername] = useState<string>("");
|
||||
const [csvPassword, setCsvPassword] = useState<string>("");
|
||||
const [sentimentThreshold, setSentimentThreshold] = useState<string>("");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
const fetchCompany = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/config");
|
||||
const data = await res.json();
|
||||
setCompany(data.company);
|
||||
setCsvUrl(data.company.csvUrl || "");
|
||||
setCsvUsername(data.company.csvUsername || "");
|
||||
setSentimentThreshold(data.company.sentimentAlert?.toString() || "");
|
||||
if (data.company.csvPassword) {
|
||||
setCsvPassword(data.company.csvPassword);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch company settings:", error);
|
||||
setMessage("Failed to load company settings.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCompany();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
async function handleSave() {
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
csvUrl,
|
||||
csvUsername,
|
||||
csvPassword,
|
||||
sentimentThreshold,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage("Settings saved successfully!");
|
||||
// Update local state if needed
|
||||
const data = await res.json();
|
||||
setCompany(data.company);
|
||||
} else {
|
||||
const error = await res.json();
|
||||
setMessage(
|
||||
`Failed to save settings: ${error.message || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage("Failed to save settings. Please try again.");
|
||||
console.error("Error saving settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">Loading settings...</div>;
|
||||
}
|
||||
|
||||
// Check for admin access
|
||||
if (session?.user?.role !== "admin") {
|
||||
return (
|
||||
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||
<p>You don't have permission to view company settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||
Company Settings
|
||||
</h1>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="grid gap-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<label className="font-medium text-gray-700">
|
||||
CSV Data Source URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
value={csvUrl}
|
||||
onChange={(e) => setCsvUrl(e.target.value)}
|
||||
placeholder="https://example.com/data.csv"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="font-medium text-gray-700">CSV Username</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
value={csvUsername}
|
||||
onChange={(e) => setCsvUsername(e.target.value)}
|
||||
placeholder="Username for CSV access (if needed)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="font-medium text-gray-700">CSV Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
value={csvPassword}
|
||||
onChange={(e) => setCsvPassword(e.target.value)}
|
||||
placeholder="Password will be updated only if provided"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
Leave blank to keep current password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="font-medium text-gray-700">
|
||||
Sentiment Alert Threshold
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
value={sentimentThreshold}
|
||||
onChange={(e) => setSentimentThreshold(e.target.value)}
|
||||
placeholder="Threshold value (0-100)"
|
||||
min="0"
|
||||
max="100"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
Percentage of negative sentiment sessions to trigger alert (0-100)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors w-full sm:w-auto"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
app/dashboard/layout.tsx
Normal file
82
app/dashboard/layout.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState, useEffect, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Sidebar from "../../components/Sidebar";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const { status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const [isSidebarExpanded, setIsSidebarExpanded] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateStatesBasedOnScreen = () => {
|
||||
const screenIsMobile = window.innerWidth < 640; // sm breakpoint for mobile
|
||||
const screenIsSmallDesktop = window.innerWidth < 768 && !screenIsMobile; // between sm and md
|
||||
|
||||
setIsMobile(screenIsMobile);
|
||||
setIsSidebarExpanded(!screenIsSmallDesktop && !screenIsMobile);
|
||||
};
|
||||
|
||||
updateStatesBasedOnScreen();
|
||||
window.addEventListener("resize", updateStatesBasedOnScreen);
|
||||
return () =>
|
||||
window.removeEventListener("resize", updateStatesBasedOnScreen);
|
||||
}, []);
|
||||
|
||||
// Toggle sidebar handler - used for clicking the toggle button
|
||||
const toggleSidebarHandler = useCallback(() => {
|
||||
setIsSidebarExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Collapse sidebar handler - used when clicking navigation links on mobile
|
||||
const collapseSidebar = useCallback(() => {
|
||||
if (isMobile) {
|
||||
setIsSidebarExpanded(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">Redirecting to login...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">Loading session...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<Sidebar
|
||||
isExpanded={isSidebarExpanded}
|
||||
isMobile={isMobile}
|
||||
onToggle={toggleSidebarHandler}
|
||||
onNavigate={collapseSidebar}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
|
||||
${
|
||||
isSidebarExpanded
|
||||
? "pl-4 sm:pl-6 md:pl-10"
|
||||
: "pl-20 sm:pl-20 md:pl-6"
|
||||
}
|
||||
sm:pr-6 md:py-6 md:pr-10`}
|
||||
>
|
||||
{/* <div className="w-full mx-auto">{children}</div> */}
|
||||
<div className="max-w-7xl mx-auto">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
429
app/dashboard/overview/page.tsx
Normal file
429
app/dashboard/overview/page.tsx
Normal file
@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SessionsLineChart,
|
||||
CategoriesBarChart,
|
||||
LanguagePieChart,
|
||||
TokenUsageChart,
|
||||
} from "../../../components/Charts";
|
||||
import { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
||||
import MetricCard from "../../../components/MetricCard";
|
||||
import DonutChart from "../../../components/DonutChart";
|
||||
import WordCloud from "../../../components/WordCloud";
|
||||
import GeographicMap from "../../../components/GeographicMap";
|
||||
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||
import WelcomeBanner from "../../../components/WelcomeBanner";
|
||||
|
||||
// Safely wrapped component with useSession
|
||||
function DashboardContent() {
|
||||
const { data: session, status } = useSession(); // Add status from useSession
|
||||
const router = useRouter(); // Initialize useRouter
|
||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [, setLoading] = useState<boolean>(false);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
|
||||
const isAuditor = session?.user?.role === "auditor";
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect if not authenticated
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return; // Stop further execution in this effect
|
||||
}
|
||||
|
||||
// Fetch metrics and company on mount if authenticated
|
||||
if (status === "authenticated") {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/dashboard/metrics");
|
||||
const data = await res.json();
|
||||
console.log("Metrics from API:", {
|
||||
avgSessionLength: data.metrics.avgSessionLength,
|
||||
avgSessionTimeTrend: data.metrics.avgSessionTimeTrend,
|
||||
totalSessionDuration: data.metrics.totalSessionDuration,
|
||||
validSessionsForDuration: data.metrics.validSessionsForDuration,
|
||||
});
|
||||
setMetrics(data.metrics);
|
||||
setCompany(data.company);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}
|
||||
}, [status, router]); // Add status and router to dependency array
|
||||
|
||||
async function handleRefresh() {
|
||||
if (isAuditor) return; // Prevent auditors from refreshing
|
||||
try {
|
||||
setRefreshing(true);
|
||||
|
||||
// Make sure we have a company ID to send
|
||||
if (!company?.id) {
|
||||
setRefreshing(false);
|
||||
alert("Cannot refresh: Company ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/admin/refresh-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Refetch metrics
|
||||
const metricsRes = await fetch("/api/dashboard/metrics");
|
||||
const data = await metricsRes.json();
|
||||
setMetrics(data.metrics);
|
||||
} else {
|
||||
const errorData = await res.json();
|
||||
alert(`Failed to refresh sessions: ${errorData.error}`);
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate sentiment distribution
|
||||
const getSentimentData = () => {
|
||||
if (!metrics) return { positive: 0, neutral: 0, negative: 0 };
|
||||
|
||||
if (
|
||||
metrics.sentimentPositiveCount !== undefined &&
|
||||
metrics.sentimentNeutralCount !== undefined &&
|
||||
metrics.sentimentNegativeCount !== undefined
|
||||
) {
|
||||
return {
|
||||
positive: metrics.sentimentPositiveCount,
|
||||
neutral: metrics.sentimentNeutralCount,
|
||||
negative: metrics.sentimentNegativeCount,
|
||||
};
|
||||
}
|
||||
|
||||
const total = metrics.totalSessions || 1;
|
||||
return {
|
||||
positive: Math.round(total * 0.6),
|
||||
neutral: Math.round(total * 0.3),
|
||||
negative: Math.round(total * 0.1),
|
||||
};
|
||||
};
|
||||
|
||||
// Prepare token usage data
|
||||
const getTokenData = () => {
|
||||
if (!metrics || !metrics.tokensByDay) {
|
||||
return { labels: [], values: [], costs: [] };
|
||||
}
|
||||
|
||||
const days = Object.keys(metrics.tokensByDay).sort();
|
||||
const labels = days.slice(-7);
|
||||
const values = labels.map((day) => metrics.tokensByDay?.[day] || 0);
|
||||
const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0);
|
||||
|
||||
return { labels, values, costs };
|
||||
};
|
||||
|
||||
// Show loading state while session status is being determined
|
||||
if (status === "loading") {
|
||||
return <div className="text-center py-10">Loading session...</div>;
|
||||
}
|
||||
|
||||
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
||||
if (status === "unauthenticated") {
|
||||
return <div className="text-center py-10">Redirecting to login...</div>;
|
||||
}
|
||||
|
||||
if (!metrics || !company) {
|
||||
return <div className="text-center py-10">Loading dashboard...</div>;
|
||||
}
|
||||
|
||||
// Function to prepare word cloud data from metrics.wordCloudData
|
||||
const getWordCloudData = (): WordCloudWord[] => {
|
||||
if (!metrics || !metrics.wordCloudData) return [];
|
||||
return metrics.wordCloudData;
|
||||
};
|
||||
|
||||
// Function to prepare country data for the map using actual metrics
|
||||
const getCountryData = () => {
|
||||
if (!metrics || !metrics.countries) return {};
|
||||
|
||||
// Convert the countries object from metrics to the format expected by GeographicMap
|
||||
const result = Object.entries(metrics.countries).reduce(
|
||||
(acc, [code, count]) => {
|
||||
if (code && count) {
|
||||
acc[code] = count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Function to prepare response time distribution data
|
||||
const getResponseTimeData = () => {
|
||||
const avgTime = metrics.avgResponseTime || 1.5;
|
||||
const simulatedData: number[] = [];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const randomFactor = 0.5 + Math.random();
|
||||
simulatedData.push(avgTime * randomFactor);
|
||||
}
|
||||
|
||||
return simulatedData;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<WelcomeBanner companyName={company.name} />
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Dashboard updated{" "}
|
||||
<span className="font-medium text-slate-600">
|
||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
||||
<button
|
||||
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || isAuditor}
|
||||
>
|
||||
{refreshing ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
"Refresh Data"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="Total Sessions"
|
||||
value={metrics.totalSessions}
|
||||
icon={
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
trend={{
|
||||
value: metrics.sessionTrend ?? 0,
|
||||
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
||||
}}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Unique Users"
|
||||
value={metrics.uniqueUsers}
|
||||
icon={
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
trend={{
|
||||
value: metrics.usersTrend ?? 0,
|
||||
isPositive: (metrics.usersTrend ?? 0) >= 0,
|
||||
}}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg. Session Time"
|
||||
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
||||
icon={
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
trend={{
|
||||
value: metrics.avgSessionTimeTrend ?? 0,
|
||||
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
||||
}}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg. Response Time"
|
||||
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
||||
icon={
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
trend={{
|
||||
value: metrics.avgResponseTimeTrend ?? 0,
|
||||
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Sessions Over Time
|
||||
</h3>
|
||||
<SessionsLineChart sessionsPerDay={metrics.days} />
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Conversation Sentiment
|
||||
</h3>
|
||||
<DonutChart
|
||||
data={{
|
||||
labels: ["Positive", "Neutral", "Negative"],
|
||||
values: [
|
||||
getSentimentData().positive,
|
||||
getSentimentData().neutral,
|
||||
getSentimentData().negative,
|
||||
],
|
||||
colors: ["#1cad7c", "#a1a1a1", "#dc2626"],
|
||||
}}
|
||||
centerText={{
|
||||
title: "Total",
|
||||
value: metrics.totalSessions,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Sessions by Category
|
||||
</h3>
|
||||
<CategoriesBarChart categories={metrics.categories || {}} />
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Languages Used
|
||||
</h3>
|
||||
<LanguagePieChart languages={metrics.languages || {}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Geographic Distribution
|
||||
</h3>
|
||||
<GeographicMap countries={getCountryData()} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Common Topics
|
||||
</h3>
|
||||
<div className="h-[300px]">
|
||||
<WordCloud words={getWordCloudData()} width={500} height={400} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Response Time Distribution
|
||||
</h3>
|
||||
<ResponseTimeDistribution
|
||||
data={getResponseTimeData()}
|
||||
average={metrics.avgResponseTime || 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
||||
<h3 className="font-bold text-lg text-gray-800">
|
||||
Token Usage & Costs
|
||||
</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 w-full sm:w-auto">
|
||||
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
||||
<span className="font-semibold mr-1">Total Tokens:</span>
|
||||
{metrics.totalTokens?.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
||||
<span className="font-semibold mr-1">Total Cost:</span>€
|
||||
{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TokenUsageChart tokenData={getTokenData()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Our exported component
|
||||
export default function DashboardPage() {
|
||||
return <DashboardContent />;
|
||||
}
|
||||
@ -1,440 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation"; // Import useRouter
|
||||
import {
|
||||
SessionsLineChart,
|
||||
CategoriesBarChart,
|
||||
LanguagePieChart,
|
||||
TokenUsageChart,
|
||||
} from "../../components/Charts";
|
||||
import DashboardSettings from "./settings";
|
||||
import UserManagement from "./users";
|
||||
import { Company, MetricsResult, WordCloudWord } from "../../lib/types"; // Added WordCloudWord
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import DonutChart from "../../components/DonutChart";
|
||||
import WordCloud from "../../components/WordCloud";
|
||||
import GeographicMap from "../../components/GeographicMap";
|
||||
import ResponseTimeDistribution from "../../components/ResponseTimeDistribution";
|
||||
import WelcomeBanner from "../../components/WelcomeBanner";
|
||||
import { FC } from "react";
|
||||
|
||||
// Safely wrapped component with useSession
|
||||
function DashboardContent() {
|
||||
const { data: session, status } = useSession(); // Add status from useSession
|
||||
const router = useRouter(); // Initialize useRouter
|
||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [, setLoading] = useState<boolean>(false);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
|
||||
const isAdmin = session?.user?.role === "admin";
|
||||
const isAuditor = session?.user?.role === "auditor";
|
||||
const DashboardPage: FC = () => {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect if not authenticated
|
||||
// Once session is loaded, redirect appropriately
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return; // Stop further execution in this effect
|
||||
} else if (status === "authenticated") {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Fetch metrics and company on mount if authenticated
|
||||
if (status === "authenticated") {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/dashboard/metrics");
|
||||
const data = await res.json();
|
||||
setMetrics(data.metrics);
|
||||
setCompany(data.company);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}
|
||||
}, [status, router]); // Add status and router to dependency array
|
||||
|
||||
async function handleRefresh() {
|
||||
if (isAuditor) return; // Prevent auditors from refreshing
|
||||
try {
|
||||
setRefreshing(true);
|
||||
|
||||
// Make sure we have a company ID to send
|
||||
if (!company?.id) {
|
||||
setRefreshing(false);
|
||||
alert("Cannot refresh: Company ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/admin/refresh-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Refetch metrics
|
||||
const metricsRes = await fetch("/api/dashboard/metrics");
|
||||
const data = await metricsRes.json();
|
||||
setMetrics(data.metrics);
|
||||
} else {
|
||||
const errorData = await res.json();
|
||||
alert(`Failed to refresh sessions: ${errorData.error}`);
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate sentiment distribution
|
||||
const getSentimentData = () => {
|
||||
if (!metrics) return { positive: 0, neutral: 0, negative: 0 };
|
||||
|
||||
if (
|
||||
metrics.sentimentPositiveCount !== undefined &&
|
||||
metrics.sentimentNeutralCount !== undefined &&
|
||||
metrics.sentimentNegativeCount !== undefined
|
||||
) {
|
||||
return {
|
||||
positive: metrics.sentimentPositiveCount,
|
||||
neutral: metrics.sentimentNeutralCount,
|
||||
negative: metrics.sentimentNegativeCount,
|
||||
};
|
||||
}
|
||||
|
||||
const total = metrics.totalSessions || 1;
|
||||
return {
|
||||
positive: Math.round(total * 0.6),
|
||||
neutral: Math.round(total * 0.3),
|
||||
negative: Math.round(total * 0.1),
|
||||
};
|
||||
};
|
||||
|
||||
// Prepare token usage data
|
||||
const getTokenData = () => {
|
||||
if (!metrics || !metrics.tokensByDay) {
|
||||
return { labels: [], values: [], costs: [] };
|
||||
}
|
||||
|
||||
const days = Object.keys(metrics.tokensByDay).sort();
|
||||
const labels = days.slice(-7);
|
||||
const values = labels.map((day) => metrics.tokensByDay?.[day] || 0);
|
||||
const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0);
|
||||
|
||||
return { labels, values, costs };
|
||||
};
|
||||
|
||||
// Show loading state while session status is being determined
|
||||
if (status === "loading") {
|
||||
return <div className="text-center py-10">Loading session...</div>;
|
||||
}
|
||||
|
||||
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
||||
if (status === "unauthenticated") {
|
||||
return <div className="text-center py-10">Redirecting to login...</div>;
|
||||
}
|
||||
|
||||
if (!metrics || !company) {
|
||||
return <div className="text-center py-10">Loading dashboard...</div>;
|
||||
}
|
||||
|
||||
// Function to prepare word cloud data from metrics.wordCloudData
|
||||
const getWordCloudData = (): WordCloudWord[] => {
|
||||
if (!metrics || !metrics.wordCloudData) return [];
|
||||
return metrics.wordCloudData;
|
||||
};
|
||||
|
||||
// Function to prepare country data for the map using actual metrics
|
||||
const getCountryData = () => {
|
||||
if (!metrics || !metrics.countries) return {};
|
||||
|
||||
// Convert the countries object from metrics to the format expected by GeographicMap
|
||||
const result = Object.entries(metrics.countries).reduce(
|
||||
(acc, [code, count]) => {
|
||||
if (code && count) {
|
||||
acc[code] = count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div>
|
||||
<p className="text-lg text-gray-600">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Function to prepare response time distribution data
|
||||
const getResponseTimeData = () => {
|
||||
const avgTime = metrics.avgResponseTime || 1.5;
|
||||
const simulatedData: number[] = [];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const randomFactor = 0.5 + Math.random();
|
||||
simulatedData.push(avgTime * randomFactor);
|
||||
}
|
||||
|
||||
return simulatedData;
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<WelcomeBanner companyName={company.name} />
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Dashboard updated{" "}
|
||||
<span className="font-medium text-slate-600">
|
||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
||||
<button
|
||||
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || isAuditor}
|
||||
>
|
||||
{refreshing ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Refresh Data
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2>
|
||||
<p className="text-gray-600 mt-2 mb-4">
|
||||
View your chat session metrics and analytics
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard/overview")}
|
||||
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="Total Sessions"
|
||||
value={metrics.totalSessions.toLocaleString()}
|
||||
icon="💬"
|
||||
variant="primary"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Sessions/Day"
|
||||
value={metrics.avgSessionsPerDay?.toFixed(1) || 0}
|
||||
icon="📊"
|
||||
trend={{ value: 5.2, label: "vs last week" }}
|
||||
variant="success"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Session Time"
|
||||
value={
|
||||
metrics.avgSessionLength
|
||||
? `${metrics.avgSessionLength.toFixed(1)} min`
|
||||
: "-"
|
||||
}
|
||||
icon="⏱️"
|
||||
trend={{ value: -2.1, label: "vs last week", isPositive: false }}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Response Time"
|
||||
value={
|
||||
metrics.avgResponseTime
|
||||
? `${metrics.avgResponseTime.toFixed(2)}s`
|
||||
: "-"
|
||||
}
|
||||
icon="⚡"
|
||||
trend={{ value: -1.8, label: "vs last week", isPositive: true }}
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-1">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Sentiment Distribution
|
||||
</h3>
|
||||
<DonutChart
|
||||
data={{
|
||||
labels: ["Positive", "Neutral", "Negative"],
|
||||
values: [
|
||||
getSentimentData().positive,
|
||||
getSentimentData().neutral,
|
||||
getSentimentData().negative,
|
||||
],
|
||||
colors: [
|
||||
"rgba(34, 197, 94, 0.8)",
|
||||
"rgba(249, 115, 22, 0.8)",
|
||||
"rgba(239, 68, 68, 0.8)",
|
||||
],
|
||||
}}
|
||||
centerText={{
|
||||
title: "Overall",
|
||||
value: `${((getSentimentData().positive / (getSentimentData().positive + getSentimentData().neutral + getSentimentData().negative)) * 100).toFixed(0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Case Handling Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<MetricCard
|
||||
title="Escalation Rate"
|
||||
value={`${(((metrics.escalatedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
||||
description={`${metrics.escalatedCount || 0} sessions escalated`}
|
||||
icon="⚠️"
|
||||
variant={
|
||||
(metrics.escalatedCount || 0) > metrics.totalSessions * 0.1
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="HR Forwarded"
|
||||
value={`${(((metrics.forwardedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
||||
description={`${metrics.forwardedCount || 0} sessions forwarded to HR`}
|
||||
icon="👥"
|
||||
variant={
|
||||
(metrics.forwardedCount || 0) > metrics.totalSessions * 0.05
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Resolved Rate"
|
||||
value={`${(((metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)) / metrics.totalSessions) * 100).toFixed(1)}%`}
|
||||
description={`${metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)} sessions resolved`}
|
||||
icon="✅"
|
||||
variant="success"
|
||||
/>
|
||||
View Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Sessions by Day
|
||||
</h3>
|
||||
<SessionsLineChart sessionsPerDay={metrics.days || {}} />
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Top Categories
|
||||
</h3>
|
||||
<CategoriesBarChart categories={metrics.categories || {}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow overflow-hidden">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Transcript Word Cloud
|
||||
</h3>
|
||||
<WordCloud words={getWordCloudData()} width={400} height={300} />
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Geographic Distribution
|
||||
</h3>
|
||||
<GeographicMap countries={getCountryData()} height={300} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||
Response Time Distribution
|
||||
</h3>
|
||||
<ResponseTimeDistribution
|
||||
responseTimes={getResponseTimeData()}
|
||||
targetResponseTime={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h3 className="font-bold text-lg text-gray-800 mb-4">Languages</h3>
|
||||
<LanguagePieChart languages={metrics.languages || {}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-bold text-lg text-gray-800">
|
||||
Token Usage & Costs
|
||||
</h3>
|
||||
<div className="flex gap-4">
|
||||
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
||||
<span className="font-semibold mr-1">Total Tokens:</span>
|
||||
{metrics.totalTokens?.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
||||
<span className="font-semibold mr-1">Total Cost:</span>€
|
||||
{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TokenUsageChart tokenData={getTokenData()} />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DashboardSettings company={company} session={session} />
|
||||
<UserManagement session={session} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Our exported component
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-100 p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<DashboardContent />
|
||||
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
|
||||
<p className="text-gray-600 mt-2 mb-4">
|
||||
Browse and analyze conversation sessions
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard/sessions")}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
View Sessions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{session?.user?.role === "admin" && (
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 className="text-lg font-semibold text-purple-700">
|
||||
Company Settings
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-2 mb-4">
|
||||
Configure company settings and integrations
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard/company")}
|
||||
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Manage Settings
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session?.user?.role === "admin" && (
|
||||
<div className="bg-gradient-to-br from-amber-50 to-amber-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 className="text-lg font-semibold text-amber-700">
|
||||
User Management
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-2 mb-4">
|
||||
Invite and manage user accounts
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard/users")}
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Manage Users
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
@ -25,7 +25,7 @@ export default function SessionViewPage() {
|
||||
|
||||
if (status === "authenticated" && id) {
|
||||
const fetchSession = async () => {
|
||||
if (!session) setLoading(true);
|
||||
setLoading(true); // Always set loading before fetch
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||
@ -52,7 +52,7 @@ export default function SessionViewPage() {
|
||||
setError("Session ID is missing.");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, status, router, session]);
|
||||
}, [id, status, router]); // session removed from dependencies
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
|
||||
@ -40,7 +40,7 @@ export default function SessionsPage() {
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const [pageSize, setPageSize] = useState(10); // Or make this configurable
|
||||
|
||||
useEffect(() => {
|
||||
@ -283,8 +283,12 @@ export default function SessionsPage() {
|
||||
Session ID: {session.sessionId || session.id}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-1">
|
||||
Start Time: {new Date(session.startTime).toLocaleString()}
|
||||
Start Time{/* (Local) */}:{" "}
|
||||
{new Date(session.startTime).toLocaleString()}
|
||||
</p>
|
||||
{/* <p className="text-xs text-gray-400 mb-1">
|
||||
Start Time (Raw API): {session.startTime.toString()}
|
||||
</p> */}
|
||||
{session.category && (
|
||||
<p className="text-sm text-gray-700">
|
||||
Category:{" "}
|
||||
|
||||
212
app/dashboard/users/page.tsx
Normal file
212
app/dashboard/users/page.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [role, setRole] = useState<string>("user");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/users");
|
||||
const data = await res.json();
|
||||
setUsers(data.users);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
setMessage("Failed to load users.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function inviteUser() {
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, role }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage("User invited successfully!");
|
||||
setEmail(""); // Clear the form
|
||||
// Refresh the user list
|
||||
fetchUsers();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
setMessage(
|
||||
`Failed to invite user: ${error.message || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage("Failed to invite user. Please try again.");
|
||||
console.error("Error inviting user:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">Loading users...</div>;
|
||||
}
|
||||
|
||||
// Check for admin access
|
||||
if (session?.user?.role !== "admin") {
|
||||
return (
|
||||
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||
<p>You don't have permission to view user management.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||
User Management
|
||||
</h1>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-4">Invite New User</h2>
|
||||
<form
|
||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
inviteUser();
|
||||
}}
|
||||
autoComplete="off" // Disable autofill for the form
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<label className="font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
placeholder="user@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="off" // Disable autofill for this input
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="font-medium text-gray-700">Role</label>
|
||||
<select
|
||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500 bg-white"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="auditor">Auditor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors"
|
||||
>
|
||||
Invite User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Current Users</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-6 py-4 text-center text-sm text-gray-500"
|
||||
>
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.role === "admin"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: user.role === "auditor"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-green-100 text-green-800"
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{/* For future: Add actions like edit, delete, etc. */}
|
||||
<span className="text-gray-400">
|
||||
No actions available
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1 @@
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@ -21,9 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gray-100 min-h-screen font-sans">
|
||||
<Providers>
|
||||
<div className="max-w-5xl mx-auto py-8">{children}</div>
|
||||
</Providers>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import Chart from "chart.js/auto";
|
||||
import Chart, { Point, BubbleDataPoint } from "chart.js/auto";
|
||||
|
||||
interface DonutChartProps {
|
||||
data: {
|
||||
@ -77,9 +77,24 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
|
||||
const label = context.label || "";
|
||||
const value = context.formattedValue;
|
||||
const total = context.chart.data.datasets[0].data.reduce(
|
||||
(a: number, b: any) => a + (typeof b === "number" ? b : 0),
|
||||
(
|
||||
a: number,
|
||||
b:
|
||||
| number
|
||||
| Point
|
||||
| [number, number]
|
||||
| BubbleDataPoint
|
||||
| null
|
||||
) => {
|
||||
if (typeof b === "number") {
|
||||
return a + b;
|
||||
}
|
||||
// Handle other types like Point, [number, number], BubbleDataPoint if necessary
|
||||
// For now, we'll assume they don't contribute to the sum or are handled elsewhere
|
||||
return a;
|
||||
},
|
||||
0
|
||||
);
|
||||
) as number;
|
||||
const percentage = Math.round((context.parsed * 100) / total);
|
||||
return `${label}: ${value} (${percentage}%)`;
|
||||
},
|
||||
@ -91,7 +106,7 @@ export default function DonutChart({ data, centerText }: DonutChartProps) {
|
||||
? [
|
||||
{
|
||||
id: "centerText",
|
||||
beforeDraw: function (chart: any) {
|
||||
beforeDraw: function (chart: Chart<"doughnut">) {
|
||||
const height = chart.height;
|
||||
const ctx = chart.ctx;
|
||||
ctx.restore();
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import countryLookup from "country-code-lookup";
|
||||
import * as countryCoder from "@rapideditor/country-coder";
|
||||
|
||||
// Define types for country data
|
||||
interface CountryData {
|
||||
@ -18,36 +18,17 @@ interface GeographicMapProps {
|
||||
height?: number; // Optional height for the container
|
||||
}
|
||||
|
||||
// Get country coordinates from the country-code-lookup package
|
||||
// Get country coordinates from the @rapideditor/country-coder package
|
||||
const getCountryCoordinates = (): Record<string, [number, number]> => {
|
||||
// Initialize with some fallback coordinates for common countries that might be missing
|
||||
// Initialize with some fallback coordinates for common countries
|
||||
const coordinates: Record<string, [number, number]> = {
|
||||
// These are just in case the lookup fails for common countries
|
||||
US: [37.0902, -95.7129],
|
||||
GB: [55.3781, -3.436],
|
||||
BA: [43.9159, 17.6791],
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all countries from the package
|
||||
const allCountries = countryLookup.countries;
|
||||
|
||||
// Map through all countries and extract coordinates
|
||||
allCountries.forEach((country) => {
|
||||
if (country.iso2 && country.latitude && country.longitude) {
|
||||
coordinates[country.iso2] = [
|
||||
parseFloat(country.latitude),
|
||||
parseFloat(country.longitude),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return coordinates;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error loading country coordinates:", error);
|
||||
return coordinates;
|
||||
}
|
||||
// This function now primarily returns fallbacks.
|
||||
// The actual fetching using @rapideditor/country-coder will be in the component's useEffect.
|
||||
return coordinates;
|
||||
};
|
||||
|
||||
// Load coordinates once when module is imported
|
||||
@ -79,44 +60,68 @@ export default function GeographicMap({
|
||||
|
||||
// Process country data when client is ready and dependencies change
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
if (!isClient || !countries) return;
|
||||
|
||||
try {
|
||||
// Generate CountryData array for the Map component
|
||||
const data: CountryData[] = Object.entries(countries)
|
||||
// Only include countries with known coordinates
|
||||
.filter(([code]) => {
|
||||
// If no coordinates found, log to help with debugging
|
||||
if (!countryCoordinates[code] && !DEFAULT_COORDINATES[code]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Missing coordinates for country code: ${code}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(([code, count]) => ({
|
||||
code,
|
||||
count,
|
||||
coordinates: countryCoordinates[code] ||
|
||||
DEFAULT_COORDINATES[code] || [0, 0],
|
||||
}));
|
||||
const data: CountryData[] = Object.entries(countries || {})
|
||||
.map(([code, count]) => {
|
||||
let countryCoords: [number, number] | undefined =
|
||||
countryCoordinates[code] || DEFAULT_COORDINATES[code];
|
||||
|
||||
if (!countryCoords) {
|
||||
const feature = countryCoder.feature(code);
|
||||
if (feature && feature.geometry) {
|
||||
if (feature.geometry.type === "Point") {
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||
} else if (
|
||||
feature.geometry.type === "Polygon" &&
|
||||
feature.geometry.coordinates &&
|
||||
feature.geometry.coordinates[0] &&
|
||||
feature.geometry.coordinates[0][0]
|
||||
) {
|
||||
// For Polygons, use the first coordinate of the first ring as a fallback representative point
|
||||
const [lon, lat] = feature.geometry.coordinates[0][0];
|
||||
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||
} else if (
|
||||
feature.geometry.type === "MultiPolygon" &&
|
||||
feature.geometry.coordinates &&
|
||||
feature.geometry.coordinates[0] &&
|
||||
feature.geometry.coordinates[0][0] &&
|
||||
feature.geometry.coordinates[0][0][0]
|
||||
) {
|
||||
// For MultiPolygons, use the first coordinate of the first ring of the first polygon
|
||||
const [lon, lat] = feature.geometry.coordinates[0][0][0];
|
||||
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (countryCoords) {
|
||||
return {
|
||||
code,
|
||||
count,
|
||||
coordinates: countryCoords,
|
||||
};
|
||||
}
|
||||
return null; // Skip if no coordinates found
|
||||
})
|
||||
.filter((item): item is CountryData => item !== null);
|
||||
|
||||
// Log for debugging
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
||||
);
|
||||
|
||||
setCountryData(data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error processing geographic data:", error);
|
||||
setCountryData([]);
|
||||
}
|
||||
}, [countries, countryCoordinates, isClient]);
|
||||
|
||||
// Find the max count for scaling circles - handle empty countries object
|
||||
const countryValues = Object.values(countries);
|
||||
// Find the max count for scaling circles - handle empty or null countries object
|
||||
const countryValues = countries ? Object.values(countries) : [];
|
||||
const maxCount = countryValues.length > 0 ? Math.max(...countryValues, 1) : 1;
|
||||
|
||||
// Show loading state during SSR or until client-side rendering takes over
|
||||
|
||||
@ -4,7 +4,7 @@ interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number | null | undefined;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
icon?: React.ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
label?: string;
|
||||
@ -67,9 +67,6 @@ export default function MetricCard({
|
||||
>
|
||||
{trend.isPositive !== false ? "↑" : "↓"}{" "}
|
||||
{Math.abs(trend.value).toFixed(1)}%
|
||||
{trend.label && (
|
||||
<span className="text-gray-500 ml-1">{trend.label}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -7,28 +7,30 @@ import annotationPlugin from "chartjs-plugin-annotation";
|
||||
Chart.register(annotationPlugin);
|
||||
|
||||
interface ResponseTimeDistributionProps {
|
||||
responseTimes: number[];
|
||||
data: number[];
|
||||
average: number;
|
||||
targetResponseTime?: number;
|
||||
}
|
||||
|
||||
export default function ResponseTimeDistribution({
|
||||
responseTimes,
|
||||
data,
|
||||
average,
|
||||
targetResponseTime,
|
||||
}: ResponseTimeDistributionProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !responseTimes.length) return;
|
||||
if (!ref.current || !data || !data.length) return;
|
||||
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||
const maxTime = Math.ceil(Math.max(...responseTimes));
|
||||
const maxTime = Math.ceil(Math.max(...data));
|
||||
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||
|
||||
// Count responses in each bin
|
||||
responseTimes.forEach((time) => {
|
||||
data.forEach((time) => {
|
||||
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||
bins[binIndex]++;
|
||||
});
|
||||
@ -63,26 +65,40 @@ export default function ResponseTimeDistribution({
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
annotation: targetResponseTime
|
||||
? {
|
||||
annotations: {
|
||||
targetLine: {
|
||||
annotation: {
|
||||
annotations: {
|
||||
averageLine: {
|
||||
type: "line",
|
||||
yMin: 0,
|
||||
yMax: Math.max(...bins),
|
||||
xMin: average,
|
||||
xMax: average,
|
||||
borderColor: "rgba(75, 192, 192, 1)",
|
||||
borderWidth: 2,
|
||||
label: {
|
||||
display: true,
|
||||
content: "Avg: " + average.toFixed(1) + "s",
|
||||
position: "start",
|
||||
},
|
||||
},
|
||||
targetLine: targetResponseTime
|
||||
? {
|
||||
type: "line",
|
||||
yMin: 0,
|
||||
yMax: Math.max(...bins),
|
||||
xMin: targetResponseTime,
|
||||
xMax: targetResponseTime,
|
||||
borderColor: "rgba(75, 192, 192, 1)",
|
||||
borderColor: "rgba(75, 192, 192, 0.7)",
|
||||
borderWidth: 2,
|
||||
label: {
|
||||
display: true,
|
||||
content: "Target",
|
||||
position: "start",
|
||||
position: "end",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
@ -103,7 +119,7 @@ export default function ResponseTimeDistribution({
|
||||
});
|
||||
|
||||
return () => chart.destroy();
|
||||
}, [responseTimes, targetResponseTime]);
|
||||
}, [data, average, targetResponseTime]);
|
||||
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
|
||||
357
components/Sidebar.tsx
Normal file
357
components/Sidebar.tsx
Normal file
@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import React from "react"; // No hooks needed since state is now managed by parent
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
// Icons for the sidebar
|
||||
const DashboardIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CompanyIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SessionsIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LogoutIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MinimalToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => (
|
||||
<svg
|
||||
className="h-6 w-6 text-gray-600 group-hover:text-sky-700 transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export interface SidebarProps {
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
isMobile?: boolean; // Add this property to indicate mobile viewport
|
||||
onNavigate?: () => void; // Function to call when navigating to a new page
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isExpanded: boolean;
|
||||
isActive: boolean;
|
||||
onNavigate?: () => void; // Function to call when navigating to a new page
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = ({
|
||||
href,
|
||||
label,
|
||||
icon,
|
||||
isExpanded,
|
||||
isActive,
|
||||
onNavigate,
|
||||
}) => (
|
||||
<Link
|
||||
href={href}
|
||||
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
|
||||
isActive
|
||||
? "bg-sky-100 text-sky-800 font-medium"
|
||||
: "hover:bg-gray-100 text-gray-700 hover:text-gray-900"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (onNavigate) {
|
||||
onNavigate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : "mx-auto"}`}>
|
||||
{icon}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<span className="truncate">{label}</span>
|
||||
) : (
|
||||
<div
|
||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||
text-white bg-gray-800 z-50
|
||||
invisible opacity-0 -translate-x-3 transition-all
|
||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function Sidebar({
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isMobile = false,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname() || "";
|
||||
|
||||
const handleLogout = () => {
|
||||
signOut({ callbackUrl: "/login" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay when sidebar is expanded on mobile */}
|
||||
{isExpanded && isMobile && (
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-900 bg-opacity-50 z-10 transition-opacity duration-300"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`fixed md:relative h-screen bg-white shadow-md transition-all duration-300
|
||||
${
|
||||
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
|
||||
} flex flex-col overflow-visible z-20`}
|
||||
>
|
||||
<div className="flex flex-col items-center pt-5 pb-3 border-b relative">
|
||||
{/* Toggle button when sidebar is collapsed - above logo */}
|
||||
{!isExpanded && (
|
||||
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 z-30">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // Prevent any navigation
|
||||
onToggle();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo section with link to homepage */}
|
||||
<Link href="/" className="flex flex-col items-center">
|
||||
<div
|
||||
className={`relative ${isExpanded ? "w-16" : "w-10 mt-8"} aspect-square mb-1 transition-all duration-300`}
|
||||
>
|
||||
<Image
|
||||
src="/favicon.svg"
|
||||
alt="LiveDash Logo"
|
||||
fill
|
||||
className="transition-all duration-300"
|
||||
priority
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<span className="text-lg font-bold text-sky-700 mt-1 transition-opacity duration-300">
|
||||
LiveDash
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="absolute top-3 right-3 z-30">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // Prevent any navigation
|
||||
onToggle();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<nav
|
||||
className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`}
|
||||
>
|
||||
<NavItem
|
||||
href="/dashboard"
|
||||
label="Dashboard"
|
||||
icon={<DashboardIcon />}
|
||||
isExpanded={isExpanded}
|
||||
isActive={pathname === "/dashboard"}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<NavItem
|
||||
href="/dashboard/overview"
|
||||
label="Analytics"
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
isExpanded={isExpanded}
|
||||
isActive={pathname === "/dashboard/overview"}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<NavItem
|
||||
href="/dashboard/sessions"
|
||||
label="Sessions"
|
||||
icon={<SessionsIcon />}
|
||||
isExpanded={isExpanded}
|
||||
isActive={pathname.startsWith("/dashboard/sessions")}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<NavItem
|
||||
href="/dashboard/company"
|
||||
label="Company Settings"
|
||||
icon={<CompanyIcon />}
|
||||
isExpanded={isExpanded}
|
||||
isActive={pathname === "/dashboard/company"}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<NavItem
|
||||
href="/dashboard/users"
|
||||
label="User Management"
|
||||
icon={<UsersIcon />}
|
||||
isExpanded={isExpanded}
|
||||
isActive={pathname === "/dashboard/users"}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</nav>
|
||||
<div className="p-4 border-t mt-auto">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`relative flex items-center p-3 w-full rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-all group ${
|
||||
isExpanded ? "" : "justify-center"
|
||||
}`}
|
||||
>
|
||||
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : ""}`}>
|
||||
<LogoutIcon />
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<span>Logout</span>
|
||||
) : (
|
||||
<div
|
||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||
text-white bg-gray-800 z-50
|
||||
invisible opacity-0 -translate-x-3 transition-all
|
||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||
>
|
||||
Logout
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -55,7 +55,7 @@ function formatTranscript(content: string): React.ReactNode[] {
|
||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||
components={{
|
||||
p: "span",
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className="text-sky-600 hover:text-sky-800 underline"
|
||||
@ -107,7 +107,7 @@ function formatTranscript(content: string): React.ReactNode[] {
|
||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||
components={{
|
||||
p: "span",
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className="text-sky-600 hover:text-sky-800 underline"
|
||||
|
||||
@ -2,15 +2,7 @@
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { select } from "d3-selection";
|
||||
import cloud from "d3-cloud";
|
||||
|
||||
interface CloudWord {
|
||||
text: string;
|
||||
size: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
rotate?: number;
|
||||
}
|
||||
import cloud, { Word } from "d3-cloud";
|
||||
|
||||
interface WordCloudProps {
|
||||
words: {
|
||||
@ -19,20 +11,55 @@ interface WordCloudProps {
|
||||
}[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
export default function WordCloud({
|
||||
words,
|
||||
width = 500,
|
||||
height = 300,
|
||||
width: initialWidth = 500,
|
||||
height: initialHeight = 300,
|
||||
minWidth = 200,
|
||||
minHeight = 200,
|
||||
}: WordCloudProps) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [dimensions, setDimensions] = useState({
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
});
|
||||
|
||||
// Set isClient to true on initial render
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Add effect to detect container size changes
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isClient) return;
|
||||
|
||||
// Create ResizeObserver to detect size changes
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
// Ensure minimum dimensions
|
||||
const newWidth = Math.max(width, minWidth);
|
||||
const newHeight = Math.max(height, minHeight);
|
||||
setDimensions({ width: newWidth, height: newHeight });
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the container
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [isClient, minWidth, minHeight]);
|
||||
|
||||
// Effect to render the word cloud whenever dimensions or words change
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !isClient || !words.length) return;
|
||||
|
||||
@ -44,7 +71,7 @@ export default function WordCloud({
|
||||
|
||||
// Configure the layout
|
||||
const layout = cloud()
|
||||
.size([width, height])
|
||||
.size([dimensions.width, dimensions.height])
|
||||
.words(
|
||||
words.map((d) => ({
|
||||
text: d.text,
|
||||
@ -53,20 +80,23 @@ export default function WordCloud({
|
||||
)
|
||||
.padding(5)
|
||||
.rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees
|
||||
.fontSize((d) => (d as any).size)
|
||||
.fontSize((d: Word) => d.size || 10)
|
||||
.on("end", draw);
|
||||
|
||||
layout.start();
|
||||
|
||||
function draw(words: CloudWord[]) {
|
||||
function draw(words: Word[]) {
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(${width / 2},${height / 2})`)
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${dimensions.width / 2},${dimensions.height / 2})`
|
||||
)
|
||||
.selectAll("text")
|
||||
.data(words)
|
||||
.enter()
|
||||
.append("text")
|
||||
.style("font-size", (d: CloudWord) => `${d.size}px`)
|
||||
.style("font-size", (d: Word) => `${d.size || 10}px`)
|
||||
.style("font-family", "Inter, Arial, sans-serif")
|
||||
.style("fill", () => {
|
||||
// Create a nice gradient of colors
|
||||
@ -85,17 +115,17 @@ export default function WordCloud({
|
||||
.attr("text-anchor", "middle")
|
||||
.attr(
|
||||
"transform",
|
||||
(d: CloudWord) =>
|
||||
(d: Word) =>
|
||||
`translate(${d.x || 0},${d.y || 0}) rotate(${d.rotate || 0})`
|
||||
)
|
||||
.text((d: CloudWord) => d.text);
|
||||
.text((d: Word) => d.text || "");
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
svg.selectAll("*").remove();
|
||||
};
|
||||
}, [words, width, height, isClient]);
|
||||
}, [words, dimensions, isClient]);
|
||||
|
||||
if (!isClient) {
|
||||
return (
|
||||
@ -106,12 +136,21 @@ export default function WordCloud({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full h-full">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex justify-center w-full h-full"
|
||||
style={{ minHeight: `${minHeight}px` }}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
className="w-full h-full"
|
||||
aria-label="Word cloud visualization of categories"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
20
e2e/example.spec.ts
Normal file
20
e2e/example.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("has title", async ({ page }) => {
|
||||
await page.goto("https://playwright.dev/");
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test("get started link", async ({ page }) => {
|
||||
await page.goto("https://playwright.dev/");
|
||||
|
||||
// Click the get started link.
|
||||
await page.getByRole("link", { name: "Get started" }).click();
|
||||
|
||||
// Expects page to have a heading with the name of Installation.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Installation" })
|
||||
).toBeVisible();
|
||||
});
|
||||
@ -26,13 +26,13 @@ const eslintConfig = [
|
||||
"coverage/",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"no-console": "warn",
|
||||
"no-trailing-spaces": "error",
|
||||
"react/no-unescaped-entities": "warn",
|
||||
"no-console": "off",
|
||||
"no-trailing-spaces": "warn",
|
||||
"prefer-const": "error",
|
||||
"no-unused-vars": "off",
|
||||
"no-unused-vars": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -5,7 +5,7 @@ import ISO6391 from "iso-639-1";
|
||||
import countries from "i18n-iso-countries";
|
||||
|
||||
// Register locales for i18n-iso-countries
|
||||
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" };
|
||||
import enLocale from "i18n-iso-countries/langs/en.json" with { type: "json" };
|
||||
countries.registerLocale(enLocale);
|
||||
|
||||
// This type is used internally for parsing the CSV records
|
||||
@ -374,6 +374,62 @@ function isTruthyValue(value?: string): boolean {
|
||||
return truthyValues.includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses a date string into a Date object.
|
||||
* Handles potential errors and various formats, prioritizing D-M-YYYY HH:MM:SS.
|
||||
* @param dateStr The date string to parse.
|
||||
* @returns A Date object or null if parsing fails.
|
||||
*/
|
||||
function safeParseDate(dateStr?: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// Try to parse D-M-YYYY HH:MM:SS format (with hyphens or dots)
|
||||
const dateTimeRegex =
|
||||
/^(\d{1,2})[.-](\d{1,2})[.-](\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/;
|
||||
const match = dateStr.match(dateTimeRegex);
|
||||
|
||||
if (match) {
|
||||
const day = match[1];
|
||||
const month = match[2];
|
||||
const year = match[3];
|
||||
const hour = match[4];
|
||||
const minute = match[5];
|
||||
const second = match[6];
|
||||
|
||||
// Reformat to YYYY-MM-DDTHH:MM:SS (ISO-like, but local time)
|
||||
// Ensure month and day are two digits
|
||||
const formattedDateStr = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${second.padStart(2, "0")}`;
|
||||
|
||||
try {
|
||||
const date = new Date(formattedDateStr);
|
||||
// Basic validation: check if the constructed date is valid
|
||||
if (!isNaN(date.getTime())) {
|
||||
// console.log(`[safeParseDate] Parsed from D-M-YYYY: ${dateStr} -> ${formattedDateStr} -> ${date.toISOString()}`);
|
||||
return date;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[safeParseDate] Error parsing reformatted string ${formattedDateStr} from ${dateStr}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other potential formats (e.g., direct ISO 8601) or if the primary parse failed
|
||||
try {
|
||||
const parsedDate = new Date(dateStr);
|
||||
if (!isNaN(parsedDate.getTime())) {
|
||||
// console.log(`[safeParseDate] Parsed with fallback: ${dateStr} -> ${parsedDate.toISOString()}`);
|
||||
return parsedDate;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[safeParseDate] Error parsing with fallback ${dateStr}:`, e);
|
||||
}
|
||||
|
||||
console.warn(`Failed to parse date string: ${dateStr}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchAndParseCsv(
|
||||
url: string,
|
||||
username?: string,
|
||||
@ -418,13 +474,6 @@ export async function fetchAndParseCsv(
|
||||
trim: true,
|
||||
});
|
||||
|
||||
// Helper function to safely parse dates
|
||||
function safeParseDate(dateStr?: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr);
|
||||
return !isNaN(date.getTime()) ? date : null;
|
||||
}
|
||||
|
||||
// Coerce types for relevant columns
|
||||
return records.map((r) => ({
|
||||
id: r.session_id,
|
||||
|
||||
@ -2,7 +2,7 @@ import ISO6391 from "iso-639-1";
|
||||
import countries from "i18n-iso-countries";
|
||||
|
||||
// Register locales for i18n-iso-countries
|
||||
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" };
|
||||
import enLocale from "i18n-iso-countries/langs/en.json" with { type: "json" };
|
||||
countries.registerLocale(enLocale);
|
||||
|
||||
/**
|
||||
|
||||
850
lib/metrics.ts
850
lib/metrics.ts
@ -13,295 +13,309 @@ interface CompanyConfig {
|
||||
sentimentAlert?: number;
|
||||
}
|
||||
|
||||
// Helper function to calculate trend percentages
|
||||
function calculateTrendPercentage(current: number, previous: number): number {
|
||||
if (previous === 0) return 0; // Avoid division by zero
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
// Mock data for previous period - in a real app, this would come from database
|
||||
const mockPreviousPeriodData = {
|
||||
totalSessions: 120,
|
||||
uniqueUsers: 85,
|
||||
avgSessionLength: 240, // in seconds
|
||||
avgResponseTime: 1.7, // in seconds
|
||||
};
|
||||
|
||||
// List of common stop words - this can be expanded
|
||||
const stopWords = new Set([
|
||||
"assistant",
|
||||
"user",
|
||||
// Web
|
||||
"bmp",
|
||||
"co",
|
||||
"com",
|
||||
"www",
|
||||
"http",
|
||||
"https",
|
||||
"www2",
|
||||
"css",
|
||||
"gif",
|
||||
"href",
|
||||
"html",
|
||||
"php",
|
||||
"js",
|
||||
"css",
|
||||
"xml",
|
||||
"json",
|
||||
"txt",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"svg",
|
||||
"org",
|
||||
"net",
|
||||
"co",
|
||||
"http",
|
||||
"https",
|
||||
"io",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"js",
|
||||
"json",
|
||||
"net",
|
||||
"org",
|
||||
"php",
|
||||
"png",
|
||||
"svg",
|
||||
"txt",
|
||||
"www",
|
||||
"www2",
|
||||
"xml",
|
||||
// English stop words
|
||||
"a",
|
||||
"about",
|
||||
"above",
|
||||
"after",
|
||||
"again",
|
||||
"against",
|
||||
"ain",
|
||||
"all",
|
||||
"am",
|
||||
"an",
|
||||
"the",
|
||||
"is",
|
||||
"any",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"aren",
|
||||
"at",
|
||||
"be",
|
||||
"been",
|
||||
"before",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"will",
|
||||
"would",
|
||||
"should",
|
||||
"below",
|
||||
"between",
|
||||
"both",
|
||||
"by",
|
||||
"bye",
|
||||
"can",
|
||||
"could",
|
||||
"may",
|
||||
"might",
|
||||
"must",
|
||||
"am",
|
||||
"i",
|
||||
"you",
|
||||
"he",
|
||||
"she",
|
||||
"it",
|
||||
"we",
|
||||
"they",
|
||||
"me",
|
||||
"him",
|
||||
"her",
|
||||
"us",
|
||||
"them",
|
||||
"my",
|
||||
"your",
|
||||
"his",
|
||||
"its",
|
||||
"our",
|
||||
"their",
|
||||
"mine",
|
||||
"yours",
|
||||
"hers",
|
||||
"ours",
|
||||
"theirs",
|
||||
"to",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"by",
|
||||
"for",
|
||||
"with",
|
||||
"about",
|
||||
"against",
|
||||
"between",
|
||||
"into",
|
||||
"through",
|
||||
"during",
|
||||
"before",
|
||||
"after",
|
||||
"above",
|
||||
"below",
|
||||
"from",
|
||||
"up",
|
||||
"couldn",
|
||||
"d",
|
||||
"did",
|
||||
"didn",
|
||||
"do",
|
||||
"does",
|
||||
"doesn",
|
||||
"don",
|
||||
"down",
|
||||
"out",
|
||||
"off",
|
||||
"over",
|
||||
"under",
|
||||
"again",
|
||||
"further",
|
||||
"then",
|
||||
"once",
|
||||
"here",
|
||||
"there",
|
||||
"when",
|
||||
"where",
|
||||
"why",
|
||||
"how",
|
||||
"all",
|
||||
"any",
|
||||
"both",
|
||||
"during",
|
||||
"each",
|
||||
"few",
|
||||
"for",
|
||||
"from",
|
||||
"further",
|
||||
"goodbye",
|
||||
"had",
|
||||
"hadn",
|
||||
"has",
|
||||
"hasn",
|
||||
"have",
|
||||
"haven",
|
||||
"he",
|
||||
"hello",
|
||||
"her",
|
||||
"here",
|
||||
"hers",
|
||||
"hi",
|
||||
"him",
|
||||
"his",
|
||||
"how",
|
||||
"i",
|
||||
"in",
|
||||
"into",
|
||||
"is",
|
||||
"isn",
|
||||
"it",
|
||||
"its",
|
||||
"just",
|
||||
"ll",
|
||||
"m",
|
||||
"ma",
|
||||
"may",
|
||||
"me",
|
||||
"might",
|
||||
"mightn",
|
||||
"mine",
|
||||
"more",
|
||||
"most",
|
||||
"other",
|
||||
"some",
|
||||
"such",
|
||||
"must",
|
||||
"mustn",
|
||||
"my",
|
||||
"needn",
|
||||
"no",
|
||||
"nor",
|
||||
"not",
|
||||
"only",
|
||||
"own",
|
||||
"same",
|
||||
"so",
|
||||
"than",
|
||||
"too",
|
||||
"very",
|
||||
"s",
|
||||
"t",
|
||||
"just",
|
||||
"don",
|
||||
"shouldve",
|
||||
"now",
|
||||
"d",
|
||||
"ll",
|
||||
"m",
|
||||
"o",
|
||||
"re",
|
||||
"ve",
|
||||
"y",
|
||||
"ain",
|
||||
"aren",
|
||||
"couldn",
|
||||
"didn",
|
||||
"doesn",
|
||||
"hadn",
|
||||
"hasn",
|
||||
"haven",
|
||||
"isn",
|
||||
"ma",
|
||||
"mightn",
|
||||
"mustn",
|
||||
"needn",
|
||||
"shan",
|
||||
"shouldn",
|
||||
"wasn",
|
||||
"weren",
|
||||
"won",
|
||||
"wouldn",
|
||||
"hi",
|
||||
"hello",
|
||||
"thanks",
|
||||
"thank",
|
||||
"please",
|
||||
"of",
|
||||
"off",
|
||||
"ok",
|
||||
"okay",
|
||||
"yes",
|
||||
"on",
|
||||
"once",
|
||||
"only",
|
||||
"other",
|
||||
"our",
|
||||
"ours",
|
||||
"out",
|
||||
"over",
|
||||
"own",
|
||||
"please",
|
||||
"re",
|
||||
"s",
|
||||
"same",
|
||||
"shan",
|
||||
"she",
|
||||
"should",
|
||||
"shouldn",
|
||||
"shouldve",
|
||||
"so",
|
||||
"some",
|
||||
"such",
|
||||
"t",
|
||||
"than",
|
||||
"thank",
|
||||
"thanks",
|
||||
"the",
|
||||
"their",
|
||||
"theirs",
|
||||
"them",
|
||||
"then",
|
||||
"there",
|
||||
"they",
|
||||
"through",
|
||||
"to",
|
||||
"too",
|
||||
"under",
|
||||
"up",
|
||||
"us",
|
||||
"ve",
|
||||
"very",
|
||||
"was",
|
||||
"wasn",
|
||||
"we",
|
||||
"were",
|
||||
"weren",
|
||||
"when",
|
||||
"where",
|
||||
"why",
|
||||
"will",
|
||||
"with",
|
||||
"won",
|
||||
"would",
|
||||
"wouldn",
|
||||
"y",
|
||||
"yeah",
|
||||
"bye",
|
||||
"goodbye",
|
||||
"yes",
|
||||
"you",
|
||||
"your",
|
||||
"yours",
|
||||
// French stop words
|
||||
"des",
|
||||
"donc",
|
||||
"et",
|
||||
"la",
|
||||
"le",
|
||||
"les",
|
||||
"mais",
|
||||
"ou",
|
||||
"un",
|
||||
"une",
|
||||
"des",
|
||||
"et",
|
||||
"ou",
|
||||
"mais",
|
||||
"donc",
|
||||
// Dutch stop words
|
||||
"dit",
|
||||
"ben",
|
||||
"de",
|
||||
"het",
|
||||
"ik",
|
||||
"jij",
|
||||
"hij",
|
||||
"zij",
|
||||
"wij",
|
||||
"jullie",
|
||||
"deze",
|
||||
"dit",
|
||||
"dat",
|
||||
"die",
|
||||
"een",
|
||||
"en",
|
||||
"of",
|
||||
"maar",
|
||||
"want",
|
||||
"omdat",
|
||||
"dus",
|
||||
"als",
|
||||
"ook",
|
||||
"dan",
|
||||
"nu",
|
||||
"nog",
|
||||
"al",
|
||||
"naar",
|
||||
"voor",
|
||||
"van",
|
||||
"door",
|
||||
"met",
|
||||
"bij",
|
||||
"tot",
|
||||
"om",
|
||||
"over",
|
||||
"tussen",
|
||||
"onder",
|
||||
"boven",
|
||||
"tegen",
|
||||
"aan",
|
||||
"uit",
|
||||
"sinds",
|
||||
"tijdens",
|
||||
"binnen",
|
||||
"buiten",
|
||||
"zonder",
|
||||
"volgens",
|
||||
"dankzij",
|
||||
"ondanks",
|
||||
"behalve",
|
||||
"mits",
|
||||
"tenzij",
|
||||
"hoewel",
|
||||
"al",
|
||||
"alhoewel",
|
||||
"toch",
|
||||
"als",
|
||||
"anders",
|
||||
"echter",
|
||||
"wel",
|
||||
"niet",
|
||||
"geen",
|
||||
"iets",
|
||||
"niets",
|
||||
"veel",
|
||||
"weinig",
|
||||
"meer",
|
||||
"meest",
|
||||
"elk",
|
||||
"ieder",
|
||||
"sommige",
|
||||
"hoe",
|
||||
"wat",
|
||||
"waar",
|
||||
"wie",
|
||||
"wanneer",
|
||||
"waarom",
|
||||
"welke",
|
||||
"wordt",
|
||||
"worden",
|
||||
"werd",
|
||||
"werden",
|
||||
"geworden",
|
||||
"zijn",
|
||||
"behalve",
|
||||
"ben",
|
||||
"ben",
|
||||
"bent",
|
||||
"was",
|
||||
"waren",
|
||||
"bij",
|
||||
"binnen",
|
||||
"boven",
|
||||
"buiten",
|
||||
"dan",
|
||||
"dankzij",
|
||||
"dat",
|
||||
"de",
|
||||
"deze",
|
||||
"die",
|
||||
"dit",
|
||||
"dit",
|
||||
"door",
|
||||
"dus",
|
||||
"echter",
|
||||
"een",
|
||||
"elk",
|
||||
"en",
|
||||
"geen",
|
||||
"gehad",
|
||||
"geweest",
|
||||
"hebben",
|
||||
"heb",
|
||||
"hebt",
|
||||
"heeft",
|
||||
"geworden",
|
||||
"had",
|
||||
"hadden",
|
||||
"gehad",
|
||||
"kunnen",
|
||||
"heb",
|
||||
"hebben",
|
||||
"hebt",
|
||||
"heeft",
|
||||
"het",
|
||||
"hij",
|
||||
"hoe",
|
||||
"hoewel",
|
||||
"ieder",
|
||||
"iets",
|
||||
"ik",
|
||||
"jij",
|
||||
"jullie",
|
||||
"kan",
|
||||
"kunt",
|
||||
"kon",
|
||||
"konden",
|
||||
"zullen",
|
||||
"kunnen",
|
||||
"kunt",
|
||||
"maar",
|
||||
"meer",
|
||||
"meest",
|
||||
"met",
|
||||
"mits",
|
||||
"naar",
|
||||
"niet",
|
||||
"niets",
|
||||
"nog",
|
||||
"nu",
|
||||
"of",
|
||||
"om",
|
||||
"omdat",
|
||||
"ondanks",
|
||||
"onder",
|
||||
"ook",
|
||||
"over",
|
||||
"sinds",
|
||||
"sommige",
|
||||
"tegen",
|
||||
"tenzij",
|
||||
"tijdens",
|
||||
"toch",
|
||||
"tot",
|
||||
"tussen",
|
||||
"uit",
|
||||
"van",
|
||||
"veel",
|
||||
"volgens",
|
||||
"voor",
|
||||
"waar",
|
||||
"waarom",
|
||||
"wanneer",
|
||||
"want",
|
||||
"waren",
|
||||
"was",
|
||||
"wat",
|
||||
"weinig",
|
||||
"wel",
|
||||
"welke",
|
||||
"werd",
|
||||
"werden",
|
||||
"wie",
|
||||
"wij",
|
||||
"worden",
|
||||
"wordt",
|
||||
"zal",
|
||||
"zij",
|
||||
"zijn",
|
||||
"zonder",
|
||||
"zullen",
|
||||
"zult",
|
||||
// Add more domain-specific stop words if necessary
|
||||
]);
|
||||
@ -310,156 +324,266 @@ export function sessionMetrics(
|
||||
sessions: ChatSession[],
|
||||
companyConfig: CompanyConfig = {}
|
||||
): MetricsResult {
|
||||
const total = sessions.length;
|
||||
const totalSessions = sessions.length; // Renamed from 'total' for clarity
|
||||
const byDay: DayMetrics = {};
|
||||
const byCategory: CategoryMetrics = {};
|
||||
const byLanguage: LanguageMetrics = {};
|
||||
const byCountry: CountryMetrics = {}; // Added for country data
|
||||
const byCountry: CountryMetrics = {};
|
||||
const tokensByDay: DayMetrics = {};
|
||||
const tokensCostByDay: DayMetrics = {};
|
||||
|
||||
let escalated = 0,
|
||||
forwarded = 0;
|
||||
let totalSentiment = 0,
|
||||
sentimentCount = 0;
|
||||
let totalResponse = 0,
|
||||
responseCount = 0;
|
||||
let totalTokens = 0,
|
||||
totalTokensEur = 0;
|
||||
let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult
|
||||
let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult
|
||||
|
||||
// For sentiment distribution
|
||||
let sentimentPositive = 0,
|
||||
sentimentNegative = 0,
|
||||
sentimentNeutral = 0;
|
||||
// Variables for calculations
|
||||
const uniqueUserIds = new Set<string>();
|
||||
let totalSessionDuration = 0;
|
||||
let validSessionsForDuration = 0;
|
||||
let totalResponseTime = 0;
|
||||
let validSessionsForResponseTime = 0;
|
||||
let sentimentPositiveCount = 0;
|
||||
let sentimentNeutralCount = 0;
|
||||
let sentimentNegativeCount = 0;
|
||||
let totalTokens = 0;
|
||||
let totalTokensEur = 0;
|
||||
const wordCounts: { [key: string]: number } = {};
|
||||
let alerts = 0;
|
||||
|
||||
// Calculate total session duration in minutes
|
||||
let totalDuration = 0;
|
||||
let durationCount = 0;
|
||||
|
||||
const wordCounts: { [key: string]: number } = {}; // For WordCloud
|
||||
|
||||
sessions.forEach((s) => {
|
||||
const day = s.startTime.toISOString().slice(0, 10);
|
||||
byDay[day] = (byDay[day] || 0) + 1;
|
||||
|
||||
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
|
||||
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
|
||||
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1; // Populate byCountry
|
||||
|
||||
// Process token usage by day
|
||||
if (s.tokens) {
|
||||
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
|
||||
for (const session of sessions) {
|
||||
// Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
|
||||
let identifierAdded = false;
|
||||
if (session.ipAddress && session.ipAddress.trim() !== "") {
|
||||
uniqueUserIds.add(session.ipAddress.trim());
|
||||
identifierAdded = true;
|
||||
}
|
||||
// Fallback to sessionId only if ipAddress was not usable and sessionId is valid
|
||||
if (
|
||||
!identifierAdded &&
|
||||
session.sessionId &&
|
||||
session.sessionId.trim() !== ""
|
||||
) {
|
||||
uniqueUserIds.add(session.sessionId.trim());
|
||||
}
|
||||
|
||||
// Process token cost by day
|
||||
if (s.tokensEur) {
|
||||
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
|
||||
}
|
||||
// Avg. Session Time
|
||||
if (session.startTime && session.endTime) {
|
||||
const startTimeMs = new Date(session.startTime).getTime();
|
||||
const endTimeMs = new Date(session.endTime).getTime();
|
||||
|
||||
if (s.endTime) {
|
||||
const duration =
|
||||
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
|
||||
if (isNaN(startTimeMs)) {
|
||||
console.warn(
|
||||
`[metrics] Invalid startTime for session ${session.id || session.sessionId}: ${session.startTime}`
|
||||
);
|
||||
}
|
||||
if (isNaN(endTimeMs)) {
|
||||
console.warn(
|
||||
`[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}`
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity check: Only include sessions with reasonable durations (less than 24 hours)
|
||||
const MAX_REASONABLE_DURATION = 24 * 60; // 24 hours in minutes
|
||||
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
|
||||
totalDuration += duration;
|
||||
durationCount++;
|
||||
if (!isNaN(startTimeMs) && !isNaN(endTimeMs)) {
|
||||
const timeDifference = endTimeMs - startTimeMs; // Calculate the signed delta
|
||||
// Use the absolute difference for duration, ensuring it's not negative.
|
||||
// If times are identical, duration will be 0.
|
||||
// If endTime is before startTime, this still yields a positive duration representing the magnitude of the difference.
|
||||
const duration = Math.abs(timeDifference);
|
||||
// console.log(
|
||||
// `[metrics] duration is ${duration} for session ${session.id || session.sessionId}`
|
||||
// );
|
||||
|
||||
totalSessionDuration += duration; // Add this duration
|
||||
|
||||
if (timeDifference < 0) {
|
||||
// Log a specific warning if the original endTime was before startTime
|
||||
console.warn(
|
||||
`[metrics] endTime (${session.endTime}) was before startTime (${session.startTime}) for session ${session.id || session.sessionId}. Using absolute difference as duration (${(duration / 1000).toFixed(2)} seconds).`
|
||||
);
|
||||
} else if (timeDifference === 0) {
|
||||
// // Optionally, log if times are identical, though this might be verbose if common
|
||||
// console.log(
|
||||
// `[metrics] startTime and endTime are identical for session ${session.id || session.sessionId}. Duration is 0.`
|
||||
// );
|
||||
}
|
||||
// If timeDifference > 0, it's a normal positive duration, no special logging needed here for that case.
|
||||
|
||||
validSessionsForDuration++; // Count this session for averaging
|
||||
}
|
||||
} else {
|
||||
if (!session.startTime) {
|
||||
console.warn(
|
||||
`[metrics] Missing startTime for session ${session.id || session.sessionId}`
|
||||
);
|
||||
}
|
||||
if (!session.endTime) {
|
||||
// This is a common case for ongoing sessions, might not always be an error
|
||||
console.log(
|
||||
`[metrics] Missing endTime for session ${session.id || session.sessionId} - likely ongoing or data issue.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (s.escalated) escalated++;
|
||||
if (s.forwardedHr) forwarded++;
|
||||
// Avg. Response Time
|
||||
if (
|
||||
session.avgResponseTime !== undefined &&
|
||||
session.avgResponseTime !== null &&
|
||||
session.avgResponseTime >= 0
|
||||
) {
|
||||
totalResponseTime += session.avgResponseTime;
|
||||
validSessionsForResponseTime++;
|
||||
}
|
||||
|
||||
if (s.sentiment != null) {
|
||||
totalSentiment += s.sentiment;
|
||||
sentimentCount++;
|
||||
// Escalated and Forwarded
|
||||
if (session.escalated) escalatedCount++;
|
||||
if (session.forwardedHr) forwardedHrCount++;
|
||||
|
||||
// Classify sentiment
|
||||
if (s.sentiment > 0.3) {
|
||||
sentimentPositive++;
|
||||
} else if (s.sentiment < -0.3) {
|
||||
sentimentNegative++;
|
||||
} else {
|
||||
sentimentNeutral++;
|
||||
// Sentiment
|
||||
if (session.sentiment !== undefined && session.sentiment !== null) {
|
||||
// Example thresholds, adjust as needed
|
||||
if (session.sentiment > 0.3) sentimentPositiveCount++;
|
||||
else if (session.sentiment < -0.3) sentimentNegativeCount++;
|
||||
else sentimentNeutralCount++;
|
||||
}
|
||||
|
||||
// Sentiment Alert Check
|
||||
if (
|
||||
companyConfig.sentimentAlert !== undefined &&
|
||||
session.sentiment !== undefined &&
|
||||
session.sentiment !== null &&
|
||||
session.sentiment < companyConfig.sentimentAlert
|
||||
) {
|
||||
alerts++;
|
||||
}
|
||||
|
||||
// Tokens
|
||||
if (session.tokens !== undefined && session.tokens !== null) {
|
||||
totalTokens += session.tokens;
|
||||
}
|
||||
if (session.tokensEur !== undefined && session.tokensEur !== null) {
|
||||
totalTokensEur += session.tokensEur;
|
||||
}
|
||||
|
||||
// Daily metrics
|
||||
const day = new Date(session.startTime).toISOString().split("T")[0];
|
||||
byDay[day] = (byDay[day] || 0) + 1; // Sessions per day
|
||||
if (session.tokens !== undefined && session.tokens !== null) {
|
||||
tokensByDay[day] = (tokensByDay[day] || 0) + session.tokens;
|
||||
}
|
||||
if (session.tokensEur !== undefined && session.tokensEur !== null) {
|
||||
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + session.tokensEur;
|
||||
}
|
||||
|
||||
// Category metrics
|
||||
if (session.category) {
|
||||
byCategory[session.category] = (byCategory[session.category] || 0) + 1;
|
||||
}
|
||||
|
||||
// Language metrics
|
||||
if (session.language) {
|
||||
byLanguage[session.language] = (byLanguage[session.language] || 0) + 1;
|
||||
}
|
||||
|
||||
// Country metrics
|
||||
if (session.country) {
|
||||
byCountry[session.country] = (byCountry[session.country] || 0) + 1;
|
||||
}
|
||||
|
||||
// Word Cloud Data (from initial message and transcript content)
|
||||
const processTextForWordCloud = (text: string | undefined | null) => {
|
||||
if (!text) return;
|
||||
const words = text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s'-]/gi, "")
|
||||
.split(/\s+/); // Keep apostrophes and hyphens
|
||||
for (const word of words) {
|
||||
const cleanedWord = word.replace(/^['-]|['-]$/g, ""); // Remove leading/trailing apostrophes/hyphens
|
||||
if (
|
||||
cleanedWord &&
|
||||
!stopWords.has(cleanedWord) &&
|
||||
cleanedWord.length > 2
|
||||
) {
|
||||
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (s.avgResponseTime != null) {
|
||||
totalResponse += s.avgResponseTime;
|
||||
responseCount++;
|
||||
}
|
||||
|
||||
totalTokens += s.tokens || 0;
|
||||
totalTokensEur += s.tokensEur || 0;
|
||||
|
||||
// Process transcript for WordCloud
|
||||
if (s.transcriptContent) {
|
||||
const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g); // Split into words, lowercase
|
||||
if (words) {
|
||||
words.forEach((word) => {
|
||||
const cleanedWord = word.replace(/[^a-z0-9]/gi, ""); // Remove punctuation
|
||||
if (
|
||||
cleanedWord &&
|
||||
!stopWords.has(cleanedWord) &&
|
||||
cleanedWord.length > 2
|
||||
) {
|
||||
// Check if not a stop word and length > 2
|
||||
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now add sentiment alert logic:
|
||||
let belowThreshold = 0;
|
||||
const threshold = companyConfig.sentimentAlert ?? null;
|
||||
if (threshold != null) {
|
||||
for (const s of sessions) {
|
||||
if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
|
||||
}
|
||||
};
|
||||
processTextForWordCloud(session.initialMsg);
|
||||
processTextForWordCloud(session.transcriptContent);
|
||||
}
|
||||
|
||||
// Calculate average sessions per day
|
||||
const dayCount = Object.keys(byDay).length;
|
||||
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
|
||||
|
||||
// Calculate average session length
|
||||
const uniqueUsers = uniqueUserIds.size;
|
||||
const avgSessionLength =
|
||||
durationCount > 0 ? totalDuration / durationCount : null;
|
||||
validSessionsForDuration > 0
|
||||
? totalSessionDuration / validSessionsForDuration / 1000 // Convert ms to minutes
|
||||
: 0;
|
||||
const avgResponseTime =
|
||||
validSessionsForResponseTime > 0
|
||||
? totalResponseTime / validSessionsForResponseTime
|
||||
: 0; // in seconds
|
||||
|
||||
// Prepare wordCloudData
|
||||
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
|
||||
.map(([text, value]) => ({ text, value }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 500); // Take top 500 words
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 50) // Top 50 words
|
||||
.map(([text, value]) => ({ text, value }));
|
||||
|
||||
// Calculate avgSessionsPerDay
|
||||
const numDaysWithSessions = Object.keys(byDay).length;
|
||||
const avgSessionsPerDay =
|
||||
numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0;
|
||||
|
||||
// Calculate trends
|
||||
const totalSessionsTrend = calculateTrendPercentage(
|
||||
totalSessions,
|
||||
mockPreviousPeriodData.totalSessions
|
||||
);
|
||||
const uniqueUsersTrend = calculateTrendPercentage(
|
||||
uniqueUsers,
|
||||
mockPreviousPeriodData.uniqueUsers
|
||||
);
|
||||
const avgSessionLengthTrend = calculateTrendPercentage(
|
||||
avgSessionLength,
|
||||
mockPreviousPeriodData.avgSessionLength
|
||||
);
|
||||
const avgResponseTimeTrend = calculateTrendPercentage(
|
||||
avgResponseTime,
|
||||
mockPreviousPeriodData.avgResponseTime
|
||||
);
|
||||
|
||||
// console.log("Debug metrics calculation:", {
|
||||
// totalSessionDuration,
|
||||
// validSessionsForDuration,
|
||||
// calculatedAvgSessionLength: avgSessionLength,
|
||||
// });
|
||||
|
||||
return {
|
||||
totalSessions: total,
|
||||
avgSessionsPerDay,
|
||||
avgSessionLength,
|
||||
days: byDay,
|
||||
languages: byLanguage,
|
||||
categories: byCategory, // This will be empty if we are not using categories for word cloud
|
||||
countries: byCountry, // Added countries to the result
|
||||
belowThresholdCount: belowThreshold,
|
||||
// Additional metrics not in the interface - using type assertion
|
||||
escalatedCount: escalated,
|
||||
forwardedCount: forwarded,
|
||||
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : undefined,
|
||||
avgResponseTime: responseCount ? totalResponse / responseCount : undefined,
|
||||
totalTokens,
|
||||
totalTokensEur,
|
||||
sentimentThreshold: threshold,
|
||||
lastUpdated: Date.now(), // Add current timestamp
|
||||
|
||||
// New metrics for enhanced dashboard
|
||||
sentimentPositiveCount: sentimentPositive,
|
||||
sentimentNeutralCount: sentimentNeutral,
|
||||
sentimentNegativeCount: sentimentNegative,
|
||||
totalSessions,
|
||||
uniqueUsers,
|
||||
avgSessionLength, // Corrected to match MetricsResult interface
|
||||
avgResponseTime, // Corrected to match MetricsResult interface
|
||||
escalatedCount,
|
||||
forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount)
|
||||
sentimentPositiveCount,
|
||||
sentimentNeutralCount,
|
||||
sentimentNegativeCount,
|
||||
days: byDay, // Corrected to match MetricsResult interface (days)
|
||||
categories: byCategory, // Corrected to match MetricsResult interface (categories)
|
||||
languages: byLanguage, // Corrected to match MetricsResult interface (languages)
|
||||
countries: byCountry, // Corrected to match MetricsResult interface (countries)
|
||||
tokensByDay,
|
||||
tokensCostByDay,
|
||||
wordCloudData, // Added word cloud data
|
||||
totalTokens,
|
||||
totalTokensEur,
|
||||
wordCloudData,
|
||||
belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount)
|
||||
avgSessionsPerDay, // Added to satisfy MetricsResult interface
|
||||
// Map trend values to the expected property names in MetricsResult
|
||||
sessionTrend: totalSessionsTrend,
|
||||
usersTrend: uniqueUsersTrend,
|
||||
avgSessionTimeTrend: avgSessionLengthTrend,
|
||||
// For response time, a negative trend is actually positive (faster responses are better)
|
||||
avgResponseTimeTrend: -avgResponseTimeTrend, // Invert as lower response time is better
|
||||
// Additional fields
|
||||
sentimentThreshold: companyConfig.sentimentAlert,
|
||||
lastUpdated: Date.now(),
|
||||
totalSessionDuration,
|
||||
validSessionsForDuration,
|
||||
};
|
||||
}
|
||||
|
||||
11
lib/types.ts
11
lib/types.ts
@ -131,6 +131,17 @@ export interface MetricsResult {
|
||||
tokensByDay?: DayMetrics;
|
||||
tokensCostByDay?: DayMetrics;
|
||||
wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud
|
||||
|
||||
// Properties for overview page cards and trends
|
||||
uniqueUsers?: number;
|
||||
sessionTrend?: number; // e.g., percentage change in totalSessions
|
||||
usersTrend?: number; // e.g., percentage change in uniqueUsers
|
||||
avgSessionTimeTrend?: number; // e.g., percentage change in avgSessionLength
|
||||
avgResponseTimeTrend?: number; // e.g., percentage change in avgResponseTime
|
||||
|
||||
// Debug properties
|
||||
totalSessionDuration?: number;
|
||||
validSessionsForDuration?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Allow cross-origin requests from specific origins in development
|
||||
|
||||
477
package-lock.json
generated
477
package-lock.json
generated
@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "livedash-node",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "livedash-node",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@rapideditor/country-coder": "^5.4.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/d3-cloud": "^1.2.9",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
@ -17,7 +18,6 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"country-code-lookup": "^0.1.3",
|
||||
"csv-parse": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-cloud": "^1.2.7",
|
||||
@ -38,6 +38,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/node": "^22.15.21",
|
||||
@ -49,8 +50,10 @@
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"eslint-plugin-prettier": "^5.4.0",
|
||||
"markdownlint-cli2": "^0.18.1",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-jinja-template": "^2.1.0",
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
@ -1069,6 +1072,22 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
|
||||
"integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz",
|
||||
@ -1151,6 +1170,18 @@
|
||||
"@prisma/debug": "6.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@rapideditor/country-coder": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@rapideditor/country-coder/-/country-coder-5.4.0.tgz",
|
||||
"integrity": "sha512-5Kjy2hnDcJZnPpRXMrTNY+jTkwhenaniCD4K6oLdZHYnY0GSM8gIIkOmoB3UikVVcot5vhz6i0QVqbTSyxAvrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"which-polygon": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
@ -1176,6 +1207,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
||||
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
@ -1834,6 +1878,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/katex": {
|
||||
"version": "0.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
|
||||
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.18.tgz",
|
||||
@ -3031,12 +3082,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/country-code-lookup": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/country-code-lookup/-/country-code-lookup-0.1.3.tgz",
|
||||
"integrity": "sha512-gLu+AQKHUnkSQNTxShKgi/4tYd0vEEait3JMrLNZgYlmIZ9DJLkHUjzXE9qcs7dy3xY/kUx2/nOxZ0Z3D9JE+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
@ -4649,6 +4694,21 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@ -4800,6 +4860,37 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
|
||||
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^2.1.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.3",
|
||||
"path-type": "^6.0.0",
|
||||
"slash": "^5.1.0",
|
||||
"unicorn-magic": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globby/node_modules/ignore": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz",
|
||||
"integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@ -5737,6 +5828,13 @@
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@ -5753,6 +5851,33 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.22",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||
"integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@ -6042,6 +6167,22 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lineclip": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/lineclip/-/lineclip-1.1.5.tgz",
|
||||
"integrity": "sha512-KlA/wRSjpKl7tS9iRUdlG72oQ7qZ1IlVbVgHwoO10TBR/4gQ86uhKow6nlzMAJJhjCWKto8OeoAzzIzKSmN25A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@ -6117,6 +6258,98 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdownlint": {
|
||||
"version": "0.38.0",
|
||||
"resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.38.0.tgz",
|
||||
"integrity": "sha512-xaSxkaU7wY/0852zGApM8LdlIfGCW8ETZ0Rr62IQtAnUMlMuifsg09vWJcNYeL4f0anvr8Vo4ZQar8jGpV0btQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark": "4.0.2",
|
||||
"micromark-core-commonmark": "2.0.3",
|
||||
"micromark-extension-directive": "4.0.0",
|
||||
"micromark-extension-gfm-autolink-literal": "2.1.0",
|
||||
"micromark-extension-gfm-footnote": "2.1.0",
|
||||
"micromark-extension-gfm-table": "2.1.1",
|
||||
"micromark-extension-math": "3.1.0",
|
||||
"micromark-util-types": "2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/DavidAnson"
|
||||
}
|
||||
},
|
||||
"node_modules/markdownlint-cli2": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.18.1.tgz",
|
||||
"integrity": "sha512-/4Osri9QFGCZOCTkfA8qJF+XGjKYERSHkXzxSyS1hd3ZERJGjvsUao2h4wdnvpHp6Tu2Jh/bPHM0FE9JJza6ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"globby": "14.1.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"markdown-it": "14.1.0",
|
||||
"markdownlint": "0.38.0",
|
||||
"markdownlint-cli2-formatter-default": "0.0.5",
|
||||
"micromatch": "4.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"markdownlint-cli2": "markdownlint-cli2-bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/DavidAnson"
|
||||
}
|
||||
},
|
||||
"node_modules/markdownlint-cli2-formatter-default": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.5.tgz",
|
||||
"integrity": "sha512-4XKTwQ5m1+Txo2kuQ3Jgpo/KmnG+X90dWt4acufg6HVGadTUG5hzHF/wssp9b5MBYOMCnZ9RMPaU//uHsszF8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/DavidAnson"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"markdownlint-cli2": ">=0.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -6279,6 +6512,13 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@ -6358,6 +6598,102 @@
|
||||
"micromark-util-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-directive": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz",
|
||||
"integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-factory-whitespace": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0",
|
||||
"parse-entities": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-autolink-literal": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
||||
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-core-commonmark": "^2.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
|
||||
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-math": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
|
||||
"integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/katex": "^0.16.0",
|
||||
"devlop": "^1.0.0",
|
||||
"katex": "^0.16.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-factory-destination": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||
@ -7343,6 +7679,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
|
||||
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -7362,6 +7711,38 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
|
||||
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
|
||||
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@ -7462,6 +7843,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-jinja-template": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-2.1.0.tgz",
|
||||
"integrity": "sha512-mzoCp2Oy9BDSug80fw3B3J4n4KQj1hRvoQOL1akqcDKBb5nvYxrik9zUEDs4AEJ6nK7QDTGoH0y9rx7AlnQ78Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||
@ -7526,6 +7917,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@ -7547,6 +7948,21 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz",
|
||||
"integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz",
|
||||
"integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
@ -8086,6 +8502,19 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -8657,6 +9086,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@ -8682,6 +9118,19 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
@ -8972,6 +9421,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-polygon": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/which-polygon/-/which-polygon-2.2.1.tgz",
|
||||
"integrity": "sha512-RlpWbqz12OMT0r2lEHk7IUPXz0hb1L/ZZsGushB2P2qxuBu1aq1+bcTfsLtfoRBYHsED6ruBMiwFaidvXZfQVw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lineclip": "^1.1.5",
|
||||
"rbush": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
|
||||
84
package.json
84
package.json
@ -1,21 +1,11 @@
|
||||
{
|
||||
"name": "livedash-node",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint --fix './**/*.{ts,tsx}'",
|
||||
"format": "prettier --write .",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "node prisma/seed.mjs"
|
||||
},
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@rapideditor/country-coder": "^5.4.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/d3-cloud": "^1.2.9",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
@ -24,7 +14,6 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"country-code-lookup": "^0.1.3",
|
||||
"csv-parse": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-cloud": "^1.2.7",
|
||||
@ -45,6 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/node": "^22.15.21",
|
||||
@ -56,11 +46,77 @@
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"eslint-plugin-prettier": "^5.4.0",
|
||||
"markdownlint-cli2": "^0.18.1",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-jinja-template": "^2.1.0",
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev --turbopack",
|
||||
"format": "npx prettier --write .",
|
||||
"format:check": "npx prettier --check .",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "npx eslint --fix",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "node prisma/seed.mjs",
|
||||
"prisma:studio": "prisma studio",
|
||||
"start": "next start",
|
||||
"lint:md": "markdownlint-cli2 \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"",
|
||||
"lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\""
|
||||
},
|
||||
"prettier": {
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 80,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false,
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.md",
|
||||
"*.markdown"
|
||||
],
|
||||
"options": {
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"proseWrap": "preserve",
|
||||
"printWidth": 100
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
"prettier-plugin-jinja-template"
|
||||
]
|
||||
},
|
||||
"markdownlint-cli2": {
|
||||
"config": {
|
||||
"MD007": {
|
||||
"indent": 4,
|
||||
"start_indented": false,
|
||||
"start_indent": 4
|
||||
},
|
||||
"MD013": false,
|
||||
"MD030": {
|
||||
"ul_single": 3,
|
||||
"ol_single": 2,
|
||||
"ul_multi": 3,
|
||||
"ol_multi": 2
|
||||
},
|
||||
"MD033": false
|
||||
},
|
||||
"ignores": [
|
||||
"node_modules",
|
||||
".git",
|
||||
"*.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,12 @@ export default async function handler(
|
||||
data: { csvUrl },
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} else if (req.method === "GET") {
|
||||
// Get company data
|
||||
const company = await prisma.company.findUnique({
|
||||
where: { id: user.companyId },
|
||||
});
|
||||
res.json({ company });
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
SessionApiResponse,
|
||||
SessionQuery,
|
||||
} from "../../../lib/types";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@ -39,7 +40,7 @@ export default async function handler(
|
||||
const pageSize = Number(queryPageSize) || 10;
|
||||
|
||||
try {
|
||||
const whereClause: any = { companyId };
|
||||
const whereClause: Prisma.SessionWhereInput = { companyId };
|
||||
|
||||
// Search Term
|
||||
if (
|
||||
@ -48,11 +49,10 @@ export default async function handler(
|
||||
searchTerm.trim() !== ""
|
||||
) {
|
||||
const searchConditions = [
|
||||
{ id: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ sessionId: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ category: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ initialMsg: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ transcriptContent: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ id: { contains: searchTerm } },
|
||||
{ category: { contains: searchTerm } },
|
||||
{ initialMsg: { contains: searchTerm } },
|
||||
{ transcriptContent: { contains: searchTerm } },
|
||||
];
|
||||
whereClause.OR = searchConditions;
|
||||
}
|
||||
@ -69,37 +69,59 @@ export default async function handler(
|
||||
|
||||
// Date Range Filter
|
||||
if (startDate && typeof startDate === "string") {
|
||||
if (!whereClause.startTime) whereClause.startTime = {};
|
||||
whereClause.startTime.gte = new Date(startDate);
|
||||
whereClause.startTime = {
|
||||
...((whereClause.startTime as object) || {}),
|
||||
gte: new Date(startDate),
|
||||
};
|
||||
}
|
||||
if (endDate && typeof endDate === "string") {
|
||||
if (!whereClause.startTime) whereClause.startTime = {};
|
||||
const inclusiveEndDate = new Date(endDate);
|
||||
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
||||
whereClause.startTime.lt = inclusiveEndDate;
|
||||
whereClause.startTime = {
|
||||
...((whereClause.startTime as object) || {}),
|
||||
lt: inclusiveEndDate,
|
||||
};
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let orderByClause: any = { startTime: "desc" };
|
||||
if (sortKey && typeof sortKey === "string") {
|
||||
const order =
|
||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc";
|
||||
const validSortKeys: { [key: string]: string } = {
|
||||
startTime: "startTime",
|
||||
category: "category",
|
||||
language: "language",
|
||||
sentiment: "sentiment",
|
||||
messagesSent: "messagesSent",
|
||||
avgResponseTime: "avgResponseTime",
|
||||
};
|
||||
if (validSortKeys[sortKey]) {
|
||||
orderByClause = { [validSortKeys[sortKey]]: order };
|
||||
}
|
||||
const validSortKeys: { [key: string]: string } = {
|
||||
startTime: "startTime",
|
||||
category: "category",
|
||||
language: "language",
|
||||
sentiment: "sentiment",
|
||||
messagesSent: "messagesSent",
|
||||
avgResponseTime: "avgResponseTime",
|
||||
};
|
||||
|
||||
let orderByCondition:
|
||||
| Prisma.SessionOrderByWithRelationInput
|
||||
| Prisma.SessionOrderByWithRelationInput[];
|
||||
|
||||
const primarySortField =
|
||||
sortKey && typeof sortKey === "string" && validSortKeys[sortKey]
|
||||
? validSortKeys[sortKey]
|
||||
: "startTime"; // Default to startTime field if sortKey is invalid/missing
|
||||
|
||||
const primarySortOrder =
|
||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
|
||||
|
||||
if (primarySortField === "startTime") {
|
||||
// If sorting by startTime, it's the only sort criteria
|
||||
orderByCondition = { [primarySortField]: primarySortOrder };
|
||||
} else {
|
||||
// If sorting by another field, use startTime: "desc" as secondary sort
|
||||
orderByCondition = [
|
||||
{ [primarySortField]: primarySortOrder },
|
||||
{ startTime: "desc" },
|
||||
];
|
||||
}
|
||||
// Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime",
|
||||
// and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" },
|
||||
// which is the correct overall default sort.
|
||||
|
||||
const prismaSessions = await prisma.session.findMany({
|
||||
where: whereClause,
|
||||
orderBy: orderByClause,
|
||||
orderBy: orderByCondition,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
@ -1,27 +1,20 @@
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import { sendEmail } from "../../lib/sendEmail";
|
||||
import crypto from "crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
|
||||
type NextApiRequest = IncomingMessage & {
|
||||
body: {
|
||||
email: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type NextApiResponse = ServerResponse & {
|
||||
status: (code: number) => NextApiResponse;
|
||||
json: (data: Record<string, unknown>) => void;
|
||||
end: () => void;
|
||||
};
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
const { email } = req.body;
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
|
||||
// Type the body with a type assertion
|
||||
const { email } = req.body as { email: string };
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return res.status(200).end(); // always 200 for privacy
|
||||
|
||||
|
||||
@ -1,43 +1,63 @@
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
|
||||
type NextApiRequest = IncomingMessage & {
|
||||
body: {
|
||||
token: string;
|
||||
password: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type NextApiResponse = ServerResponse & {
|
||||
status: (code: number) => NextApiResponse;
|
||||
json: (data: Record<string, unknown>) => void;
|
||||
end: () => void;
|
||||
};
|
||||
import type { NextApiRequest, NextApiResponse } from "next"; // Import official Next.js types
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
req: NextApiRequest, // Use official NextApiRequest
|
||||
res: NextApiResponse // Use official NextApiResponse
|
||||
) {
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
const { token, password } = req.body;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: { gte: new Date() },
|
||||
},
|
||||
});
|
||||
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]); // Good practice to set Allow header for 405
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
res.status(200).end();
|
||||
// It's good practice to explicitly type the expected body for clarity and safety
|
||||
const { token, password } = req.body as { token?: string; password?: string };
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({ error: "Token and password are required." });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
// Example: Add password complexity rule
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Password must be at least 8 characters long." });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: { gte: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid or expired token. Please request a new password reset.",
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Instead of just res.status(200).end(), send a success message
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: "Password has been reset successfully." });
|
||||
} catch (error) {
|
||||
console.error("Reset password error:", error); // Log the error for server-side debugging
|
||||
// Provide a generic error message to the client
|
||||
return res.status(500).json({
|
||||
error: "An internal server error occurred. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
79
playwright.config.ts
Normal file
79
playwright.config.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env.development') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "npm run start",
|
||||
url: "http://127.0.0.1:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
489
tests-examples/demo-todo-app.spec.ts
Normal file
489
tests-examples/demo-todo-app.spec.ts
Normal file
@ -0,0 +1,489 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("https://demo.playwright.dev/todomvc");
|
||||
});
|
||||
|
||||
const TODO_ITEMS = [
|
||||
"buy some cheese",
|
||||
"feed the cat",
|
||||
"book a doctors appointment",
|
||||
] as const;
|
||||
|
||||
test.describe("New Todo", () => {
|
||||
test("should allow me to add todo items", async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||
|
||||
// Create 1st todo.
|
||||
await newTodo.fill(TODO_ITEMS[0]);
|
||||
await newTodo.press("Enter");
|
||||
|
||||
// Make sure the list only has one todo item.
|
||||
await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0]]);
|
||||
|
||||
// Create 2nd todo.
|
||||
await newTodo.fill(TODO_ITEMS[1]);
|
||||
await newTodo.press("Enter");
|
||||
|
||||
// Make sure the list now has two todo items.
|
||||
await expect(page.getByTestId("todo-title")).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
TODO_ITEMS[1],
|
||||
]);
|
||||
|
||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||
});
|
||||
|
||||
test("should clear text input field when an item is added", async ({
|
||||
page,
|
||||
}) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||
|
||||
// Create one todo item.
|
||||
await newTodo.fill(TODO_ITEMS[0]);
|
||||
await newTodo.press("Enter");
|
||||
|
||||
// Check that input is empty.
|
||||
await expect(newTodo).toBeEmpty();
|
||||
await checkNumberOfTodosInLocalStorage(page, 1);
|
||||
});
|
||||
|
||||
test("should append new items to the bottom of the list", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create 3 items.
|
||||
await createDefaultTodos(page);
|
||||
|
||||
// create a todo count locator
|
||||
const todoCount = page.getByTestId("todo-count");
|
||||
|
||||
// Check test using different methods.
|
||||
await expect(page.getByText("3 items left")).toBeVisible();
|
||||
await expect(todoCount).toHaveText("3 items left");
|
||||
await expect(todoCount).toContainText("3");
|
||||
await expect(todoCount).toHaveText(/3/);
|
||||
|
||||
// Check all items in one call.
|
||||
await expect(page.getByTestId("todo-title")).toHaveText(TODO_ITEMS);
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mark all as completed", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test("should allow me to mark all items as completed", async ({ page }) => {
|
||||
// Complete all todos.
|
||||
await page.getByLabel("Mark all as complete").check();
|
||||
|
||||
// Ensure all todos have 'completed' class.
|
||||
await expect(page.getByTestId("todo-item")).toHaveClass([
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
]);
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test("should allow me to clear the complete state of all items", async ({
|
||||
page,
|
||||
}) => {
|
||||
const toggleAll = page.getByLabel("Mark all as complete");
|
||||
// Check and then immediately uncheck.
|
||||
await toggleAll.check();
|
||||
await toggleAll.uncheck();
|
||||
|
||||
// Should be no completed classes.
|
||||
await expect(page.getByTestId("todo-item")).toHaveClass(["", "", ""]);
|
||||
});
|
||||
|
||||
test("complete all checkbox should update state when items are completed / cleared", async ({
|
||||
page,
|
||||
}) => {
|
||||
const toggleAll = page.getByLabel("Mark all as complete");
|
||||
await toggleAll.check();
|
||||
await expect(toggleAll).toBeChecked();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
|
||||
// Uncheck first todo.
|
||||
const firstTodo = page.getByTestId("todo-item").nth(0);
|
||||
await firstTodo.getByRole("checkbox").uncheck();
|
||||
|
||||
// Reuse toggleAll locator and make sure its not checked.
|
||||
await expect(toggleAll).not.toBeChecked();
|
||||
|
||||
await firstTodo.getByRole("checkbox").check();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
|
||||
// Assert the toggle all is checked again.
|
||||
await expect(toggleAll).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Item", () => {
|
||||
test("should allow me to mark items as complete", async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||
|
||||
// Create two items.
|
||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press("Enter");
|
||||
}
|
||||
|
||||
// Check first item.
|
||||
const firstTodo = page.getByTestId("todo-item").nth(0);
|
||||
await firstTodo.getByRole("checkbox").check();
|
||||
await expect(firstTodo).toHaveClass("completed");
|
||||
|
||||
// Check second item.
|
||||
const secondTodo = page.getByTestId("todo-item").nth(1);
|
||||
await expect(secondTodo).not.toHaveClass("completed");
|
||||
await secondTodo.getByRole("checkbox").check();
|
||||
|
||||
// Assert completed class.
|
||||
await expect(firstTodo).toHaveClass("completed");
|
||||
await expect(secondTodo).toHaveClass("completed");
|
||||
});
|
||||
|
||||
test("should allow me to un-mark items as complete", async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||
|
||||
// Create two items.
|
||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press("Enter");
|
||||
}
|
||||
|
||||
const firstTodo = page.getByTestId("todo-item").nth(0);
|
||||
const secondTodo = page.getByTestId("todo-item").nth(1);
|
||||
const firstTodoCheckbox = firstTodo.getByRole("checkbox");
|
||||
|
||||
await firstTodoCheckbox.check();
|
||||
await expect(firstTodo).toHaveClass("completed");
|
||||
await expect(secondTodo).not.toHaveClass("completed");
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
await firstTodoCheckbox.uncheck();
|
||||
await expect(firstTodo).not.toHaveClass("completed");
|
||||
await expect(secondTodo).not.toHaveClass("completed");
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
||||
});
|
||||
|
||||
test("should allow me to edit an item", async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
|
||||
const todoItems = page.getByTestId("todo-item");
|
||||
const secondTodo = todoItems.nth(1);
|
||||
await secondTodo.dblclick();
|
||||
await expect(secondTodo.getByRole("textbox", { name: "Edit" })).toHaveValue(
|
||||
TODO_ITEMS[1]
|
||||
);
|
||||
await secondTodo
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.fill("buy some sausages");
|
||||
await secondTodo.getByRole("textbox", { name: "Edit" }).press("Enter");
|
||||
|
||||
// Explicitly assert the new text value.
|
||||
await expect(todoItems).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
"buy some sausages",
|
||||
TODO_ITEMS[2],
|
||||
]);
|
||||
await checkTodosInLocalStorage(page, "buy some sausages");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Editing", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test("should hide other controls when editing", async ({ page }) => {
|
||||
const todoItem = page.getByTestId("todo-item").nth(1);
|
||||
await todoItem.dblclick();
|
||||
await expect(todoItem.getByRole("checkbox")).not.toBeVisible();
|
||||
await expect(
|
||||
todoItem.locator("label", {
|
||||
hasText: TODO_ITEMS[1],
|
||||
})
|
||||
).not.toBeVisible();
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test("should save edits on blur", async ({ page }) => {
|
||||
const todoItems = page.getByTestId("todo-item");
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems
|
||||
.nth(1)
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.fill("buy some sausages");
|
||||
await todoItems
|
||||
.nth(1)
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.dispatchEvent("blur");
|
||||
|
||||
await expect(todoItems).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
"buy some sausages",
|
||||
TODO_ITEMS[2],
|
||||
]);
|
||||
await checkTodosInLocalStorage(page, "buy some sausages");
|
||||
});
|
||||
|
||||
test("should trim entered text", async ({ page }) => {
|
||||
const todoItems = page.getByTestId("todo-item");
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems
|
||||
.nth(1)
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.fill(" buy some sausages ");
|
||||
await todoItems
|
||||
.nth(1)
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.press("Enter");
|
||||
|
||||
await expect(todoItems).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
"buy some sausages",
|
||||
TODO_ITEMS[2],
|
||||
]);
|
||||
await checkTodosInLocalStorage(page, "buy some sausages");
|
||||
});
|
||||
|
||||
test("should remove the item if an empty text string was entered", async ({
|
||||
page,
|
||||
}) => {
|
||||
const todoItems = page.getByTestId("todo-item");
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill("");
|
||||
await todoItems
|
||||
.nth(1)
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.press("Enter");
|
||||
|
||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||
});
|
||||
|
||||
test("should cancel edits on escape", async ({ page }) => {
|
||||
const todoItems = page.getByTestId("todo-item");
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems
|
||||
.nth(1)
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.fill("buy some sausages");
|
||||
await todoItems
|
||||
.nth(1)
|
||||
.getByRole("textbox", { name: "Edit" })
|
||||
.press("Escape");
|
||||
await expect(todoItems).toHaveText(TODO_ITEMS);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Counter", () => {
|
||||
test("should display the current number of todo items", async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||
|
||||
// create a todo count locator
|
||||
const todoCount = page.getByTestId("todo-count");
|
||||
|
||||
await newTodo.fill(TODO_ITEMS[0]);
|
||||
await newTodo.press("Enter");
|
||||
|
||||
await expect(todoCount).toContainText("1");
|
||||
|
||||
await newTodo.fill(TODO_ITEMS[1]);
|
||||
await newTodo.press("Enter");
|
||||
await expect(todoCount).toContainText("2");
|
||||
|
||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Clear completed button", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
});
|
||||
|
||||
test("should display the correct text", async ({ page }) => {
|
||||
await page.locator(".todo-list li .toggle").first().check();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Clear completed" })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should remove completed items when clicked", async ({ page }) => {
|
||||
const todoItems = page.getByTestId("todo-item");
|
||||
await todoItems.nth(1).getByRole("checkbox").check();
|
||||
await page.getByRole("button", { name: "Clear completed" }).click();
|
||||
await expect(todoItems).toHaveCount(2);
|
||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||
});
|
||||
|
||||
test("should be hidden when there are no items that are completed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator(".todo-list li .toggle").first().check();
|
||||
await page.getByRole("button", { name: "Clear completed" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Clear completed" })
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Persistence", () => {
|
||||
test("should persist its data", async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||
|
||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press("Enter");
|
||||
}
|
||||
|
||||
const todoItems = page.getByTestId("todo-item");
|
||||
const firstTodoCheck = todoItems.nth(0).getByRole("checkbox");
|
||||
await firstTodoCheck.check();
|
||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||
await expect(firstTodoCheck).toBeChecked();
|
||||
await expect(todoItems).toHaveClass(["completed", ""]);
|
||||
|
||||
// Ensure there is 1 completed item.
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
// Now reload.
|
||||
await page.reload();
|
||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||
await expect(firstTodoCheck).toBeChecked();
|
||||
await expect(todoItems).toHaveClass(["completed", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Routing", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
// make sure the app had a chance to save updated todos in storage
|
||||
// before navigating to a new view, otherwise the items can get lost :(
|
||||
// in some frameworks like Durandal
|
||||
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
||||
});
|
||||
|
||||
test("should allow me to display active items", async ({ page }) => {
|
||||
const todoItem = page.getByTestId("todo-item");
|
||||
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
await page.getByRole("link", { name: "Active" }).click();
|
||||
await expect(todoItem).toHaveCount(2);
|
||||
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||
});
|
||||
|
||||
test("should respect the back button", async ({ page }) => {
|
||||
const todoItem = page.getByTestId("todo-item");
|
||||
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
await test.step("Showing all items", async () => {
|
||||
await page.getByRole("link", { name: "All" }).click();
|
||||
await expect(todoItem).toHaveCount(3);
|
||||
});
|
||||
|
||||
await test.step("Showing active items", async () => {
|
||||
await page.getByRole("link", { name: "Active" }).click();
|
||||
});
|
||||
|
||||
await test.step("Showing completed items", async () => {
|
||||
await page.getByRole("link", { name: "Completed" }).click();
|
||||
});
|
||||
|
||||
await expect(todoItem).toHaveCount(1);
|
||||
await page.goBack();
|
||||
await expect(todoItem).toHaveCount(2);
|
||||
await page.goBack();
|
||||
await expect(todoItem).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("should allow me to display completed items", async ({ page }) => {
|
||||
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
await page.getByRole("link", { name: "Completed" }).click();
|
||||
await expect(page.getByTestId("todo-item")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("should allow me to display all items", async ({ page }) => {
|
||||
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
await page.getByRole("link", { name: "Active" }).click();
|
||||
await page.getByRole("link", { name: "Completed" }).click();
|
||||
await page.getByRole("link", { name: "All" }).click();
|
||||
await expect(page.getByTestId("todo-item")).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("should highlight the currently applied filter", async ({ page }) => {
|
||||
await expect(page.getByRole("link", { name: "All" })).toHaveClass(
|
||||
"selected"
|
||||
);
|
||||
|
||||
//create locators for active and completed links
|
||||
const activeLink = page.getByRole("link", { name: "Active" });
|
||||
const completedLink = page.getByRole("link", { name: "Completed" });
|
||||
await activeLink.click();
|
||||
|
||||
// Page change - active items.
|
||||
await expect(activeLink).toHaveClass("selected");
|
||||
await completedLink.click();
|
||||
|
||||
// Page change - completed items.
|
||||
await expect(completedLink).toHaveClass("selected");
|
||||
});
|
||||
});
|
||||
|
||||
async function createDefaultTodos(page: Page) {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||
|
||||
for (const item of TODO_ITEMS) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press("Enter");
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
||||
return await page.waitForFunction((e) => {
|
||||
return JSON.parse(localStorage["react-todos"]).length === e;
|
||||
}, expected);
|
||||
}
|
||||
|
||||
async function checkNumberOfCompletedTodosInLocalStorage(
|
||||
page: Page,
|
||||
expected: number
|
||||
) {
|
||||
return await page.waitForFunction((e) => {
|
||||
return (
|
||||
JSON.parse(localStorage["react-todos"]).filter(
|
||||
(todo: any) => todo.completed
|
||||
).length === e
|
||||
);
|
||||
}, expected);
|
||||
}
|
||||
|
||||
async function checkTodosInLocalStorage(page: Page, title: string) {
|
||||
return await page.waitForFunction((t) => {
|
||||
return JSON.parse(localStorage["react-todos"])
|
||||
.map((todo: any) => todo.title)
|
||||
.includes(t);
|
||||
}, title);
|
||||
}
|
||||
@ -1,36 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false, // Allow implicit any types
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"incremental": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"noImplicitAny": false, // Allow implicit any types
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"target": "es5"
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"components/SessionDetails.tsx.bak"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user