enhanced the worknote section implemented websocket and in-app notifucation also aaudit tril api integrated
This commit is contained in:
parent
3208a1ea7f
commit
d919e925c8
88
package-lock.json
generated
88
package-lock.json
generated
@ -53,6 +53,7 @@
|
|||||||
"react-resizable-panels": "^2.0.12",
|
"react-resizable-panels": "^2.0.12",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"recharts": "^2.12.2",
|
"recharts": "^2.12.2",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^1.4.3",
|
"sonner": "^1.4.3",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@ -2986,6 +2987,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@ -4227,7 +4234,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -4338,6 +4344,28 @@
|
|||||||
"embla-carousel": "8.6.0"
|
"embla-carousel": "8.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.4",
|
"version": "5.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||||
@ -5575,7 +5603,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -6316,6 +6343,34 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||||
|
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "1.7.4",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
@ -6815,6 +6870,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -55,6 +55,7 @@
|
|||||||
"react-resizable-panels": "^2.0.12",
|
"react-resizable-panels": "^2.0.12",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"recharts": "^2.12.2",
|
"recharts": "^2.12.2",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^1.4.3",
|
"sonner": "^1.4.3",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
205
src/App.tsx
205
src/App.tsx
@ -35,16 +35,17 @@ import { ConstitutionalChangePage } from './components/applications/Constitution
|
|||||||
import { ConstitutionalChangeDetails } from './components/applications/ConstitutionalChangeDetails';
|
import { ConstitutionalChangeDetails } from './components/applications/ConstitutionalChangeDetails';
|
||||||
import { RelocationRequestPage } from './components/applications/RelocationRequestPage';
|
import { RelocationRequestPage } from './components/applications/RelocationRequestPage';
|
||||||
import { RelocationRequestDetails } from './components/applications/RelocationRequestDetails';
|
import { RelocationRequestDetails } from './components/applications/RelocationRequestDetails';
|
||||||
import { WorknotePage } from './components/applications/WorknotePage';
|
|
||||||
import { DealerResignationPage } from './components/dealer/DealerResignationPage';
|
import { DealerResignationPage } from './components/dealer/DealerResignationPage';
|
||||||
import { DealerConstitutionalChangePage } from './components/dealer/DealerConstitutionalChangePage';
|
import { DealerConstitutionalChangePage } from './components/dealer/DealerConstitutionalChangePage';
|
||||||
import { DealerRelocationPage } from './components/dealer/DealerRelocationPage';
|
import { DealerRelocationPage } from './components/dealer/DealerRelocationPage';
|
||||||
import QuestionnaireBuilder from './components/admin/QuestionnaireBuilder';
|
import QuestionnaireBuilder from './components/admin/QuestionnaireBuilder';
|
||||||
import QuestionnaireList from './components/admin/QuestionnaireList';
|
import QuestionnaireList from './components/admin/QuestionnaireList';
|
||||||
|
import { WorkNotesPage } from './components/applications/WorkNotesPage';
|
||||||
import { Toaster } from './components/ui/sonner';
|
import { Toaster } from './components/ui/sonner';
|
||||||
import { User } from './lib/mock-data';
|
import { User } from './lib/mock-data';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { API } from './api/API';
|
import { API } from './api/API';
|
||||||
|
import { SocketProvider } from './context/SocketContext';
|
||||||
|
|
||||||
// Layout Component
|
// Layout Component
|
||||||
const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string }) => {
|
const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string }) => {
|
||||||
@ -174,119 +175,129 @@ export default function App() {
|
|||||||
|
|
||||||
// Protected Routes
|
// Protected Routes
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<SocketProvider>
|
||||||
{/* Prospective Dealer Route - STRICTLY ISOLATED */}
|
<Routes>
|
||||||
<Route
|
{/* Prospective Dealer Route - STRICTLY ISOLATED */}
|
||||||
path="/prospective-dashboard"
|
<Route
|
||||||
element={
|
path="/prospective-dashboard"
|
||||||
<RoleGuard allowedRoles={['Prospective Dealer']}>
|
element={
|
||||||
<ProspectiveDashboardPage />
|
<RoleGuard allowedRoles={['Prospective Dealer']}>
|
||||||
|
<ProspectiveDashboardPage />
|
||||||
|
</RoleGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Internal & Dealer Routes - EXCLUDES Prospective Dealers */}
|
||||||
|
<Route element={
|
||||||
|
<RoleGuard excludeRoles={['Prospective Dealer']} redirectTo="/prospective-dashboard">
|
||||||
|
<AppLayout onLogout={handleLogout} title={getPageTitle(location.pathname)} />
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
}
|
}>
|
||||||
/>
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|
||||||
{/* Internal & Dealer Routes - EXCLUDES Prospective Dealers */}
|
{/* Dashboards */}
|
||||||
<Route element={
|
<Route path="/dashboard" element={
|
||||||
<RoleGuard excludeRoles={['Prospective Dealer']} redirectTo="/prospective-dashboard">
|
currentUser?.role === 'Finance Admin' || currentUser?.role === 'Finance' ?
|
||||||
<AppLayout onLogout={handleLogout} title={getPageTitle(location.pathname)} />
|
<FinanceDashboard currentUser={currentUser} onNavigate={(path) => navigate(`/${path}`)} onViewPaymentDetails={(id) => navigate(`/finance-onboarding/${id}`)} onViewFnFDetails={(id) => navigate(`/finance-fnf/${id}`)} /> :
|
||||||
</RoleGuard>
|
currentUser?.role === 'Dealer' ?
|
||||||
}>
|
<DealerDashboard currentUser={currentUser} onNavigate={(path) => navigate(`/${path}`)} /> :
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Dashboard onNavigate={(path) => navigate(`/${path}`)} />
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Dashboards */}
|
{/* Applications */}
|
||||||
<Route path="/dashboard" element={
|
<Route path="/applications" element={<ApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" />} />
|
||||||
currentUser?.role === 'Finance Admin' || currentUser?.role === 'Finance' ?
|
<Route path="/applications/:id" element={<ApplicationDetails />} />
|
||||||
<FinanceDashboard currentUser={currentUser} onNavigate={(path) => navigate(`/${path}`)} onViewPaymentDetails={(id) => navigate(`/finance-onboarding/${id}`)} onViewFnFDetails={(id) => navigate(`/finance-fnf/${id}`)} /> :
|
<Route path="/applications/:id/worknotes" element={
|
||||||
currentUser?.role === 'Dealer' ?
|
<WorkNotesPage
|
||||||
<DealerDashboard currentUser={currentUser} onNavigate={(path) => navigate(`/${path}`)} /> :
|
applicationId={window.location.pathname.split('/')[2]}
|
||||||
<Dashboard onNavigate={(path) => navigate(`/${path}`)} />
|
applicationName=""
|
||||||
} />
|
registrationNumber=""
|
||||||
|
onBack={() => window.history.back()}
|
||||||
|
/>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Applications */}
|
<Route path="/all-applications" element={
|
||||||
<Route path="/applications" element={<ApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" />} />
|
currentUser?.role === 'DD' ? <AllApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" /> : <Navigate to="/dashboard" />
|
||||||
<Route path="/applications/:id" element={<ApplicationDetails applicationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/applications')} />} />
|
} />
|
||||||
|
|
||||||
<Route path="/all-applications" element={
|
{/* Admin/Lead Routes */}
|
||||||
currentUser?.role === 'DD' ? <AllApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" /> : <Navigate to="/dashboard" />
|
<Route path="/opportunity-requests" element={<OpportunityRequestsPage onViewDetails={(id) => navigate(`/applications/${id}`)} />} />
|
||||||
} />
|
<Route path="/unopportunity-requests" element={<UnopportunityRequestsPage onViewDetails={(id) => navigate(`/applications/${id}`)} />} />
|
||||||
|
|
||||||
{/* Admin/Lead Routes */}
|
{/* Other Modules */}
|
||||||
<Route path="/opportunity-requests" element={<OpportunityRequestsPage onViewDetails={(id) => navigate(`/applications/${id}`)} />} />
|
<Route path="/users" element={<UserManagementPage />} />
|
||||||
<Route path="/unopportunity-requests" element={<UnopportunityRequestsPage onViewDetails={(id) => navigate(`/applications/${id}`)} />} />
|
<Route path="/master" element={<MasterPage />} />
|
||||||
|
<Route path="/questions" element={<QuestionnaireList />} />
|
||||||
|
<Route path="/questionnaire-builder" element={<QuestionnaireBuilder />} />
|
||||||
|
<Route path="/questionnaire-builder/:id" element={<QuestionnaireBuilder />} />
|
||||||
|
<Route path="/questionnaires" element={<QuestionnaireList />} />
|
||||||
|
|
||||||
{/* Other Modules */}
|
{/* HR/Finance Modules (Simplified for brevity, following pattern) */}
|
||||||
<Route path="/users" element={<UserManagementPage />} />
|
<Route path="/resignation" element={<ResignationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/resignation/${id}`)} />} />
|
||||||
<Route path="/master" element={<MasterPage />} />
|
<Route path="/resignation/:id" element={<ResignationDetails resignationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/resignation')} currentUser={currentUser} />} />
|
||||||
<Route path="/questions" element={<QuestionnaireList />} />
|
|
||||||
<Route path="/questionnaire-builder" element={<QuestionnaireBuilder />} />
|
|
||||||
<Route path="/questionnaire-builder/:id" element={<QuestionnaireBuilder />} />
|
|
||||||
<Route path="/questionnaires" element={<QuestionnaireList />} />
|
|
||||||
|
|
||||||
{/* HR/Finance Modules (Simplified for brevity, following pattern) */}
|
<Route path="/termination" element={<TerminationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/termination/${id}`)} />} />
|
||||||
<Route path="/resignation" element={<ResignationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/resignation/${id}`)} />} />
|
<Route path="/termination/:id" element={<TerminationDetails terminationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/termination')} currentUser={currentUser} />} />
|
||||||
<Route path="/resignation/:id" element={<ResignationDetails resignationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/resignation')} currentUser={currentUser} />} />
|
|
||||||
|
|
||||||
<Route path="/termination" element={<TerminationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/termination/${id}`)} />} />
|
<Route path="/fnf" element={<FnFPage currentUser={currentUser} onViewDetails={(id) => navigate(`/fnf/${id}`)} />} />
|
||||||
<Route path="/termination/:id" element={<TerminationDetails terminationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/termination')} currentUser={currentUser} />} />
|
<Route path="/fnf/:id" element={<FnFDetails fnfId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/fnf')} currentUser={currentUser} />} />
|
||||||
|
|
||||||
<Route path="/fnf" element={<FnFPage currentUser={currentUser} onViewDetails={(id) => navigate(`/fnf/${id}`)} />} />
|
<Route path="/finance-onboarding" element={<FinanceOnboardingPage onViewPaymentDetails={(id) => navigate(`/finance-onboarding/${id}`)} />} />
|
||||||
<Route path="/fnf/:id" element={<FnFDetails fnfId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/fnf')} currentUser={currentUser} />} />
|
<Route path="/finance-onboarding/:id" element={<FinancePaymentDetailsPage applicationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/finance-onboarding')} />} />
|
||||||
|
|
||||||
<Route path="/finance-onboarding" element={<FinanceOnboardingPage onViewPaymentDetails={(id) => navigate(`/finance-onboarding/${id}`)} />} />
|
<Route path="/finance-fnf" element={<FinanceFnFPage onViewFnFDetails={(id) => navigate(`/finance-fnf/${id}`)} />} />
|
||||||
<Route path="/finance-onboarding/:id" element={<FinancePaymentDetailsPage applicationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/finance-onboarding')} />} />
|
<Route path="/finance-fnf/:id" element={<FinanceFnFDetailsPage fnfId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/finance-fnf')} />} />
|
||||||
|
|
||||||
<Route path="/finance-fnf" element={<FinanceFnFPage onViewFnFDetails={(id) => navigate(`/finance-fnf/${id}`)} />} />
|
<Route path="/constitutional-change" element={<ConstitutionalChangePage currentUser={currentUser} onViewDetails={(id) => navigate(`/constitutional-change/${id}`)} />} />
|
||||||
<Route path="/finance-fnf/:id" element={<FinanceFnFDetailsPage fnfId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/finance-fnf')} />} />
|
<Route path="/constitutional-change/:id" element={<ConstitutionalChangeDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/constitutional-change')} currentUser={currentUser} onOpenWorknote={() => { }} />} />
|
||||||
|
|
||||||
<Route path="/constitutional-change" element={<ConstitutionalChangePage currentUser={currentUser} onViewDetails={(id) => navigate(`/constitutional-change/${id}`)} />} />
|
<Route path="/relocation-requests" element={<RelocationRequestPage currentUser={currentUser} onViewDetails={(id) => navigate(`/relocation-requests/${id}`)} />} />
|
||||||
<Route path="/constitutional-change/:id" element={<ConstitutionalChangeDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/constitutional-change')} currentUser={currentUser} onOpenWorknote={() => { }} />} />
|
<Route path="/relocation-requests/:id" element={<RelocationRequestDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/relocation-requests')} currentUser={currentUser} onOpenWorknote={() => { }} />} />
|
||||||
|
|
||||||
<Route path="/relocation-requests" element={<RelocationRequestPage currentUser={currentUser} onViewDetails={(id) => navigate(`/relocation-requests/${id}`)} />} />
|
{/* Dealer Routes */}
|
||||||
<Route path="/relocation-requests/:id" element={<RelocationRequestDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/relocation-requests')} currentUser={currentUser} onOpenWorknote={() => { }} />} />
|
<Route path="/dealer-resignation" element={<DealerResignationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/resignation/${id}`)} />} />
|
||||||
|
<Route path="/dealer-constitutional" element={<DealerConstitutionalChangePage currentUser={currentUser} onViewDetails={(id) => navigate(`/constitutional-change/${id}`)} />} />
|
||||||
|
<Route path="/dealer-relocation" element={<DealerRelocationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/relocation-requests/${id}`)} />} />
|
||||||
|
|
||||||
{/* Dealer Routes */}
|
{/* Placeholder Routes */}
|
||||||
<Route path="/dealer-resignation" element={<DealerResignationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/resignation/${id}`)} />} />
|
<Route path="/tasks" element={
|
||||||
<Route path="/dealer-constitutional" element={<DealerConstitutionalChangePage currentUser={currentUser} onViewDetails={(id) => navigate(`/constitutional-change/${id}`)} />} />
|
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
|
||||||
<Route path="/dealer-relocation" element={<DealerRelocationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/relocation-requests/${id}`)} />} />
|
<h2 className="text-slate-900 mb-2">My Tasks</h2>
|
||||||
|
<p className="text-slate-600">Task management interface would be displayed here</p>
|
||||||
{/* Placeholder Routes */}
|
<p className="text-slate-500 mt-4">Shows applications assigned to the current user</p>
|
||||||
<Route path="/tasks" element={
|
</div>
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
|
} />
|
||||||
<h2 className="text-slate-900 mb-2">My Tasks</h2>
|
<Route path="/reports" element={
|
||||||
<p className="text-slate-600">Task management interface would be displayed here</p>
|
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
|
||||||
<p className="text-slate-500 mt-4">Shows applications assigned to the current user</p>
|
<h2 className="text-slate-900 mb-2">Reports & Analytics</h2>
|
||||||
</div>
|
<p className="text-slate-600">Advanced reporting and analytics dashboard</p>
|
||||||
} />
|
<p className="text-slate-500 mt-4">Charts, export capabilities, and custom filters</p>
|
||||||
<Route path="/reports" element={
|
</div>
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
|
} />
|
||||||
<h2 className="text-slate-900 mb-2">Reports & Analytics</h2>
|
<Route path="/settings" element={
|
||||||
<p className="text-slate-600">Advanced reporting and analytics dashboard</p>
|
<div className="bg-white rounded-lg border border-slate-200 p-8">
|
||||||
<p className="text-slate-500 mt-4">Charts, export capabilities, and custom filters</p>
|
<h2 className="text-slate-900 mb-4">Settings</h2>
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
} />
|
<div>
|
||||||
<Route path="/settings" element={
|
<h3 className="text-slate-900 mb-2">Profile Settings</h3>
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-8">
|
<p className="text-slate-600">Update your profile information and preferences</p>
|
||||||
<h2 className="text-slate-900 mb-4">Settings</h2>
|
</div>
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div>
|
<h3 className="text-slate-900 mb-2">Notification Preferences</h3>
|
||||||
<h3 className="text-slate-900 mb-2">Profile Settings</h3>
|
<p className="text-slate-600">Configure email and system notifications</p>
|
||||||
<p className="text-slate-600">Update your profile information and preferences</p>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<h3 className="text-slate-900 mb-2">Security</h3>
|
||||||
<h3 className="text-slate-900 mb-2">Notification Preferences</h3>
|
<p className="text-slate-600">Change password and manage security settings</p>
|
||||||
<p className="text-slate-600">Configure email and system notifications</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-slate-900 mb-2">Security</h3>
|
|
||||||
<p className="text-slate-600">Change password and manage security settings</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
} />
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</SocketProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,12 @@ export const API = {
|
|||||||
deleteEmailTemplate: (id: string) => client.delete(`/admin/email-templates/${id}`),
|
deleteEmailTemplate: (id: string) => client.delete(`/admin/email-templates/${id}`),
|
||||||
previewEmailTemplate: (data: any) => client.post('/admin/email-templates/preview', data),
|
previewEmailTemplate: (data: any) => client.post('/admin/email-templates/preview', data),
|
||||||
|
|
||||||
|
// Audit Trail
|
||||||
|
getAuditLogs: (entityType: string, entityId: string, page: number = 1, limit: number = 50) =>
|
||||||
|
client.get('/audit/logs', { entityType, entityId, page, limit }),
|
||||||
|
getAuditSummary: (entityType: string, entityId: string) =>
|
||||||
|
client.get('/audit/summary', { entityType, entityId }),
|
||||||
|
|
||||||
// Prospective Login
|
// Prospective Login
|
||||||
sendOtp: (phone: string) => client.post('/prospective-login/send-otp', { phone }),
|
sendOtp: (phone: string) => client.post('/prospective-login/send-otp', { phone }),
|
||||||
verifyOtp: (phone: string, otp: string) => client.post('/prospective-login/verify-otp', { phone, otp }),
|
verifyOtp: (phone: string, otp: string) => client.post('/prospective-login/verify-otp', { phone, otp }),
|
||||||
|
|||||||
@ -104,10 +104,22 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
const updateQuestion = (index: number, field: keyof Question, value: any) => {
|
const updateQuestion = (index: number, field: keyof Question, value: any) => {
|
||||||
const newQuestions = [...questions];
|
const newQuestions = [...questions];
|
||||||
|
|
||||||
|
// Weight validation and option score capping
|
||||||
|
if (field === 'weight') {
|
||||||
|
const newWeight = parseFloat(value) || 0;
|
||||||
|
// Cap existing option scores if weight is decreased
|
||||||
|
if (newQuestions[index].options) {
|
||||||
|
newQuestions[index].options = newQuestions[index].options!.map(opt => ({
|
||||||
|
...opt,
|
||||||
|
score: Math.min(opt.score, newWeight)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-populate Yes/No options if switching to yesno
|
// Auto-populate Yes/No options if switching to yesno
|
||||||
if (field === 'inputType' && value === 'yesno' && (!newQuestions[index].options || newQuestions[index].options?.length === 0)) {
|
if (field === 'inputType' && value === 'yesno' && (!newQuestions[index].options || newQuestions[index].options?.length === 0)) {
|
||||||
newQuestions[index].options = [
|
newQuestions[index].options = [
|
||||||
{ text: 'Yes', score: 5 },
|
{ text: 'Yes', score: newQuestions[index].weight || 5 },
|
||||||
{ text: 'No', score: 0 }
|
{ text: 'No', score: 0 }
|
||||||
];
|
];
|
||||||
} else if (field === 'inputType' && value === 'select' && (!newQuestions[index].options)) {
|
} else if (field === 'inputType' && value === 'select' && (!newQuestions[index].options)) {
|
||||||
@ -128,9 +140,15 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
const updateOption = (questionIndex: number, optionIndex: number, field: 'text' | 'score', value: any) => {
|
const updateOption = (questionIndex: number, optionIndex: number, field: 'text' | 'score', value: any) => {
|
||||||
const newQuestions = [...questions];
|
const newQuestions = [...questions];
|
||||||
if (newQuestions[questionIndex].options) {
|
if (newQuestions[questionIndex].options) {
|
||||||
|
let finalValue = value;
|
||||||
|
if (field === 'score') {
|
||||||
|
const maxWeight = newQuestions[questionIndex].weight || 0;
|
||||||
|
finalValue = Math.min(parseFloat(value) || 0, maxWeight);
|
||||||
|
}
|
||||||
|
|
||||||
newQuestions[questionIndex].options![optionIndex] = {
|
newQuestions[questionIndex].options![optionIndex] = {
|
||||||
...newQuestions[questionIndex].options![optionIndex],
|
...newQuestions[questionIndex].options![optionIndex],
|
||||||
[field]: value
|
[field]: finalValue
|
||||||
};
|
};
|
||||||
setQuestions(newQuestions);
|
setQuestions(newQuestions);
|
||||||
}
|
}
|
||||||
@ -150,6 +168,18 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate option scores vs weight
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i];
|
||||||
|
if (q.inputType === 'select' || q.inputType === 'yesno') {
|
||||||
|
const invalidOption = q.options?.find(opt => opt.score > q.weight);
|
||||||
|
if (invalidOption) {
|
||||||
|
toast.error(`Question ${i + 1}: Option "${invalidOption.text}" score (${invalidOption.score}) exceeds question weightage (${q.weight})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (totalWeight !== 100) {
|
if (totalWeight !== 100) {
|
||||||
toast.error(`Total weightage must be exactly 100. Current total: ${totalWeight}`);
|
toast.error(`Total weightage must be exactly 100. Current total: ${totalWeight}`);
|
||||||
return;
|
return;
|
||||||
@ -320,8 +350,10 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={opt.score}
|
value={opt.score}
|
||||||
onChange={(e) => updateOption(index, optIndex, 'score', parseFloat(e.target.value))}
|
max={q.weight}
|
||||||
className="w-20 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none"
|
min={0}
|
||||||
|
onChange={(e) => updateOption(index, optIndex, 'score', e.target.value)}
|
||||||
|
className={`w-20 border ${opt.score > q.weight ? 'border-red-500 text-red-600' : 'border-slate-300'} p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { mockAuditLogs, mockDocuments, mockWorkNotes, Application, ApplicationStatus } from '../../lib/mock-data';
|
import { mockDocuments, mockWorkNotes, Application, ApplicationStatus } from '../../lib/mock-data';
|
||||||
import { onboardingService } from '../../services/onboarding.service';
|
import { onboardingService } from '../../services/onboarding.service';
|
||||||
|
import { auditService } from '../../services/audit.service';
|
||||||
import { WorkNotesPage } from './WorkNotesPage';
|
import { WorkNotesPage } from './WorkNotesPage';
|
||||||
import QuestionnaireResponseView from './QuestionnaireResponseView';
|
import QuestionnaireResponseView from './QuestionnaireResponseView';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
@ -320,6 +321,29 @@ export function ApplicationDetails() {
|
|||||||
fetchApplication();
|
fetchApplication();
|
||||||
}
|
}
|
||||||
}, [applicationId]);
|
}, [applicationId]);
|
||||||
|
// Audit Trail State
|
||||||
|
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||||
|
const [auditLoading, setAuditLoading] = useState(false);
|
||||||
|
|
||||||
|
// Fetch audit logs when application loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (applicationId) {
|
||||||
|
const fetchAuditLogs = async () => {
|
||||||
|
setAuditLoading(true);
|
||||||
|
try {
|
||||||
|
const logs = await auditService.getAuditLogs('application', applicationId);
|
||||||
|
setAuditLogs(Array.isArray(logs) ? logs : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch audit logs', error);
|
||||||
|
setAuditLogs([]);
|
||||||
|
} finally {
|
||||||
|
setAuditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAuditLogs();
|
||||||
|
}
|
||||||
|
}, [applicationId]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('questionnaire');
|
const [activeTab, setActiveTab] = useState('questionnaire');
|
||||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||||
@ -1172,19 +1196,7 @@ export function ApplicationDetails() {
|
|||||||
// Final visibility flags
|
// Final visibility flags
|
||||||
const shouldShowApproveReject = !hasMadeDecisionForUser && hasSubmittedFeedbackForActive;
|
const shouldShowApproveReject = !hasMadeDecisionForUser && hasSubmittedFeedbackForActive;
|
||||||
|
|
||||||
// If Work Notes page is open, show that instead
|
|
||||||
if (showWorkNotesPage) {
|
|
||||||
return (
|
|
||||||
<WorkNotesPage
|
|
||||||
applicationId={applicationId}
|
|
||||||
applicationName={application.name}
|
|
||||||
registrationNumber={application.registrationNumber}
|
|
||||||
onBack={() => setShowWorkNotesPage(false)}
|
|
||||||
initialNotes={mockWorkNotes}
|
|
||||||
participants={application.participants}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1843,19 +1855,38 @@ export function ApplicationDetails() {
|
|||||||
<TabsContent value="audit">
|
<TabsContent value="audit">
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{mockAuditLogs.map((log) => (
|
{auditLoading ? (
|
||||||
<div key={log.id} className="flex gap-4 p-3 hover:bg-slate-50 rounded-lg">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="w-2 h-2 bg-amber-600 rounded-full mt-2 flex-shrink-0"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
|
||||||
<div className="flex-1">
|
<span className="ml-2 text-slate-500">Loading audit trail...</span>
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<p className="text-slate-900">{log.action}</p>
|
|
||||||
<span className="text-slate-500">{log.timestamp}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-600 mt-1">by {log.user}</p>
|
|
||||||
<p className="text-slate-500 mt-1">{log.details}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : auditLogs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
No audit logs recorded yet for this application.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
auditLogs.map((log: any) => (
|
||||||
|
<div key={log.id} className="flex gap-4 p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div className="w-2 h-2 bg-amber-600 rounded-full mt-2 flex-shrink-0"></div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<p className="text-slate-900 font-medium">{log.description || log.action}</p>
|
||||||
|
<span className="text-slate-500 text-sm whitespace-nowrap ml-4">
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-600 mt-1">by {log.userName || 'System'}</p>
|
||||||
|
{log.changes && log.changes.length > 0 && (
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{log.changes.map((change: string, idx: number) => (
|
||||||
|
<p key={idx} className="text-slate-500 text-sm">{change}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@ -1948,7 +1979,13 @@ export function ApplicationDetails() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setShowWorkNotesPage(true)}
|
onClick={() => navigate(`/applications/${application.id}/worknotes`, {
|
||||||
|
state: {
|
||||||
|
applicationName: application.name,
|
||||||
|
registrationNumber: application.registrationNumber,
|
||||||
|
participants: application.participants
|
||||||
|
}
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
Work Note
|
Work Note
|
||||||
@ -2225,6 +2262,8 @@ export function ApplicationDetails() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog >
|
</Dialog >
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Schedule Interview Modal */}
|
{/* Schedule Interview Modal */}
|
||||||
< Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal} >
|
< Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal} >
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@ -1,50 +1,131 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { RootState } from '../../store';
|
||||||
|
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||||
import { Badge } from '../ui/badge';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Send,
|
Send,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Smile,
|
Smile,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
FileText,
|
||||||
|
File as FileIcon,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { WorkNote, Participant } from '../../lib/mock-data';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
|
import { worknoteService } from '../../services/worknote.service';
|
||||||
|
import { onboardingService } from '../../services/onboarding.service';
|
||||||
|
import { useSocket } from '../../context/SocketContext';
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkNote {
|
||||||
|
id: string;
|
||||||
|
noteText: string;
|
||||||
|
noteType: string;
|
||||||
|
createdAt: string;
|
||||||
|
userId?: string;
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
attachments?: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participant interface for mentions
|
||||||
|
|
||||||
interface WorkNotesPageProps {
|
interface WorkNotesPageProps {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
applicationName: string;
|
applicationName: string;
|
||||||
registrationNumber: string;
|
registrationNumber: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
initialNotes?: WorkNote[];
|
initialNotes?: any[];
|
||||||
participants?: Participant[];
|
participants?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// This interface defines the structure for participants displayed in the UI
|
|
||||||
interface ParticipantUI {
|
interface ParticipantUI {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
email: string;
|
||||||
initials: string;
|
initials: string;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkNotesPage({
|
const BACKEND_URL = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
||||||
applicationId,
|
|
||||||
applicationName,
|
export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||||
registrationNumber,
|
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||||
onBack,
|
const { id } = useParams<{ id: string }>();
|
||||||
initialNotes = [],
|
const location = useLocation();
|
||||||
participants: externalParticipants = []
|
const navigate = useNavigate();
|
||||||
}: WorkNotesPageProps) {
|
|
||||||
const [notes, setNotes] = useState<WorkNote[]>(initialNotes);
|
// Use props if provided (modal mode), otherwise use URL and state
|
||||||
|
const applicationId = props.applicationId || id || '';
|
||||||
|
const applicationName = props.applicationName || location.state?.applicationName || 'Application';
|
||||||
|
const registrationNumber = props.registrationNumber || location.state?.registrationNumber || '';
|
||||||
|
const onBack = props.onBack || (() => navigate(-1));
|
||||||
|
const externalParticipantsInit = props.participants || location.state?.participants || [];
|
||||||
|
const [externalParticipants, setExternalParticipants] = useState<any[]>(externalParticipantsInit);
|
||||||
|
const [notes, setNotes] = useState<WorkNote[]>([]);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [showMentionSuggestions, setShowMentionSuggestions] = useState(false);
|
const [showMentionSuggestions, setShowMentionSuggestions] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState('');
|
const [mentionQuery, setMentionQuery] = useState('');
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
|
||||||
|
const [attachedFiles, setAttachedFiles] = useState<Attachment[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const { socket } = useSocket();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [previewFile, setPreviewFile] = useState<Attachment | null>(null);
|
||||||
|
|
||||||
|
const getFileIcon = (mimeType: string, filePath?: string) => {
|
||||||
|
if (mimeType.startsWith('image/') && filePath) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`${BACKEND_URL}/${filePath.replace(/\\/g, '/')}`}
|
||||||
|
className="w-full h-full object-cover rounded"
|
||||||
|
alt="Thumbnail"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mimeType.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-blue-500" />;
|
||||||
|
if (mimeType === 'application/pdf') return <FileText className="w-5 h-5 text-red-500" />;
|
||||||
|
return <FileIcon className="w-5 h-5 text-slate-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMON_EMOJIS = [
|
||||||
|
'😊', '😂', '🤣', '❤️', '👍', '🙏', '🔥', '✨',
|
||||||
|
'😍', '🥰', '😎', '🤔', '😅', '🙌', '👏', '🎉',
|
||||||
|
'✅', '❌', '📌', '📎', '📍', '💡', '🔔', '📢',
|
||||||
|
'⭐', '🌟', '💪', '🚀', '👀', '💯', '🌈', '☀️',
|
||||||
|
'😢', '😭', '😞', '😔', '😟', '😕', '😠', '😡',
|
||||||
|
'🤬', '😤', '😲', '🙄', '🤨', '😓', '😩', '😫',
|
||||||
|
'🤐', '😴', '🤢', '🤮', '😱', '🤡', '💀', '👻',
|
||||||
|
'🤝', '👋', '✌️', '👌', '✋', '🍎', '🍕', '☕',
|
||||||
|
'💻', '📱', '⌚', '📁', '📄', '📅', '🔒', '🔑',
|
||||||
|
'🛠️', '⚙️', '💬', '💭', '🌊', '🍀', '✈️', '🏠'
|
||||||
|
];
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
const getInitials = (name: string) => {
|
||||||
return name
|
return name
|
||||||
@ -70,23 +151,102 @@ export function WorkNotesPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Map backend participants to the UI sub-format
|
// Map backend participants to the UI sub-format
|
||||||
const participantsList: ParticipantUI[] = externalParticipants.map(p => ({
|
// Handles both nested structure { user: { id, fullName } } and flat { id, userId, name/fullName }
|
||||||
name: p.user?.name || 'Unknown User',
|
// NOTE: p.id is the RequestParticipant record ID. p.userId or p.user?.id is the actual User ID.
|
||||||
initials: getInitials(p.user?.name || 'U'),
|
const participantsList: ParticipantUI[] = externalParticipants.map((p: any) => {
|
||||||
color: getAvatarColor(p.user?.name || 'U')
|
const id = p.user?.id || p.userId || p.id || '';
|
||||||
}));
|
const name = p.user?.fullName || p.user?.name || p.fullName || p.name || 'Unknown User';
|
||||||
|
const email = p.user?.email || p.email || '';
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
initials: getInitials(name),
|
||||||
|
color: getAvatarColor(name)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Fallback to current user if no participants
|
console.log('Participants list for mentions:', participantsList.map(p => ({ id: p.id, name: p.name })));
|
||||||
if (participantsList.length === 0) {
|
|
||||||
participantsList.push({ name: 'System User', initials: 'SU', color: 'bg-slate-600' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll to bottom when new messages arrive
|
// Fetch Notes on load and join socket room
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
const fetchNotes = async () => {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
try {
|
||||||
|
const res: any = await worknoteService.getWorknotes(applicationId, 'application');
|
||||||
|
if (res.success) {
|
||||||
|
setNotes(res.data.map((n: any) => ({
|
||||||
|
id: n.id,
|
||||||
|
noteText: n.noteText,
|
||||||
|
noteType: n.noteType,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
userId: n.userId,
|
||||||
|
author: n.author || { name: 'System', email: '', role: 'system' },
|
||||||
|
attachments: n.attachments || []
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch notes error:', error);
|
||||||
|
toast.error('Failed to load work notes');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchNotes();
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('join_room', applicationId);
|
||||||
|
|
||||||
|
socket.on('new_worknote', (newNote: any) => {
|
||||||
|
setNotes(prev => {
|
||||||
|
// 1. Check for exact ID match (real vs real or real vs replaced temp)
|
||||||
|
const isDuplicate = prev.some(n => n.id === newNote.id);
|
||||||
|
if (isDuplicate) return prev;
|
||||||
|
|
||||||
|
// 2. Check for optimistic match (matching text and author from a very recent temp note)
|
||||||
|
const optimisticMatchIndex = prev.findIndex(n =>
|
||||||
|
n.id.startsWith('temp-') &&
|
||||||
|
n.noteText === newNote.noteText &&
|
||||||
|
(n.author.email?.toLowerCase() === newNote.author.email?.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (optimisticMatchIndex !== -1) {
|
||||||
|
// Replace the temp note with the real one from the socket
|
||||||
|
const newNotes = [...prev];
|
||||||
|
newNotes[optimisticMatchIndex] = newNote;
|
||||||
|
return newNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. New message from someone else
|
||||||
|
return [newNote, ...prev];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.emit('leave_room', applicationId);
|
||||||
|
socket.off('new_worknote');
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [notes]);
|
}, [applicationId, socket]);
|
||||||
|
|
||||||
|
// Fetch application details if participants are missing (e.g. on refresh)
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalParticipants.length === 0 && applicationId) {
|
||||||
|
const fetchApplicationDetails = async () => {
|
||||||
|
try {
|
||||||
|
const appData = await onboardingService.getApplicationById(applicationId);
|
||||||
|
if (appData && appData.participants) {
|
||||||
|
setExternalParticipants(appData.participants);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch application details for participants:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchApplicationDetails();
|
||||||
|
}
|
||||||
|
}, [applicationId, externalParticipants.length]);
|
||||||
|
|
||||||
|
// Scroll logic removed - handled by flex-col-reverse anchoring
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
@ -115,14 +275,16 @@ export function WorkNotesPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMentionSelect = (name: string) => {
|
const handleMentionSelect = (participant: ParticipantUI) => {
|
||||||
const textBeforeCursor = message.substring(0, cursorPosition);
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||||
const textAfterCursor = message.substring(cursorPosition);
|
const textAfterCursor = message.substring(cursorPosition);
|
||||||
|
|
||||||
|
// Insert clean @Name into input
|
||||||
|
const mentionText = `@${participant.name}`;
|
||||||
const newMessage =
|
const newMessage =
|
||||||
message.substring(0, lastAtSymbol) +
|
message.substring(0, lastAtSymbol) +
|
||||||
`@${name} ` +
|
mentionText + ' ' +
|
||||||
textAfterCursor;
|
textAfterCursor;
|
||||||
|
|
||||||
setMessage(newMessage);
|
setMessage(newMessage);
|
||||||
@ -130,37 +292,115 @@ export function WorkNotesPage({
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
const handleEmojiSelect = (emoji: string) => {
|
||||||
if (!message.trim()) return;
|
const start = inputRef.current?.selectionStart || message.length;
|
||||||
|
const newMessage = message.substring(0, start) + emoji + message.substring(start);
|
||||||
// Extract mentions from message
|
setMessage(newMessage);
|
||||||
const mentionRegex = /@(\w+\s*\w*)/g;
|
setIsEmojiPickerOpen(false);
|
||||||
const mentions: string[] = [];
|
inputRef.current?.focus();
|
||||||
let match;
|
|
||||||
while ((match = mentionRegex.exec(message)) !== null) {
|
|
||||||
mentions.push(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNote: WorkNote = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
user: 'Current User',
|
|
||||||
message: message,
|
|
||||||
timestamp: new Date().toLocaleString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
}),
|
|
||||||
mentions: mentions.length > 0 ? mentions : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setNotes([...notes, newNote]);
|
|
||||||
setMessage('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const res: any = await worknoteService.uploadAttachment(file, applicationId, 'application');
|
||||||
|
if (res.success) {
|
||||||
|
setAttachedFiles(prev => [...prev, res.data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File upload error:', error);
|
||||||
|
toast.error('Failed to upload attachment');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
|
setAttachedFiles(prev => prev.filter(f => f.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!message.trim() && attachedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const optimisticMessage = message;
|
||||||
|
const optimisticAttachments = attachedFiles;
|
||||||
|
setMessage('');
|
||||||
|
setAttachedFiles([]);
|
||||||
|
|
||||||
|
// Convert clean @Name mentions to structured format: @[Name](user:Id)
|
||||||
|
let processedMessage = optimisticMessage;
|
||||||
|
const mentionedUserIds: string[] = [];
|
||||||
|
|
||||||
|
participantsList.forEach(p => {
|
||||||
|
if (p.id && p.name) {
|
||||||
|
// Simplified regex: Look for @ followed by the name, case-insensitive
|
||||||
|
// We use a more standard boundary check
|
||||||
|
const escapedName = p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const mentionRegex = new RegExp(`@${escapedName}\\b`, 'gi');
|
||||||
|
|
||||||
|
if (processedMessage.match(mentionRegex)) {
|
||||||
|
console.log(`Mention found for ${p.name} (${p.id})`);
|
||||||
|
mentionedUserIds.push(p.id);
|
||||||
|
processedMessage = processedMessage.replace(mentionRegex, `@[${p.name}](user:${p.id})`);
|
||||||
|
} else {
|
||||||
|
console.log(`No match for participant: ${p.name} in message: "${processedMessage}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Final processed message:', processedMessage);
|
||||||
|
console.log('Mentioned user IDs:', mentionedUserIds);
|
||||||
|
|
||||||
|
console.log('Final processed message for API:', processedMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Optimistic update
|
||||||
|
const tempId = `temp-${Date.now()}`;
|
||||||
|
|
||||||
|
const tempNote: WorkNote = {
|
||||||
|
id: tempId,
|
||||||
|
noteText: processedMessage, // Structured text for proper rendering
|
||||||
|
noteType: 'General',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
userId: currentUser?.id,
|
||||||
|
author: {
|
||||||
|
name: currentUser?.name || 'You',
|
||||||
|
email: currentUser?.email || '',
|
||||||
|
role: currentUser?.role || ''
|
||||||
|
},
|
||||||
|
attachments: optimisticAttachments
|
||||||
|
};
|
||||||
|
|
||||||
|
setNotes(prev => [tempNote, ...prev]);
|
||||||
|
|
||||||
|
const res: any = await worknoteService.addWorknote({
|
||||||
|
requestId: applicationId,
|
||||||
|
requestType: 'application',
|
||||||
|
noteText: processedMessage,
|
||||||
|
noteType: 'General',
|
||||||
|
tags: mentionedUserIds,
|
||||||
|
attachmentDocIds: optimisticAttachments.map(f => f.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success && res.data) {
|
||||||
|
// Replace optimistic update with actual data from server
|
||||||
|
setNotes(prev => prev.map(n => n.id === tempId ? res.data : n));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send message error:', error);
|
||||||
|
toast.error('Failed to send message');
|
||||||
|
// Revert optimistic update? (Optional, but good practice)
|
||||||
|
// fetchNotes(); // or similar
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSendMessage();
|
handleSendMessage();
|
||||||
@ -168,24 +408,36 @@ export function WorkNotesPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderMessageWithMentions = (text: string) => {
|
const renderMessageWithMentions = (text: string) => {
|
||||||
const parts = text.split(/(@\w+\s*\w*)/g);
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Regex matches the structured mention: @[Name](user:Id)
|
||||||
|
const mentionRegex = /(@\[[^\]]+\]\([^\)]+\))/g;
|
||||||
|
const parts = text.split(mentionRegex);
|
||||||
|
|
||||||
return parts.map((part, index) => {
|
return parts.map((part, index) => {
|
||||||
if (part.startsWith('@')) {
|
const mentionMatch = part.match(/@\[([^\]]+)\]\(([^\)]+)\)/);
|
||||||
|
if (mentionMatch) {
|
||||||
|
const name = mentionMatch[1];
|
||||||
return (
|
return (
|
||||||
<span key={index} className="text-blue-600 hover:underline cursor-pointer">
|
<span key={index} className="text-blue-600 font-medium hover:underline cursor-pointer">
|
||||||
{part}
|
@{name}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span key={index}>{part}</span>;
|
return part;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredParticipants = participantsList.filter(p => {
|
||||||
|
// Filter by name query
|
||||||
|
const matchesQuery = p.name.toLowerCase().includes(mentionQuery.toLowerCase());
|
||||||
|
|
||||||
const filteredParticipants = participantsList.filter(p =>
|
// Filter out the current user by ID or Email (self-mention protection)
|
||||||
p.name.toLowerCase().includes(mentionQuery.toLowerCase())
|
const isSelf = (p.id && currentUser?.id && String(p.id) === String(currentUser.id)) ||
|
||||||
);
|
(p.email && currentUser?.email && p.email.toLowerCase() === currentUser.email.toLowerCase());
|
||||||
|
|
||||||
|
return matchesQuery && !isSelf;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-slate-50">
|
<div className="h-screen flex flex-col bg-slate-50">
|
||||||
@ -239,109 +491,223 @@ export function WorkNotesPage({
|
|||||||
|
|
||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<ScrollArea className="flex-1 px-6 py-4">
|
<ScrollArea className="flex-1 px-6 py-4">
|
||||||
<div className="max-w-4xl mx-auto space-y-6" ref={scrollRef}>
|
<div className="max-w-4xl mx-auto space-y-6 flex flex-col-reverse" ref={scrollRef}>
|
||||||
{notes.map((note, index) => {
|
{notes.map((note) => {
|
||||||
const isCurrentUser = note.user === 'Current User';
|
const isMe = (note?.author?.email && currentUser?.email && note.author.email.toLowerCase() === currentUser.email.toLowerCase()) ||
|
||||||
const previousNote = index > 0 ? notes[index - 1] : null;
|
(note?.userId && currentUser?.id && note.userId === currentUser.id) ||
|
||||||
const showAvatar = !previousNote || previousNote.user !== note.user;
|
note.id.startsWith('temp-');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={note.id} className="flex gap-3">
|
<div key={note.id} className={`flex w-full ${isMe ? 'justify-end' : 'justify-start'}`}>
|
||||||
{/* Avatar */}
|
<div className={`flex gap-3 max-w-[85%] ${isMe ? 'flex-row-reverse' : ''}`}>
|
||||||
{showAvatar ? (
|
{/* Avatar */}
|
||||||
<Avatar className="w-10 h-10 flex-shrink-0">
|
<Avatar className="w-10 h-10 flex-shrink-0 mt-1">
|
||||||
<AvatarFallback className={`${getAvatarColor(note.user)} text-white`}>
|
<AvatarFallback className={`${getAvatarColor(note?.author?.name || 'System')} text-white`}>
|
||||||
{getInitials(note.user)}
|
{getInitials(note?.author?.name || 'S')}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
) : (
|
|
||||||
<div className="w-10 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message Content */}
|
{/* Message Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
|
||||||
{showAvatar && (
|
<div className={`flex items-center gap-2 mb-1 px-1 ${isMe ? 'flex-row-reverse text-right' : 'text-left'}`}>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<span className="text-slate-900 font-medium text-sm">{isMe ? 'You' : (note?.author?.name || 'Unknown')}</span>
|
||||||
<span className="text-slate-900">{note.user}</span>
|
<span className="text-slate-400 text-[10px] uppercase">
|
||||||
{index === 0 && (
|
{(note?.author?.role && note.author.role !== '0' && note.author.role !== '') ? `(${note.author.role})` : ''}
|
||||||
<Badge variant="secondary" className="text-xs">
|
</span>
|
||||||
Initiator
|
<span className="text-slate-400 text-[10px]">
|
||||||
</Badge>
|
{note.createdAt ? new Date(note.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||||
)}
|
|
||||||
<span className="text-slate-400 text-xs">
|
|
||||||
{note.timestamp}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 px-4 py-3 shadow-sm">
|
<div className={`rounded-2xl border px-4 py-2.5 shadow-sm relative ${isMe
|
||||||
<p className="text-slate-700 leading-relaxed whitespace-pre-wrap">
|
? 'bg-blue-50 border-blue-100 text-slate-800 rounded-tr-none'
|
||||||
{renderMessageWithMentions(note.message)}
|
: 'bg-white border-slate-200 text-slate-700 rounded-tl-none'
|
||||||
</p>
|
}`}>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{renderMessageWithMentions(note.noteText)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{note.attachments && note.attachments.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-2 border-t border-slate-100 pt-2">
|
||||||
|
{note.attachments.map(file => {
|
||||||
|
const isImage = file.mimeType.startsWith('image/');
|
||||||
|
return (
|
||||||
|
<div key={file.id} className="flex items-center gap-2">
|
||||||
|
{isImage ? (
|
||||||
|
<div className="rounded-lg overflow-hidden border border-slate-100 max-w-[200px]">
|
||||||
|
<img
|
||||||
|
src={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
|
||||||
|
alt={file.fileName}
|
||||||
|
className="w-full h-auto cursor-pointer"
|
||||||
|
onClick={() => setPreviewFile(file)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : file.mimeType === 'application/pdf' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewFile(file)}
|
||||||
|
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{file.fileName} (Preview)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{file.fileName}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{notes.length === 0 && (
|
{notes.length === 0 && !isLoading && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<MessageSquare className="w-16 h-16 text-slate-300 mb-4" />
|
<MessageSquare className="w-16 h-16 text-slate-300 mb-4" />
|
||||||
<h3 className="text-slate-900 mb-2">No messages yet</h3>
|
<h3 className="text-slate-900 mb-2">No messages yet</h3>
|
||||||
<p className="text-slate-600">Start the conversation by sending a message below</p>
|
<p className="text-slate-600">Start the conversation by sending a message below</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<span className="text-slate-500">Loading notes...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<div className="bg-white border-t border-slate-200 px-6 py-4">
|
<div className="bg-white border-t border-slate-200 px-6 py-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto space-y-4">
|
||||||
{/* Mention Suggestions */}
|
|
||||||
{showMentionSuggestions && filteredParticipants.length > 0 && (
|
{/* Attachment Previews */}
|
||||||
<div className="mb-2 bg-white border border-slate-200 rounded-lg shadow-lg overflow-hidden">
|
{attachedFiles.length > 0 && (
|
||||||
{filteredParticipants.map((participant) => (
|
<div className="flex flex-wrap gap-3 mb-3">
|
||||||
<button
|
{attachedFiles.map(file => {
|
||||||
key={participant.name}
|
const isPreviewable = file.mimeType.startsWith('image/') || file.mimeType === 'application/pdf';
|
||||||
onClick={() => handleMentionSelect(participant.name)}
|
return (
|
||||||
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-slate-50 transition-colors text-left"
|
<div
|
||||||
>
|
key={file.id}
|
||||||
<Avatar className="w-8 h-8">
|
className="flex items-center gap-3 p-2 bg-white rounded-xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group max-w-[200px]"
|
||||||
<AvatarFallback className={`${participant.color} text-white text-xs`}>
|
>
|
||||||
{participant.initials}
|
<div className="w-10 h-10 bg-slate-50 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||||
</AvatarFallback>
|
{getFileIcon(file.mimeType, file.filePath)}
|
||||||
</Avatar>
|
</div>
|
||||||
<span className="text-slate-900">{participant.name}</span>
|
<div className="flex-1 min-w-0 pr-6 relative">
|
||||||
</button>
|
<p
|
||||||
))}
|
className={`text-xs font-medium text-slate-700 truncate ${isPreviewable ? 'hover:text-blue-600 cursor-pointer hover:underline' : ''}`}
|
||||||
|
onClick={() => isPreviewable && setPreviewFile(file)}
|
||||||
|
>
|
||||||
|
{file.fileName}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => removeAttachment(file.id)}
|
||||||
|
className="absolute -top-1 -right-1 p-1 bg-white rounded-full border border-slate-100 text-slate-400 hover:text-red-500 shadow-sm opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input Field */}
|
<div className="flex items-end gap-3 bg-white p-2.5 rounded-2xl border border-slate-200 shadow-sm focus-within:border-blue-400 focus-within:ring-1 focus-within:ring-blue-100 transition-all relative">
|
||||||
<div className="flex items-center gap-3">
|
{/* Mention Suggestions moved inside relative container */}
|
||||||
<Button
|
{showMentionSuggestions && filteredParticipants.length > 0 && (
|
||||||
variant="ghost"
|
<div className="absolute bottom-full left-0 mb-2 w-64 bg-white border border-slate-200 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto z-50 custom-scrollbar">
|
||||||
size="icon"
|
{filteredParticipants.map((participant) => (
|
||||||
className="text-slate-400 hover:text-slate-600"
|
<button
|
||||||
>
|
key={participant.id}
|
||||||
<Paperclip className="w-5 h-5" />
|
onClick={() => handleMentionSelect(participant)}
|
||||||
</Button>
|
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-slate-50 transition-colors text-left"
|
||||||
|
>
|
||||||
<Button
|
<Avatar className="w-8 h-8">
|
||||||
variant="ghost"
|
<AvatarFallback className={`${participant.color} text-white text-xs`}>
|
||||||
size="icon"
|
{participant.initials}
|
||||||
className="text-slate-400 hover:text-slate-600"
|
</AvatarFallback>
|
||||||
>
|
</Avatar>
|
||||||
<ImageIcon className="w-5 h-5" />
|
<div className="flex flex-col">
|
||||||
</Button>
|
<span className="text-slate-900 text-sm font-medium">{participant.name}</span>
|
||||||
|
</div>
|
||||||
<Button
|
</button>
|
||||||
variant="ghost"
|
))}
|
||||||
size="icon"
|
</div>
|
||||||
className="text-slate-400 hover:text-slate-600"
|
)}
|
||||||
>
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<Smile className="w-5 h-5" />
|
<input
|
||||||
</Button>
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-9 h-9 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`w-9 h-9 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl relative ${isEmojiPickerOpen ? 'bg-blue-50 text-blue-600' : ''}`}
|
||||||
|
onClick={() => setIsEmojiPickerOpen(!isEmojiPickerOpen)}
|
||||||
|
>
|
||||||
|
<Smile className="w-5 h-5" />
|
||||||
|
{isEmojiPickerOpen && (
|
||||||
|
<div className="absolute bottom-12 left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-72 animate-in fade-in slide-in-from-bottom-2 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Emojis</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEmojiPickerOpen(false)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 grid grid-cols-8 gap-1 max-h-60 overflow-y-auto custom-scrollbar">
|
||||||
|
{COMMON_EMOJIS.map(emoji => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
className="w-8 h-8 flex items-center justify-center hover:bg-blue-50 hover:scale-110 rounded-lg transition-all text-lg"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEmojiSelect(emoji);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-9 h-9 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Input
|
<Input
|
||||||
@ -351,25 +717,68 @@ export function WorkNotesPage({
|
|||||||
value={message}
|
value={message}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
className="w-full pr-4"
|
className="w-full pr-4 border-none focus-visible:ring-0 px-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
disabled={!message.trim()}
|
disabled={!message.trim() && attachedFiles.length === 0 || isUploading}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
className="bg-blue-600 hover:bg-blue-700 text-white rounded-xl h-10 w-10 p-0"
|
||||||
size="icon"
|
|
||||||
>
|
>
|
||||||
<Send className="w-5 h-5" />
|
{isUploading ? (
|
||||||
|
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
) : (
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-slate-400 text-xs mt-2">
|
<p className="text-slate-400 text-[10px] px-1">
|
||||||
Press Enter to send • Use @ to mention someone
|
Press Enter to send • Use @ to mention someone • {isUploading ? 'Uploading files...' : 'Files attached appear above'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Modal */}
|
||||||
|
<Dialog open={!!previewFile} onOpenChange={(open) => !open && setPreviewFile(null)}>
|
||||||
|
<DialogContent className="max-w-6xl h-[90vh] flex flex-col p-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{previewFile && getFileIcon(previewFile.mimeType)}
|
||||||
|
<span className="truncate">{previewFile?.fileName}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg bg-slate-100 flex items-center justify-center p-4">
|
||||||
|
{previewFile?.mimeType.startsWith('image/') ? (
|
||||||
|
<img
|
||||||
|
src={`${BACKEND_URL}/${previewFile.filePath.replace(/\\/g, '/')}`}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
alt="Preview"
|
||||||
|
/>
|
||||||
|
) : previewFile?.mimeType === 'application/pdf' ? (
|
||||||
|
<iframe
|
||||||
|
src={`${BACKEND_URL}/${previewFile.filePath.replace(/\\/g, '/')}`}
|
||||||
|
className="w-full h-full border-none"
|
||||||
|
title="PDF Preview"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<FileIcon className="w-16 h-16 text-slate-300 mx-auto mb-4" />
|
||||||
|
<p className="text-slate-500">Preview not available for this file type.</p>
|
||||||
|
<a
|
||||||
|
href={`${BACKEND_URL}/${previewFile?.filePath.replace(/\\/g, '/')}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
Open in new tab
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Bell, RefreshCw, HelpCircle, User as UserIcon } from 'lucide-react';
|
import { Bell, RefreshCw, HelpCircle, User as UserIcon } from 'lucide-react';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import {
|
import {
|
||||||
@ -7,9 +8,13 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '../ui/dropdown-menu';
|
} from '../ui/dropdown-menu';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from '../../store';
|
import { RootState } from '../../store';
|
||||||
|
import { notificationService, Notification } from '../../services/notification.service';
|
||||||
|
import { useSocket } from '../../context/SocketContext';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@ -18,28 +23,66 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export function Header({ title, onRefresh }: HeaderProps) {
|
export function Header({ title, onRefresh }: HeaderProps) {
|
||||||
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||||
const notifications = [
|
const { socket } = useSocket();
|
||||||
{
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
id: '1',
|
|
||||||
message: 'New application assigned: APP-006',
|
|
||||||
time: '5 min ago',
|
|
||||||
unread: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
message: 'Interview scheduled for APP-001',
|
|
||||||
time: '1 hour ago',
|
|
||||||
unread: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
message: 'Document verified for APP-004',
|
|
||||||
time: '2 hours ago',
|
|
||||||
unread: false
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const unreadCount = notifications.filter(n => n.unread).length;
|
useEffect(() => {
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await notificationService.getNotifications();
|
||||||
|
if (res.success) {
|
||||||
|
setNotifications(res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch notifications error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchNotifications();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.on('notification', (newNotification: Notification) => {
|
||||||
|
setNotifications(prev => [newNotification, ...prev]);
|
||||||
|
toast(newNotification.title, {
|
||||||
|
description: newNotification.message,
|
||||||
|
action: newNotification.link ? {
|
||||||
|
label: 'View',
|
||||||
|
onClick: () => window.location.href = newNotification.link!
|
||||||
|
} : undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('notification');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const handleMarkAsRead = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res: any = await notificationService.markAsRead(id);
|
||||||
|
if (res.success) {
|
||||||
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mark as read error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await notificationService.markAllAsRead();
|
||||||
|
if (res.success) {
|
||||||
|
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mark all as read error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter(n => !n.isRead).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white border-b border-slate-200 px-6 py-4">
|
<header className="bg-white border-b border-slate-200 px-6 py-4">
|
||||||
@ -96,30 +139,48 @@ export function Header({ title, onRefresh }: HeaderProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80">
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
<div className="p-3 border-b">
|
<div className="p-3 border-b flex items-center justify-between">
|
||||||
<p>Notifications</p>
|
<p className="font-semibold text-slate-900">Notifications</p>
|
||||||
</div>
|
{unreadCount > 0 && (
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<button
|
||||||
{notifications.map((notification) => (
|
onClick={handleMarkAllAsRead}
|
||||||
<DropdownMenuItem
|
className="text-xs text-blue-600 hover:underline"
|
||||||
key={notification.id}
|
|
||||||
className={`p-3 cursor-pointer ${notification.unread ? 'bg-amber-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
Mark all read
|
||||||
<p className="text-slate-900">{notification.message}</p>
|
</button>
|
||||||
<p className="text-slate-500 mt-1">{notification.time}</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
{notification.unread && (
|
<div className="max-h-96 overflow-y-auto custom-scrollbar">
|
||||||
<div className="w-2 h-2 bg-amber-600 rounded-full flex-shrink-0"></div>
|
{notifications.length === 0 ? (
|
||||||
)}
|
<div className="p-8 text-center text-slate-500">
|
||||||
</DropdownMenuItem>
|
No notifications yet
|
||||||
))}
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={notification.id}
|
||||||
|
className={`p-3 cursor-pointer flex items-start gap-3 ${!notification.isRead ? 'bg-blue-50/50' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-slate-900 text-sm font-medium">{notification.title}</p>
|
||||||
|
<p className="text-slate-600 text-xs mt-1 leading-relaxed">{notification.message}</p>
|
||||||
|
<p className="text-slate-400 text-[10px] mt-2">
|
||||||
|
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!notification.isRead && (
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 border-t text-center">
|
<div className="p-3 border-t text-center">
|
||||||
<button className="text-amber-600 hover:text-amber-700">
|
<p className="text-xs text-slate-400">
|
||||||
View All Notifications
|
Stay updated with your mentions and tasks
|
||||||
</button>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
70
src/context/SocketContext.tsx
Normal file
70
src/context/SocketContext.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { RootState } from '../store';
|
||||||
|
|
||||||
|
interface SocketContextType {
|
||||||
|
socket: Socket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocketContext = createContext<SocketContextType>({
|
||||||
|
socket: null,
|
||||||
|
isConnected: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSocket = () => useContext(SocketContext);
|
||||||
|
|
||||||
|
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let socketUrl = (import.meta as any).env.VITE_API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
|
// If URL ends with /api, strip it as Socket.io usually connects to the root
|
||||||
|
if (socketUrl.endsWith('/api')) {
|
||||||
|
socketUrl = socketUrl.replace(/\/api$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSocket = io(socketUrl, {
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('connect', () => {
|
||||||
|
console.log('Socket connected:', newSocket.id);
|
||||||
|
setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('disconnect', () => {
|
||||||
|
console.log('Socket disconnected');
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSocket(newSocket);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
newSocket.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Join personal room for notifications
|
||||||
|
useEffect(() => {
|
||||||
|
if (socket && isConnected && currentUser?.id) {
|
||||||
|
socket.emit('join_room', `user_${currentUser.id}`);
|
||||||
|
console.log(`Joined private notification room: user_${currentUser.id}`);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.emit('leave_room', `user_${currentUser.id}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [socket, isConnected, currentUser?.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={{ socket, isConnected }}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
src/services/audit.service.ts
Normal file
35
src/services/audit.service.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { API } from '../api/API';
|
||||||
|
|
||||||
|
export const auditService = {
|
||||||
|
/**
|
||||||
|
* Get audit logs for a specific entity (e.g., application)
|
||||||
|
* @param entityType - The type of entity (e.g., 'application', 'resignation', 'dealer')
|
||||||
|
* @param entityId - The UUID of the entity
|
||||||
|
* @param page - Page number for pagination (default: 1)
|
||||||
|
* @param limit - Number of records per page (default: 50)
|
||||||
|
*/
|
||||||
|
getAuditLogs: async (entityType: string, entityId: string, page: number = 1, limit: number = 50) => {
|
||||||
|
try {
|
||||||
|
const response: any = await API.getAuditLogs(entityType, entityId, page, limit);
|
||||||
|
return response.data?.data || response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get audit logs error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit summary/stats for a specific entity
|
||||||
|
* @param entityType - The type of entity
|
||||||
|
* @param entityId - The UUID of the entity
|
||||||
|
*/
|
||||||
|
getAuditSummary: async (entityType: string, entityId: string) => {
|
||||||
|
try {
|
||||||
|
const response: any = await API.getAuditSummary(entityType, entityId);
|
||||||
|
return response.data?.data || response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get audit summary error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/services/notification.service.ts
Normal file
38
src/services/notification.service.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import client from '../api/client';
|
||||||
|
|
||||||
|
const API_BASE = '/communication';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: string;
|
||||||
|
link: string | null;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationService = {
|
||||||
|
getNotifications: async () => {
|
||||||
|
const response = await client.get(`${API_BASE}/notifications`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markAsRead: async (id: string) => {
|
||||||
|
const response = await client.patch(`${API_BASE}/notifications/${id}/read`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllAsRead: async () => {
|
||||||
|
const response = await client.patch(`${API_BASE}/notifications/read-all`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePushSubscription: async (subscription: any) => {
|
||||||
|
const response = await client.post(`${API_BASE}/notifications/subscribe`, { subscription });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default notificationService;
|
||||||
36
src/services/worknote.service.ts
Normal file
36
src/services/worknote.service.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import client from '../api/client';
|
||||||
|
|
||||||
|
const API_BASE = '/collaboration';
|
||||||
|
|
||||||
|
export const worknoteService = {
|
||||||
|
getWorknotes: async (requestId: string, requestType: string) => {
|
||||||
|
const response = await client.get(`${API_BASE}/worknotes`, { requestId, requestType });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
addWorknote: async (data: {
|
||||||
|
requestId: string;
|
||||||
|
requestType: string;
|
||||||
|
noteText: string;
|
||||||
|
noteType?: string;
|
||||||
|
tags?: string[];
|
||||||
|
attachmentDocIds?: string[];
|
||||||
|
}) => {
|
||||||
|
const response = await client.post(`${API_BASE}/worknotes`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadAttachment: async (file: File, requestId?: string, requestType?: string) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (requestId) formData.append('requestId', requestId);
|
||||||
|
if (requestType) formData.append('requestType', requestType);
|
||||||
|
|
||||||
|
const response = await client.post(`${API_BASE}/upload`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default worknoteService;
|
||||||
@ -187,4 +187,26 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #e2e8f0 transparent;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user