diff --git a/AUTHORIZATION_FIX_SUMMARY.md b/AUTHORIZATION_FIX_SUMMARY.md
deleted file mode 100644
index 545dee3..0000000
--- a/AUTHORIZATION_FIX_SUMMARY.md
+++ /dev/null
@@ -1,268 +0,0 @@
-# Authorization Fix - Implementation Summary
-
-## Problem Fixed
-
-**Issue**: Unauthorized users could trigger API calls and see page content (headers, breadcrumbs) before being redirected.
-
-**Example**: OrgAdmin accessing `/users` page would:
-1. ❌ Make API calls to `/api/cities` and `/api/users`
-2. ❌ See headers and breadcrumbs briefly
-3. ❌ Display toast error messages
-4. ❌ Then redirect to `/access-denied`
-
-## Solution Implemented
-
-Created **two reusable authorization patterns** that check permissions **before** any rendering:
-
-### 1. **useAuthorization Hook**
-For pages with API calls and effects
-
-### 2. **withAuthorization HOC**
-For simple pages without effects
-
----
-
-## Files Created
-
-### Core Authorization Logic
-- ✅ `/src/hooks/useAuthorization.ts` - Authorization hook
-- ✅ `/src/components/auth/withAuthorization.tsx` - HOC wrapper
-- ✅ `/src/components/auth/RoleGuard.tsx` - Updated to use new hook
-
-### Documentation
-- ✅ `/AUTHORIZATION_PATTERN_GUIDE.md` - Complete migration guide
-- ✅ `/AUTHORIZATION_FIX_SUMMARY.md` - This file
-
----
-
-## Pages Migrated (Examples)
-
-### ✅ Users Page - Hook Pattern
-**File**: `/src/app/users/page.tsx`
-
-Uses `useAuthorization` hook because it has:
-- Multiple `useEffect` hooks
-- API calls on mount (`fetchUsers`, `fetchLocations`)
-- Complex state management
-
-**Result**: No API calls or rendering until authorized
-
-### ✅ Organisations Page - HOC Pattern
-**File**: `/src/app/organisations/page.tsx`
-
-Uses `withAuthorization` HOC because it:
-- Has no effects
-- Static content only
-- Simple component structure
-
-**Result**: Clean, minimal code with full protection
-
-### ✅ Advice Page - HOC Pattern
-**File**: `/src/app/advice/page.tsx`
-
-Uses `withAuthorization` HOC
-**Result**: Same as organisations page
-
----
-
-## Remaining Pages to Migrate
-
-### Priority 1: Pages with API Calls (Use Hook Pattern)
-These pages likely make API calls and need immediate attention:
-
-```bash
-/app/banners/page.tsx
-/app/banners/[id]/page.tsx
-/app/banners/[id]/edit/page.tsx
-/app/swep-banners/page.tsx
-```
-
-### Priority 2: Simple Pages (Use HOC Pattern)
-These pages are simpler and can use the HOC:
-
-```bash
-/app/resources/page.tsx
-/app/sweps/page.tsx
-/app/banners/new/page.tsx
-```
-
----
-
-## How to Migrate Pages
-
-### For Pages with useEffect (Use Hook):
-
-1. **Import the hook**:
-```tsx
-import { useAuthorization } from '@/hooks/useAuthorization';
-```
-
-2. **Add authorization check at top of component**:
-```tsx
-const { isChecking, isAuthorized } = useAuthorization({
- allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN],
- requiredPage: '/your-page',
- autoRedirect: true
-});
-```
-
-3. **Condition effects on isAuthorized**:
-```tsx
-useEffect(() => {
- if (isAuthorized) {
- fetchData(); // Only runs if authorized
- }
-}, [isAuthorized]);
-```
-
-4. **Add guards before return**:
-```tsx
-if (isChecking) {
- return
;
-}
-
-if (!isAuthorized) {
- return null;
-}
-```
-
-5. **Remove RoleGuard wrapper**
-
-### For Simple Pages (Use HOC):
-
-1. **Add 'use client' directive**:
-```tsx
-'use client';
-```
-
-2. **Import the HOC**:
-```tsx
-import { withAuthorization } from '@/components/auth/withAuthorization';
-```
-
-3. **Change function to named function**:
-```tsx
-function YourPage() {
- return Content
;
-}
-```
-
-4. **Export wrapped component**:
-```tsx
-export default withAuthorization(YourPage, {
- allowedRoles: [ROLES.SUPER_ADMIN],
- requiredPage: '/your-page'
-});
-```
-
-5. **Remove RoleGuard wrapper and metadata export**
-
----
-
-## Testing Checklist
-
-After migrating each page, test:
-
-- [ ] Unauthorized user sees **loading spinner only**
-- [ ] No API calls in browser network tab
-- [ ] No headers or breadcrumbs visible
-- [ ] No toast error messages
-- [ ] Redirect to `/access-denied` after loading
-- [ ] Authorized user can access page normally
-- [ ] All functionality works as expected
-
----
-
-## Key Benefits
-
-### ✅ Before Fix
-- API calls made regardless of authorization
-- Headers/UI rendered before auth check
-- Error toasts displayed
-- Poor user experience
-
-### ✅ After Fix
-- No API calls until authorized
-- No UI rendering until authorized
-- Clean redirect without errors
-- Professional user experience
-
----
-
-## Quick Reference
-
-### Hook Pattern Template
-```tsx
-'use client';
-import { useAuthorization } from '@/hooks/useAuthorization';
-import { ROLES } from '@/constants/roles';
-
-export default function YourPage() {
- const { isChecking, isAuthorized } = useAuthorization({
- allowedRoles: [ROLES.SUPER_ADMIN],
- requiredPage: '/your-page',
- autoRedirect: true
- });
-
- useEffect(() => {
- if (isAuthorized) {
- // Your API calls here
- }
- }, [isAuthorized]);
-
- if (isChecking || !isAuthorized) return null;
-
- return Your content
;
-}
-```
-
-### HOC Pattern Template
-```tsx
-'use client';
-import { withAuthorization } from '@/components/auth/withAuthorization';
-import { ROLES } from '@/constants/roles';
-
-function YourPage() {
- return Your content
;
-}
-
-export default withAuthorization(YourPage, {
- allowedRoles: [ROLES.SUPER_ADMIN],
- requiredPage: '/your-page'
-});
-```
-
----
-
-## Next Steps
-
-1. **Test the migrated pages** with different roles
-2. **Migrate remaining pages** using appropriate pattern
-3. **Remove old RoleGuard usages** once all pages migrated
-4. **Update tests** to account for authorization checks
-5. **Document role permissions** if not already done
-
----
-
-## Support Resources
-
-- **Full Guide**: `AUTHORIZATION_PATTERN_GUIDE.md`
-- **Hook Implementation**: `/src/hooks/useAuthorization.ts`
-- **HOC Implementation**: `/src/components/auth/withAuthorization.tsx`
-- **Example (Hook)**: `/src/app/users/page.tsx`
-- **Example (HOC)**: `/src/app/organisations/page.tsx`
-- **RBAC Documentation**: Existing role and permission docs
-
----
-
-## Conclusion
-
-This fix ensures that **no unauthorized access attempts trigger any application logic**. Pages now properly guard against:
-- Premature API calls
-- UI flash before redirect
-- Error toast spam
-- Poor user experience
-
-All while maintaining clean, maintainable, and reusable code patterns.
diff --git a/AUTHORIZATION_PATTERN_GUIDE.md b/AUTHORIZATION_PATTERN_GUIDE.md
deleted file mode 100644
index b4e6931..0000000
--- a/AUTHORIZATION_PATTERN_GUIDE.md
+++ /dev/null
@@ -1,332 +0,0 @@
-# Authorization Pattern Guide
-
-## Problem Statement
-
-The previous `RoleGuard` component used `useEffect` for authorization checks, which meant:
-- ❌ Page components rendered immediately
-- ❌ API calls were made before authorization was verified
-- ❌ UI (headers, breadcrumbs) was visible briefly before redirect
-- ❌ Error toasts displayed from failed API calls
-- ❌ Poor user experience with flashing content
-
-## Solution
-
-We now provide **two authorization patterns** that check permissions **before** rendering any content:
-
-### 1. **useAuthorization Hook** (For Complex Pages)
-Best for pages with `useEffect`, API calls, and complex state management.
-
-### 2. **withAuthorization HOC** (For Simple Pages)
-Best for simple pages without effects or minimal logic.
-
----
-
-## Pattern 1: useAuthorization Hook
-
-### When to Use
-- ✅ Pages with `useEffect` hooks
-- ✅ Pages making API calls on mount
-- ✅ Complex state management
-- ✅ Need fine-grained control over authorization logic
-
-### Implementation Example
-
-```tsx
-'use client';
-
-import { useState, useEffect } from 'react';
-import { useAuthorization } from '@/hooks/useAuthorization';
-import { ROLES } from '@/constants/roles';
-
-export default function UsersPage() {
- // 1. Check authorization FIRST before any other logic
- const { isChecking, isAuthorized } = useAuthorization({
- allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN],
- requiredPage: '/users',
- autoRedirect: true
- });
-
- // 2. State and other hooks
- const [users, setUsers] = useState([]);
- const [loading, setLoading] = useState(true);
-
- // 3. Effects run ONLY if authorized
- useEffect(() => {
- if (isAuthorized) {
- fetchUsers();
- }
- }, [isAuthorized]);
-
- // 4. Show loading during authorization check
- if (isChecking) {
- return (
-
- );
- }
-
- // 5. Return null if not authorized (redirect handled by hook)
- if (!isAuthorized) {
- return null;
- }
-
- // 6. Render protected content - API calls safe here
- return (
-
-
Users Page
- {/* Your content here */}
-
- );
-}
-```
-
-### Key Points
-- ✅ No `RoleGuard` wrapper needed
-- ✅ No API calls until `isAuthorized === true`
-- ✅ No UI rendering until authorization complete
-- ✅ Clean separation of authorization and business logic
-
----
-
-## Pattern 2: withAuthorization HOC
-
-### When to Use
-- ✅ Simple pages without effects
-- ✅ Minimal or no API calls
-- ✅ Static or mostly static content
-- ✅ Cleaner code with less boilerplate
-
-### Implementation Example
-
-```tsx
-'use client';
-
-import { withAuthorization } from '@/components/auth/withAuthorization';
-import { ROLES } from '@/constants/roles';
-
-function OrganisationsPage() {
- // Your component logic here
- return (
-
-
Organisations
- {/* Your content here */}
-
- );
-}
-
-// Export wrapped component with authorization
-export default withAuthorization(OrganisationsPage, {
- allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.ORG_ADMIN],
- requiredPage: '/organisations'
-});
-```
-
-### Key Points
-- ✅ Cleaner than hook pattern for simple pages
-- ✅ Authorization logic separated from component
-- ✅ Good TypeScript inference
-- ✅ Reusable and testable
-
----
-
-## Migration Guide
-
-### From RoleGuard to useAuthorization Hook
-
-**Before:**
-```tsx
-import RoleGuard from '@/components/auth/RoleGuard';
-
-export default function UsersPage() {
- useEffect(() => {
- fetchData(); // ⚠️ Runs immediately!
- }, []);
-
- return (
-
- Content
-
- );
-}
-```
-
-**After:**
-```tsx
-import { useAuthorization } from '@/hooks/useAuthorization';
-
-export default function UsersPage() {
- const { isChecking, isAuthorized } = useAuthorization({
- allowedRoles: [ROLES.SUPER_ADMIN],
- requiredPage: '/users',
- autoRedirect: true
- });
-
- useEffect(() => {
- if (isAuthorized) {
- fetchData(); // ✅ Only runs if authorized
- }
- }, [isAuthorized]);
-
- if (isChecking || !isAuthorized) {
- return null; // Or loading spinner
- }
-
- return Content
;
-}
-```
-
-### From RoleGuard to withAuthorization HOC
-
-**Before:**
-```tsx
-import RoleGuard from '@/components/auth/RoleGuard';
-
-export default function OrganisationsPage() {
- return (
-
- Content
-
- );
-}
-```
-
-**After:**
-```tsx
-import { withAuthorization } from '@/components/auth/withAuthorization';
-
-function OrganisationsPage() {
- return Content
;
-}
-
-export default withAuthorization(OrganisationsPage, {
- allowedRoles: [ROLES.SUPER_ADMIN],
- requiredPage: '/organisations'
-});
-```
-
----
-
-## Pages Requiring Migration
-
-### Priority 1: Pages with API Calls (Use Hook Pattern)
-- ✅ `/app/users/page.tsx` - **COMPLETED**
-- [ ] `/app/banners/page.tsx`
-- [ ] `/app/banners/[id]/page.tsx`
-- [ ] `/app/banners/[id]/edit/page.tsx`
-- [ ] `/app/swep-banners/page.tsx`
-
-### Priority 2: Simple Pages (Use HOC Pattern)
-- ✅ `/app/organisations/page.tsx` - **COMPLETED**
-- [ ] `/app/advice/page.tsx`
-- [ ] `/app/resources/page.tsx`
-- [ ] `/app/sweps/page.tsx`
-- [ ] `/app/banners/new/page.tsx`
-
----
-
-## Testing Checklist
-
-After migration, verify:
-- [ ] Authorization check happens before any rendering
-- [ ] No API calls made for unauthorized users
-- [ ] No headers/breadcrumbs visible before redirect
-- [ ] No toast errors from unauthorized API calls
-- [ ] Loading spinner shows during auth check
-- [ ] Redirect to `/access-denied` works correctly
-- [ ] Authorized users can access page normally
-
----
-
-## Best Practices
-
-### ✅ DO
-- Check authorization before any effects
-- Condition API calls on `isAuthorized`
-- Return loading spinner during `isChecking`
-- Return `null` when not authorized
-- Use hook pattern for pages with effects
-- Use HOC pattern for simple pages
-
-### ❌ DON'T
-- Make API calls before authorization
-- Render UI before authorization check
-- Use `RoleGuard` wrapper for new pages
-- Mix authorization patterns in same component
-- Forget to add dependency on `isAuthorized`
-
----
-
-## API Reference
-
-### useAuthorization Hook
-
-```typescript
-function useAuthorization(options: UseAuthorizationOptions): AuthorizationResult
-
-interface UseAuthorizationOptions {
- allowedRoles?: UserRole[];
- requiredPage?: string;
- fallbackPath?: string;
- autoRedirect?: boolean;
-}
-
-interface AuthorizationResult {
- isChecking: boolean;
- isAuthorized: boolean;
- isAuthenticated: boolean;
-}
-```
-
-### withAuthorization HOC
-
-```typescript
-function withAuthorization(
- Component: ComponentType
,
- options: UseAuthorizationOptions
-): ComponentType
-```
-
----
-
-## Troubleshooting
-
-### Issue: "Page still makes API calls before redirect"
-**Solution**: Ensure effects depend on `isAuthorized`:
-```tsx
-useEffect(() => {
- if (isAuthorized) {
- fetchData();
- }
-}, [isAuthorized]);
-```
-
-### Issue: "Loading spinner flashes too quickly"
-**Solution**: This is normal behavior - authorization should be fast.
-
-### Issue: "TypeScript errors with HOC"
-**Solution**: Ensure component function is declared before export:
-```tsx
-function MyPage() { /* ... */ }
-export default withAuthorization(MyPage, { /* ... */ });
-```
-
----
-
-## Related Files
-
-- `/src/hooks/useAuthorization.ts` - Authorization hook implementation
-- `/src/components/auth/withAuthorization.tsx` - HOC implementation
-- `/src/components/auth/RoleGuard.tsx` - Legacy component (uses hook internally)
-- `/src/lib/userService.ts` - Permission checking logic
-- `/src/types/auth.ts` - Type definitions
-
----
-
-## Support
-
-For questions or issues:
-1. Check this guide first
-2. Review example implementations in `/app/users/page.tsx` and `/app/organisations/page.tsx`
-3. Consult `/src/hooks/useAuthorization.ts` for hook details
-4. Refer to RBAC documentation for role configuration
diff --git a/BEFORE_AFTER_COMPARISON.md b/BEFORE_AFTER_COMPARISON.md
deleted file mode 100644
index 61a1c55..0000000
--- a/BEFORE_AFTER_COMPARISON.md
+++ /dev/null
@@ -1,342 +0,0 @@
-# Before & After: Authorization Fix Comparison
-
-## Visual Flow Comparison
-
-### ❌ BEFORE (RoleGuard with useEffect)
-
-```
-User navigates to /users
- ↓
-Component renders immediately
- ↓
-useEffect runs → API calls fire
- ├─ /api/cities ❌ (403 Error)
- └─ /api/users ❌ (403 Error)
- ↓
-Headers render ⚠️
-Breadcrumbs render ⚠️
- ↓
-Toast errors appear 🔴
- ↓
-useEffect in RoleGuard checks auth
- ↓
-Redirect to /access-denied
-```
-
-**Result**: User sees flashing content, errors, and bad UX
-
----
-
-### ✅ AFTER (useAuthorization Hook)
-
-```
-User navigates to /users
- ↓
-useAuthorization hook runs
- ↓
-Authorization check (synchronous)
- ├─ isChecking = true
- └─ Shows loading spinner only
- ↓
-Authorization fails
- ├─ isAuthorized = false
- ├─ isChecking = false
- └─ Redirects immediately
- ↓
-User sees /access-denied
-```
-
-**Result**: Clean redirect, no errors, professional UX
-
----
-
-## Code Comparison
-
-### Users Page (Complex Page with API Calls)
-
-#### ❌ BEFORE
-```tsx
-'use client';
-import RoleGuard from '@/components/auth/RoleGuard';
-
-export default function UsersPage() {
- const [users, setUsers] = useState([]);
-
- // ⚠️ Runs immediately, before auth check!
- useEffect(() => {
- fetchLocations(); // ❌ API call
- }, []);
-
- useEffect(() => {
- fetchUsers(); // ❌ API call
- }, [currentPage]);
-
- return (
-
- {/* ⚠️ Renders before auth check! */}
-
-
Users
-
- {/* More content */}
-
- );
-}
-```
-
-**Problems**:
-- ❌ `fetchLocations()` runs immediately
-- ❌ `fetchUsers()` runs immediately
-- ❌ Headers render immediately
-- ❌ Auth check happens in nested useEffect
-- ❌ Redirect happens after damage is done
-
----
-
-#### ✅ AFTER
-```tsx
-'use client';
-import { useAuthorization } from '@/hooks/useAuthorization';
-
-export default function UsersPage() {
- // ✅ Check auth FIRST
- const { isChecking, isAuthorized } = useAuthorization({
- allowedRoles: [ROLES.SUPER_ADMIN],
- requiredPage: '/users',
- autoRedirect: true
- });
-
- const [users, setUsers] = useState([]);
-
- // ✅ Only runs if authorized
- useEffect(() => {
- if (isAuthorized) {
- fetchLocations(); // ✅ Safe API call
- }
- }, [isAuthorized]);
-
- useEffect(() => {
- if (isAuthorized) {
- fetchUsers(); // ✅ Safe API call
- }
- }, [isAuthorized, currentPage]);
-
- // ✅ Guard prevents rendering
- if (isChecking || !isAuthorized) return null;
-
- return (
-
-
Users
-
- );
-}
-```
-
-**Benefits**:
-- ✅ Auth check happens first
-- ✅ No API calls until authorized
-- ✅ No rendering until authorized
-- ✅ Clean redirect without errors
-- ✅ Single source of truth for auth state
-
----
-
-### Organisations Page (Simple Page)
-
-#### ❌ BEFORE
-```tsx
-import RoleGuard from '@/components/auth/RoleGuard';
-
-export default function OrganisationsPage() {
- return (
-
- {/* ⚠️ Content renders immediately */}
-
-
Organisations
- {/* Static content */}
-
-
- );
-}
-```
-
-**Problems**:
-- ❌ Content renders before auth check
-- ❌ Unnecessary wrapper component
-- ❌ Auth check in nested useEffect
-
----
-
-#### ✅ AFTER
-```tsx
-'use client';
-import { withAuthorization } from '@/components/auth/withAuthorization';
-
-function OrganisationsPage() {
- return (
-
-
Organisations
- {/* Static content */}
-
- );
-}
-
-// ✅ Authorization enforced at export
-export default withAuthorization(OrganisationsPage, {
- allowedRoles: [ROLES.SUPER_ADMIN],
- requiredPage: '/organisations'
-});
-```
-
-**Benefits**:
-- ✅ No rendering until authorized
-- ✅ Cleaner code structure
-- ✅ Separation of concerns
-- ✅ Better TypeScript inference
-
----
-
-## Network Tab Comparison
-
-### ❌ BEFORE - Unauthorized Access
-```
-Request URL: /api/cities
-Status: 403 Forbidden ❌
-Time: 45ms
-
-Request URL: /api/users?page=1&limit=9
-Status: 403 Forbidden ❌
-Time: 52ms
-
-→ 2 failed requests
-→ Toast errors displayed
-→ User confused
-```
-
-### ✅ AFTER - Unauthorized Access
-```
-(No network requests)
-
-→ 0 failed requests ✅
-→ No toast errors ✅
-→ Clean redirect ✅
-```
-
----
-
-## User Experience Comparison
-
-### ❌ BEFORE
-1. User clicks "Users" link
-2. Brief flash of users page header ⚠️
-3. Breadcrumbs appear momentarily ⚠️
-4. Two error toasts pop up 🔴🔴
-5. Page redirects to access denied
-6. **Total time**: ~300-500ms of broken UI
-
-### ✅ AFTER
-1. User clicks "Users" link
-2. Loading spinner shows (50-100ms)
-3. Immediate redirect to access denied
-4. **Total time**: ~50-100ms clean transition
-
----
-
-## Performance Comparison
-
-### ❌ BEFORE
-- **Wasted API Calls**: 2+ per unauthorized access
-- **Wasted Renders**: Multiple component renders
-- **Error Handling**: Toast cleanup, error state management
-- **Network Traffic**: Unnecessary 403 responses
-
-### ✅ AFTER
-- **API Calls**: 0 until authorized ✅
-- **Renders**: Single loading state only ✅
-- **Error Handling**: None needed ✅
-- **Network Traffic**: Minimal ✅
-
----
-
-## Developer Experience
-
-### ❌ BEFORE
-```tsx
-// Scattered authorization logic
-
-
-
- {/* Where is auth actually checked? */}
-
-
-
-```
-- ❓ Unclear when auth check happens
-- ❓ Hard to debug timing issues
-- ❓ Effects run at wrong time
-
-### ✅ AFTER (Hook Pattern)
-```tsx
-// Clear authorization at top
-const { isChecking, isAuthorized } = useAuthorization({...});
-
-// Explicit effect dependencies
-useEffect(() => {
- if (isAuthorized) { /* ... */ }
-}, [isAuthorized]);
-
-// Clear render guards
-if (isChecking || !isAuthorized) return null;
-```
-- ✅ Auth check order is obvious
-- ✅ Easy to debug
-- ✅ Effects run at right time
-
-### ✅ AFTER (HOC Pattern)
-```tsx
-function Component() { /* Pure component */ }
-
-// Authorization separate from logic
-export default withAuthorization(Component, {...});
-```
-- ✅ Clean separation of concerns
-- ✅ Reusable pattern
-- ✅ Testable components
-
----
-
-## Summary
-
-| Aspect | Before ❌ | After ✅ |
-|--------|----------|----------|
-| **API Calls** | Immediate | Only when authorized |
-| **UI Rendering** | Immediate | Only when authorized |
-| **Error Toasts** | 2+ errors | None |
-| **Network Requests** | 2+ failed | 0 failed |
-| **User Experience** | Janky | Smooth |
-| **Code Clarity** | Unclear timing | Explicit flow |
-| **Maintainability** | Scattered logic | Centralized |
-| **Performance** | Wasted resources | Efficient |
-
----
-
-## Migration Priority
-
-1. ✅ **Users Page** - Migrated (Hook pattern)
-2. ✅ **Organisations Page** - Migrated (HOC pattern)
-3. ✅ **Advice Page** - Migrated (HOC pattern)
-4. ⏳ **Banners Pages** - Next priority
-5. ⏳ **SWEP Pages** - Next priority
-6. ⏳ **Resources Pages** - Lower priority
-
----
-
-## Conclusion
-
-The new authorization patterns provide:
-- ✅ **Better UX**: No flashing content or errors
-- ✅ **Better Performance**: No wasted API calls
-- ✅ **Better DX**: Clear, predictable auth flow
-- ✅ **Better Maintainability**: Reusable patterns
-- ✅ **Better Security**: Fail-safe by default
-
-**The fix is complete and ready to deploy!** 🚀
diff --git a/MIGRATION_COMPLETE_SUMMARY.md b/MIGRATION_COMPLETE_SUMMARY.md
deleted file mode 100644
index 5be6d54..0000000
--- a/MIGRATION_COMPLETE_SUMMARY.md
+++ /dev/null
@@ -1,262 +0,0 @@
-# Authorization Migration - Complete ✅
-
-## Summary
-
-Successfully migrated **all protected pages** to use the new authorization pattern that prevents API calls and UI rendering for unauthorized users.
-
----
-
-## ✅ Completed Migrations
-
-### Pages with API Calls (Hook Pattern)
-Used `useAuthorization` hook for pages making API calls on mount:
-
-1. **✅ /app/users/page.tsx**
- - API calls: `/api/cities`, `/api/users`
- - Now: No calls until `isAuthorized === true`
-
-2. **✅ /app/banners/page.tsx**
- - API calls: `/api/banners`, `/api/cities`
- - Now: No calls until `isAuthorized === true`
-
-3. **✅ /app/banners/[id]/page.tsx**
- - API calls: `/api/banners/${id}`
- - Now: No calls until `isAuthorized === true`
-
-4. **✅ /app/banners/[id]/edit/page.tsx**
- - API calls: `/api/banners/${id}`, form submission
- - Now: No calls until `isAuthorized === true`
-
-### Simple Pages (HOC Pattern)
-Used `withAuthorization` HOC for pages without mount effects:
-
-5. **✅ /app/organisations/page.tsx**
- - Static content only
- - Clean HOC wrapper
-
-6. **✅ /app/advice/page.tsx**
- - Static content only
- - Clean HOC wrapper
-
-7. **✅ /app/resources/page.tsx**
- - Static content only
- - Clean HOC wrapper
-
-8. **✅ /app/swep-banners/page.tsx**
- - Static content only
- - Clean HOC wrapper
-
-9. **✅ /app/sweps/page.tsx**
- - Component wrapper
- - Clean HOC wrapper
-
-10. **✅ /app/banners/new/page.tsx**
- - Form submission only (no mount effects)
- - Clean HOC wrapper
-
----
-
-## 🎯 Problem Solved
-
-### Before ❌
-```
-User accesses unauthorized page
- ↓
-Page renders immediately
- ↓
-API calls fire → 403 errors
- ↓
-Headers/UI visible
- ↓
-Error toasts show
- ↓
-Then redirect
-```
-
-### After ✅
-```
-User accesses unauthorized page
- ↓
-Authorization check
- ↓
-Loading spinner only
- ↓
-Immediate redirect
- ↓
-No API calls, no errors, clean UX
-```
-
----
-
-## 📊 Results
-
-| Metric | Before | After |
-|--------|--------|-------|
-| **API Calls (Unauthorized)** | 2-4 per page | 0 ✅ |
-| **UI Flash** | Headers/breadcrumbs visible | None ✅ |
-| **Error Toasts** | Multiple errors | None ✅ |
-| **User Experience** | Janky, confusing | Smooth, professional ✅ |
-| **Network Traffic** | Wasted requests | Efficient ✅ |
-
----
-
-## 🔧 Implementation Patterns
-
-### Hook Pattern (for pages with API calls)
-```tsx
-export default function UsersPage() {
- // 1. Authorization check FIRST
- const { isChecking, isAuthorized } = useAuthorization({
- allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN],
- requiredPage: '/users',
- autoRedirect: true
- });
-
- // 2. Effects depend on authorization
- useEffect(() => {
- if (isAuthorized) {
- fetchData(); // ✅ Only runs if authorized
- }
- }, [isAuthorized]);
-
- // 3. Guards before rendering
- if (isChecking || !isAuthorized) return null;
-
- // 4. Render content
- return Protected content
;
-}
-```
-
-### HOC Pattern (for simple pages)
-```tsx
-function OrganisationsPage() {
- return Protected content
;
-}
-
-export default withAuthorization(OrganisationsPage, {
- allowedRoles: [ROLES.SUPER_ADMIN, ROLES.ORG_ADMIN],
- requiredPage: '/organisations'
-});
-```
-
----
-
-## 📁 Files Created
-
-### Core Authorization System
-- ✅ `/src/hooks/useAuthorization.ts` - Authorization hook
-- ✅ `/src/components/auth/withAuthorization.tsx` - HOC wrapper
-- ✅ `/src/components/auth/RoleGuard.tsx` - Updated to use hook internally
-
-### Documentation
-- ✅ `/AUTHORIZATION_PATTERN_GUIDE.md` - Complete guide
-- ✅ `/AUTHORIZATION_FIX_SUMMARY.md` - Implementation details
-- ✅ `/BEFORE_AFTER_COMPARISON.md` - Visual comparisons
-- ✅ `/MIGRATION_COMPLETE_SUMMARY.md` - This file
-
----
-
-## 🧪 Testing Instructions
-
-For each migrated page, verify:
-
-### Test as Unauthorized User
-1. Login as user without page access (e.g., OrgAdmin for `/users`)
-2. Try to access the protected page
-3. **Verify**:
- - ✅ Only loading spinner shows (no page content)
- - ✅ Network tab shows NO API calls
- - ✅ NO headers or breadcrumbs visible
- - ✅ NO toast error messages
- - ✅ Clean redirect to `/access-denied`
-
-### Test as Authorized User
-1. Login as user with page access
-2. Access the page normally
-3. **Verify**:
- - ✅ Page loads correctly
- - ✅ All functionality works
- - ✅ API calls succeed
- - ✅ No errors or issues
-
----
-
-## 📈 Performance Improvements
-
-### Network Traffic Reduction
-- **Before**: 2-4 failed requests per unauthorized access
-- **After**: 0 requests ✅
-- **Savings**: 100% reduction in wasted API calls
-
-### User Experience
-- **Before**: 300-500ms of broken UI
-- **After**: 50-100ms clean transition ✅
-- **Improvement**: 80% faster, smoother UX
-
-### Server Load
-- **Before**: Server processes unauthorized requests
-- **After**: No server load from unauthorized attempts ✅
-- **Benefit**: Reduced server costs and improved efficiency
-
----
-
-## 🎓 Key Learnings
-
-### When to Use Hook Pattern
-- ✅ Pages with `useEffect` hooks
-- ✅ Pages making API calls on mount
-- ✅ Complex state management
-- ✅ Need fine-grained control
-
-### When to Use HOC Pattern
-- ✅ Simple pages without effects
-- ✅ Minimal or no API calls
-- ✅ Static content
-- ✅ Cleaner code preferred
-
----
-
-## 🚀 Next Steps
-
-### Recommended Actions
-1. **Test all pages** with different user roles
-2. **Monitor production** for any issues
-3. **Update tests** to account for new authorization flow
-4. **Consider removing** old RoleGuard component if no longer needed
-
-### Future Enhancements
-- Add page-level loading states
-- Implement better error boundaries
-- Add analytics for unauthorized access attempts
-- Create automated tests for authorization flows
-
----
-
-## 📚 Documentation Reference
-
-- **Pattern Guide**: `AUTHORIZATION_PATTERN_GUIDE.md`
-- **Implementation Details**: `AUTHORIZATION_FIX_SUMMARY.md`
-- **Before/After**: `BEFORE_AFTER_COMPARISON.md`
-- **Hook Source**: `/src/hooks/useAuthorization.ts`
-- **HOC Source**: `/src/components/auth/withAuthorization.tsx`
-
----
-
-## ✨ Success Criteria - All Met! ✅
-
-- ✅ No API calls for unauthorized users
-- ✅ No UI rendering before authorization check
-- ✅ No error toasts from unauthorized attempts
-- ✅ Clean redirect experience
-- ✅ Maintained all existing functionality
-- ✅ Reusable, maintainable patterns
-- ✅ Comprehensive documentation
-- ✅ All 10 protected pages migrated
-
----
-
-## 🎉 Migration Complete!
-
-All protected pages now properly prevent unauthorized access without making API calls or rendering UI. The system is production-ready and provides a much better user experience.
-
-**Ready to deploy!** 🚀
diff --git a/src/app/api/organisations/[id]/accommodations/[accommodationId]/route.ts b/src/app/api/organisations/[id]/accommodations/[accommodationId]/route.ts
new file mode 100644
index 0000000..1ca1823
--- /dev/null
+++ b/src/app/api/organisations/[id]/accommodations/[accommodationId]/route.ts
@@ -0,0 +1,73 @@
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const putHandler: AuthenticatedApiHandler<{ id: string; accommodationId: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.PUT)) {
+ return sendForbidden();
+ }
+
+ const { accommodationId } = context.params;
+ const body = await req.json();
+
+ const url = `${API_BASE_URL}/api/accommodations/${accommodationId}`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.PUT,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to update accommodation');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error updating accommodation:', error);
+ return sendInternalError();
+ }
+};
+
+const deleteHandler: AuthenticatedApiHandler<{ id: string; accommodationId: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.DELETE)) {
+ return sendForbidden();
+ }
+
+ const { accommodationId } = context.params;
+ const url = `${API_BASE_URL}/api/accommodations/${accommodationId}`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.DELETE,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to delete accommodation');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error deleting accommodation:', error);
+ return sendInternalError();
+ }
+};
+
+export const PUT = withAuth(putHandler);
+export const DELETE = withAuth(deleteHandler);
diff --git a/src/app/api/organisations/[id]/accommodations/route.ts b/src/app/api/organisations/[id]/accommodations/route.ts
new file mode 100644
index 0000000..47b99df
--- /dev/null
+++ b/src/app/api/organisations/[id]/accommodations/route.ts
@@ -0,0 +1,72 @@
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const getHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.GET)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const url = `${API_BASE_URL}/api/accommodations/provider/${id}`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.GET,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to fetch organisation accommodations');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error fetching organisation accommodations:', error);
+ return sendInternalError();
+ }
+};
+
+const postHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.POST)) {
+ return sendForbidden();
+ }
+
+ const body = await req.json();
+
+ const url = `${API_BASE_URL}/api/accommodations`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.POST,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to create organisation accommodation');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error creating organisation accommodation:', error);
+ return sendInternalError();
+ }
+};
+
+export const GET = withAuth(getHandler);
+export const POST = withAuth(postHandler);
diff --git a/src/app/api/organisations/[id]/administrator/route.ts b/src/app/api/organisations/[id]/administrator/route.ts
new file mode 100644
index 0000000..2056b04
--- /dev/null
+++ b/src/app/api/organisations/[id]/administrator/route.ts
@@ -0,0 +1,43 @@
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+// @desc Update selected administrator for organisation
+// @route PUT /api/organisations/[id]/administrator
+// @access Private (OrgAdmin, CityAdmin, SuperAdmin)
+const putHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PUT)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const body = await req.json();
+
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/administrator`, {
+ method: HTTP_METHODS.PUT,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to update administrator');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error updating administrator:', error);
+ return sendInternalError();
+ }
+};
+
+export const PUT = withAuth(putHandler);
diff --git a/src/app/api/organisations/[id]/confirm-info/route.ts b/src/app/api/organisations/[id]/confirm-info/route.ts
new file mode 100644
index 0000000..6601577
--- /dev/null
+++ b/src/app/api/organisations/[id]/confirm-info/route.ts
@@ -0,0 +1,40 @@
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+// @desc Confirm organisation information is up to date
+// @route POST /api/organisations/[id]/confirm-info
+// @access Private (OrgAdmin, CityAdmin, SuperAdmin)
+const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.POST)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/confirm-info`, {
+ method: HTTP_METHODS.POST,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to confirm organisation information');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error confirming organisation information:', error);
+ return sendInternalError();
+ }
+};
+
+export const POST = withAuth(postHandler);
diff --git a/src/app/api/organisations/[id]/notes/route.ts b/src/app/api/organisations/[id]/notes/route.ts
new file mode 100644
index 0000000..5384b30
--- /dev/null
+++ b/src/app/api/organisations/[id]/notes/route.ts
@@ -0,0 +1,70 @@
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.POST)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const body = await req.json();
+
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/notes`, {
+ method: HTTP_METHODS.POST,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to add note');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error adding note:', error);
+ return sendInternalError();
+ }
+};
+
+const deleteHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.DELETE)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/notes`, {
+ method: HTTP_METHODS.DELETE,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to clear notes');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error clearing notes:', error);
+ return sendInternalError();
+ }
+};
+
+export const POST = withAuth(postHandler);
+export const DELETE = withAuth(deleteHandler);
diff --git a/src/app/api/organisations/[id]/route.ts b/src/app/api/organisations/[id]/route.ts
new file mode 100644
index 0000000..2ddbf90
--- /dev/null
+++ b/src/app/api/organisations/[id]/route.ts
@@ -0,0 +1,101 @@
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.GET)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}`, {
+ method: HTTP_METHODS.GET,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to fetch organisation');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error fetching organisation:', error);
+ return sendInternalError();
+ }
+};
+
+const putHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PUT)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const body = await req.json();
+
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}`, {
+ method: HTTP_METHODS.PUT,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to update organisation');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error updating organisation:', error);
+ return sendInternalError();
+ }
+};
+
+const patchHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PATCH)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const body = await req.json();
+
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}`, {
+ method: HTTP_METHODS.PATCH,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to patch organisation');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error patching organisation:', error);
+ return sendInternalError();
+ }
+};
+
+export const GET = withAuth(getHandler);
+export const PUT = withAuth(putHandler);
+export const PATCH = withAuth(patchHandler);
diff --git a/src/app/api/organisations/[id]/services/[serviceId]/route.ts b/src/app/api/organisations/[id]/services/[serviceId]/route.ts
new file mode 100644
index 0000000..e36ee70
--- /dev/null
+++ b/src/app/api/organisations/[id]/services/[serviceId]/route.ts
@@ -0,0 +1,73 @@
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const putHandler: AuthenticatedApiHandler<{ id: string; serviceId: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.PUT)) {
+ return sendForbidden();
+ }
+
+ const { serviceId } = context.params;
+ const body = await req.json();
+
+ const url = `${API_BASE_URL}/api/services/${serviceId}`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.PUT,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to update organisation service');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error updating organisation service:', error);
+ return sendInternalError();
+ }
+};
+
+const deleteHandler: AuthenticatedApiHandler<{ id: string; serviceId: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.DELETE)) {
+ return sendForbidden();
+ }
+
+ const { serviceId } = context.params;
+ const url = `${API_BASE_URL}/api/services/${serviceId}`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.DELETE,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to delete organisation service');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error deleting organisation service:', error);
+ return sendInternalError();
+ }
+};
+
+export const PUT = withAuth(putHandler);
+export const DELETE = withAuth(deleteHandler);
diff --git a/src/app/api/organisations/[id]/services/route.ts b/src/app/api/organisations/[id]/services/route.ts
new file mode 100644
index 0000000..e629865
--- /dev/null
+++ b/src/app/api/organisations/[id]/services/route.ts
@@ -0,0 +1,72 @@
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const getHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.GET)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const url = `${API_BASE_URL}/api/services/provider/${id}`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.GET,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to fetch organisation services');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error fetching organisation services:', error);
+ return sendInternalError();
+ }
+};
+
+const postHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.POST)) {
+ return sendForbidden();
+ }
+
+ const body = await req.json();
+
+ const url = `${API_BASE_URL}/api/services`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.POST,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to create organisation service');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error creating organisation service:', error);
+ return sendInternalError();
+ }
+};
+
+export const GET = withAuth(getHandler);
+export const POST = withAuth(postHandler);
diff --git a/src/app/api/organisations/[id]/toggle-published/route.ts b/src/app/api/organisations/[id]/toggle-published/route.ts
new file mode 100644
index 0000000..672548c
--- /dev/null
+++ b/src/app/api/organisations/[id]/toggle-published/route.ts
@@ -0,0 +1,40 @@
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const patchHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PATCH)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+ const body = await req.json();
+
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/toggle-published`, {
+ method: HTTP_METHODS.PATCH,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to toggle published status');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error toggling published status:', error);
+ return sendInternalError();
+ }
+};
+
+export const PATCH = withAuth(patchHandler);
diff --git a/src/app/api/organisations/[id]/toggle-verified/route.ts b/src/app/api/organisations/[id]/toggle-verified/route.ts
new file mode 100644
index 0000000..c2fe040
--- /dev/null
+++ b/src/app/api/organisations/[id]/toggle-verified/route.ts
@@ -0,0 +1,38 @@
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const patchHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PATCH)) {
+ return sendForbidden();
+ }
+
+ const { id } = context.params;
+
+ const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/toggle-verified`, {
+ method: HTTP_METHODS.PATCH,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to toggle verified status');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error toggling verified status:', error);
+ return sendInternalError();
+ }
+};
+
+export const PATCH = withAuth(patchHandler);
diff --git a/src/app/api/organisations/route.ts b/src/app/api/organisations/route.ts
new file mode 100644
index 0000000..cd782ad
--- /dev/null
+++ b/src/app/api/organisations/route.ts
@@ -0,0 +1,87 @@
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses';
+import { UserAuthClaims } from '@/types/auth';
+import { getUserLocationSlugs } from '@/utils/locationUtils';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.GET)) {
+ return sendForbidden();
+ }
+
+ // Forward query parameters
+ const searchParams = req.nextUrl.searchParams;
+
+ // Add location filtering for CityAdmin users when dropdown is empty (showing all their locations)
+ const userAuthClaims = auth.session.user.authClaims as UserAuthClaims;
+ const locationSlugs = getUserLocationSlugs(userAuthClaims, true);
+ const selectedLocation = searchParams.get('location');
+
+ // If CityAdmin with specific locations AND no location selected in dropdown
+ // Pass all their locations to show all users they have access to
+ if (locationSlugs && locationSlugs.length > 0 && !selectedLocation) {
+ searchParams.set('locations', locationSlugs.join(','));
+ }
+
+ const queryString = searchParams.toString();
+ const url = `${API_BASE_URL}/api/organisations${queryString ? `?${queryString}` : ''}`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.GET,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to fetch organisations');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error fetching organisations:', error);
+ return sendInternalError();
+ }
+};
+
+const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.POST)) {
+ return sendForbidden();
+ }
+
+ const body = await req.json();
+ const url = `${API_BASE_URL}/api/organisations`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.POST,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to create organisation');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error creating organisation:', error);
+ return sendInternalError();
+ }
+};
+
+export const GET = withAuth(getHandler);
+export const POST = withAuth(postHandler);
diff --git a/src/app/api/service-categories/route.ts b/src/app/api/service-categories/route.ts
new file mode 100644
index 0000000..b406cd5
--- /dev/null
+++ b/src/app/api/service-categories/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest } from 'next/server';
+import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth';
+import { hasApiAccess } from '@/lib/userService';
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses';
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => {
+ try {
+ if (!hasApiAccess(auth.session.user.authClaims, '/api/service-categories', HTTP_METHODS.GET)) {
+ return sendForbidden();
+ }
+
+ const url = `${API_BASE_URL}/api/service-categories`;
+
+ const response = await fetch(url, {
+ method: HTTP_METHODS.GET,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${auth.accessToken}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return sendError(response.status, data.error || 'Failed to fetch service categories');
+ }
+
+ return proxyResponse(data);
+ } catch (error) {
+ console.error('Error fetching service categories:', error);
+ return sendInternalError();
+ }
+};
+
+export const GET = withAuth(getHandler);
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
index 9dd60de..6162cef 100644
--- a/src/app/api/users/route.ts
+++ b/src/app/api/users/route.ts
@@ -41,7 +41,7 @@ const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, au
});
const data = await response.json();
-
+
if (!response.ok) {
return sendError(response.status, data.error || 'Failed to fetch users');
}
diff --git a/src/app/banners/page.tsx b/src/app/banners/page.tsx
index cb93164..cb88414 100644
--- a/src/app/banners/page.tsx
+++ b/src/app/banners/page.tsx
@@ -120,8 +120,9 @@ export default function BannersListPage() {
setLocations(result.data);
}
} catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Failed to load locations';
- console.error('Failed to fetch locations:', errorMessage);
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch locations';
+ setError(errorMessage);
+ errorToast.generic(errorMessage);
}
};
@@ -247,6 +248,7 @@ export default function BannersListPage() {
handleLocationFilter(e.target.value)}
className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-48"
@@ -258,6 +260,7 @@ export default function BannersListPage() {
handleTemplateFilter(e.target.value)}
className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-48"
@@ -269,6 +272,7 @@ export default function BannersListPage() {
handleStatusFilter(e.target.value)}
className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-32"
diff --git a/src/app/organisations/page.tsx b/src/app/organisations/page.tsx
index 83e54b4..6095bdd 100644
--- a/src/app/organisations/page.tsx
+++ b/src/app/organisations/page.tsx
@@ -1,16 +1,376 @@
'use client';
+import { useState, useEffect } from 'react';
+import '@/styles/pagination.css';
import { useAuthorization } from '@/hooks/useAuthorization';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { Pagination } from '@/components/ui/Pagination';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import { Plus, Search } from 'lucide-react';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import OrganisationCard from '@/components/organisations/OrganisationCard';
+import AddUserToOrganisationModal from '@/components/organisations/AddUserToOrganisationModal';
+import { AddOrganisationModal } from '@/components/organisations/AddOrganisationModal';
+import EditOrganisationModal from '@/components/organisations/EditOrganisationModal';
+import { NotesModal } from '@/components/organisations/NotesModal';
+import { DisableOrganisationModal } from '@/components/organisations/DisableOrganisationModal';
+import toastUtils, { errorToast, loadingToast } from '@/utils/toast';
import { ROLES } from '@/constants/roles';
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { useSession } from 'next-auth/react';
+import { UserAuthClaims } from '@/types/auth';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { exportOrganisationsToCsv } from '@/utils/csvExport';
export default function OrganisationsPage() {
- // Check authorization FIRST
+ // Check authorization FIRST before any other logic
const { isChecking, isAuthorized } = useAuthorization({
allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN, ROLES.ORG_ADMIN],
requiredPage: '/organisations',
autoRedirect: true
});
+ const { data: session } = useSession();
+ const [organisations, setOrganisations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [nameInput, setNameInput] = useState(''); // Name input field value
+ const [searchName, setSearchName] = useState(''); // Actual search term sent to API
+ const [isVerifiedFilter, setIsVerifiedFilter] = useState(''); // '', 'true', 'false'
+ const [isPublishedFilter, setIsPublishedFilter] = useState(''); // '', 'true', 'false'
+ const [locationFilter, setLocationFilter] = useState('');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [total, setTotal] = useState(0);
+ const [locations, setLocations] = useState>([]);
+ const [showDisableModal, setShowDisableModal] = useState(false);
+ const [showClearNotesConfirmModal, setShowClearNotesConfirmModal] = useState(false);
+ const [isAddUserModalOpen, setIsAddUserModalOpen] = useState(false);
+ const [isAddOrganisationModalOpen, setIsAddOrganisationModalOpen] = useState(false);
+ const [isEditOrganisationModalOpen, setIsEditOrganisationModalOpen] = useState(false);
+ const [isViewOrganisationModalOpen, setIsViewOrganisationModalOpen] = useState(false);
+ const [isNotesModalOpen, setIsNotesModalOpen] = useState(false);
+ const [selectedOrganisation, setSelectedOrganisation] = useState(null);
+ const [organisationToDisable, setOrganisationToDisable] = useState(null);
+ const [togglingPublishId, setTogglingPublishId] = useState(null);
+ const [togglingVerifyId, setTogglingVerifyId] = useState(null);
+
+ const limit = 9;
+
+ // Get user auth claims
+ const userAuthClaims = (session?.user?.authClaims || { roles: [], specificClaims: [] }) as UserAuthClaims;
+
+ // Check if user is OrgAdmin (without other admin roles)
+ const isOrgAdmin = userAuthClaims.roles.includes(ROLES.ORG_ADMIN) &&
+ !userAuthClaims.roles.includes(ROLES.SUPER_ADMIN) &&
+ !userAuthClaims.roles.includes(ROLES.VOLUNTEER_ADMIN) &&
+ !userAuthClaims.roles.includes(ROLES.CITY_ADMIN);
+
+ // Get organisation keys from AdminFor: claims for OrgAdmin users
+ const orgAdminKeys = isOrgAdmin
+ ? userAuthClaims.specificClaims
+ .filter((claim: string) => claim.startsWith('AdminFor:'))
+ .map((claim: string) => claim.replace('AdminFor:', ''))
+ : [];
+
+ // Only run effects if authorized
+ useEffect(() => {
+ if (isAuthorized) {
+ fetchLocations();
+ }
+ }, [isAuthorized]);
+
+ useEffect(() => {
+ if (isAuthorized) {
+ fetchOrganisations();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isAuthorized, currentPage, searchName, isVerifiedFilter, isPublishedFilter, locationFilter, limit]);
+
+ const fetchLocations = async () => {
+ try {
+ const response = await authenticatedFetch('/api/cities');
+ if (response.ok) {
+ const data = await response.json();
+ setLocations(data.data || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch locations:', err);
+ }
+ };
+
+ const fetchOrganisations = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ // OrgAdmin users: Fetch organisations by key (currently only first org, but supports multiple in future)
+ if (isOrgAdmin && orgAdminKeys.length > 0) {
+ // TODO: In the future, we can extend this to fetch multiple organisations by keys
+ // For now, fetch the first organisation the user administers
+ const orgKey = orgAdminKeys[0];
+
+ const response = await authenticatedFetch(`/api/organisations/${orgKey}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch organisation');
+ }
+
+ const result = await response.json();
+ // API returns single organisation, wrap in array for consistent display
+ setOrganisations(result.data ? [result.data] : []);
+ setTotal(1);
+ setTotalPages(1);
+ } else {
+ // All other roles: Fetch organisations with pagination and filters
+ const params = new URLSearchParams({
+ page: currentPage.toString(),
+ limit: limit.toString(),
+ });
+
+ if (searchName) params.append('search', searchName);
+ if (isVerifiedFilter) params.append('isVerified', isVerifiedFilter);
+ if (isPublishedFilter) params.append('isPublished', isPublishedFilter);
+ if (locationFilter) params.append('location', locationFilter);
+
+ const response = await authenticatedFetch(`/api/organisations?${params.toString()}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch organisations');
+ }
+
+ const result = await response.json();
+ setOrganisations(result.data || []);
+ setTotal(result.pagination?.total || 0);
+ setTotalPages(result.pagination?.pages || 1);
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch organisations';
+ setError(errorMessage);
+ errorToast.generic(errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSearchClick = () => {
+ setSearchName(nameInput.trim());
+ setCurrentPage(1);
+ };
+
+ const handleSearchKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSearchClick();
+ }
+ };
+
+ const handleIsVerifiedFilter = (value: string) => {
+ setIsVerifiedFilter(value);
+ setCurrentPage(1);
+ };
+
+ const handleIsPublishedFilter = (value: string) => {
+ setIsPublishedFilter(value);
+ setCurrentPage(1);
+ };
+
+ const handleLocationFilter = (value: string) => {
+ setLocationFilter(value);
+ setCurrentPage(1);
+ };
+
+ const handleView = (organisation: IOrganisation) => {
+ setSelectedOrganisation(organisation);
+ setIsViewOrganisationModalOpen(true);
+ };
+
+ const handleEdit = (organisation: IOrganisation) => {
+ setSelectedOrganisation(organisation);
+ setIsEditOrganisationModalOpen(true);
+ };
+
+ const handleDisableClick = (organisation: IOrganisation) => {
+ setOrganisationToDisable(organisation);
+ setShowDisableModal(true);
+ };
+
+ const handleTogglePublished = async (organisation: IOrganisation, staffName?: string, reason?: string, disablingDate?: Date) => {
+ setTogglingPublishId(organisation._id);
+ const isCurrentlyPublished = organisation.IsPublished;
+ const action = isCurrentlyPublished ? 'disable' : 'publish';
+ const toastId = loadingToast.process(action === 'publish' ? 'Publishing organisation' : 'Disabling organisation');
+
+ try {
+ let body: unknown = {};
+
+ // If disabling, include note information
+ if (isCurrentlyPublished && staffName && reason) {
+ body = {
+ note: {
+ StaffName: staffName,
+ Reason: reason,
+ Date: disablingDate || new Date() // Include disabling date
+ }
+ }
+ }
+
+ const response = await authenticatedFetch(`/api/organisations/${organisation._id}/toggle-published`, {
+ method: HTTP_METHODS.PATCH,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body)
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Failed to ${action} organisation`);
+ }
+
+ toastUtils.dismiss(toastId);
+ if (action === 'publish') {
+ toastUtils.custom('Organisation published successfully', { type: 'success' });
+ } else {
+ toastUtils.custom('Organisation disabled successfully', { type: 'success' });
+ }
+
+ // Refresh the list
+ fetchOrganisations();
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : `Failed to ${action} organisation`;
+ toastUtils.dismiss(toastId);
+ errorToast.generic(errorMessage);
+ } finally {
+ setTogglingPublishId(null);
+ }
+ };
+
+ const handleToggleVerified = async (organisation: IOrganisation) => {
+ setTogglingVerifyId(organisation._id);
+ const isCurrentlyVerified = organisation.IsVerified;
+ const action = isCurrentlyVerified ? 'unverify' : 'verify';
+ const toastId = loadingToast.process(action === 'verify' ? 'Verifying organisation' : 'Unverifying organisation');
+
+ try {
+ const response = await authenticatedFetch(`/api/organisations/${organisation._id}/toggle-verified`, {
+ method: HTTP_METHODS.PATCH
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Failed to ${action} organisation`);
+ }
+
+ toastUtils.dismiss(toastId);
+ if (action === 'verify') {
+ toastUtils.custom('Organisation verified successfully', { type: 'success' });
+ } else {
+ toastUtils.custom('Organisation unverified successfully', { type: 'success' });
+ }
+
+ // Refresh the list
+ fetchOrganisations();
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : `Failed to ${action} organisation`;
+ toastUtils.dismiss(toastId);
+ errorToast.generic(errorMessage);
+ } finally {
+ setTogglingVerifyId(null);
+ }
+ };
+
+ const handleAddUser = (organisation: IOrganisation) => {
+ setSelectedOrganisation(organisation);
+ setIsAddUserModalOpen(true);
+ };
+
+ const handleViewNotes = (organisation: IOrganisation) => {
+ setSelectedOrganisation(organisation);
+ setIsNotesModalOpen(true);
+ };
+
+ const handleClearNotesClick = () => {
+ setShowClearNotesConfirmModal(true);
+ };
+
+ const confirmClearNotes = async () => {
+ if (!selectedOrganisation) return;
+
+ setShowClearNotesConfirmModal(false);
+ const toastId = loadingToast.process('Clearing notes');
+
+ try {
+ const response = await authenticatedFetch(`/api/organisations/${selectedOrganisation._id}/notes`, {
+ method: HTTP_METHODS.DELETE
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to clear notes');
+ }
+
+ toastUtils.dismiss(toastId);
+ toastUtils.custom('Notes cleared successfully', { type: 'success' });
+
+ // Close the notes modal
+ setIsNotesModalOpen(false);
+
+ // Refresh the list
+ fetchOrganisations();
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to clear notes';
+ toastUtils.dismiss(toastId);
+ errorToast.generic(errorMessage);
+ }
+ };
+
+ const handleExport = async () => {
+ const toastId = loadingToast.process('Exporting organisations');
+
+ try {
+ // Build query parameters to fetch ALL organisations with current filters
+ const queryParams = new URLSearchParams();
+ queryParams.append('page', '1');
+ queryParams.append('limit', total.toString()); // Fetch all based on total count
+
+ if (searchName) queryParams.append('name', searchName);
+ if (locationFilter) queryParams.append('location', locationFilter);
+ if (isVerifiedFilter) queryParams.append('isVerified', isVerifiedFilter);
+ if (isPublishedFilter) queryParams.append('isPublished', isPublishedFilter);
+
+ // For OrgAdmin, add organisation keys filter
+ if (isOrgAdmin && orgAdminKeys.length > 0) {
+ queryParams.append('keys', orgAdminKeys.join(','));
+ }
+
+ const response = await authenticatedFetch(`/api/organisations?${queryParams.toString()}`);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch organisations for export');
+ }
+
+ const data = await response.json();
+ const allOrganisations = data.data || [];
+
+ // Export all organisations
+ exportOrganisationsToCsv(allOrganisations);
+
+ toastUtils.dismiss(toastId);
+ toastUtils.custom(`Exported ${allOrganisations.length} organisation${allOrganisations.length !== 1 ? 's' : ''}`, { type: 'success' });
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to export organisations';
+ toastUtils.dismiss(toastId);
+ errorToast.generic(errorMessage);
+ }
+ };
+
+ const confirmDisable = (staffName: string, reason: string, disablingDate: Date) => {
+ if (!organisationToDisable) return;
+
+ setShowDisableModal(false);
+ handleTogglePublished(organisationToDisable, staffName, reason, disablingDate);
+ setOrganisationToDisable(null);
+ };
+
// Show loading while checking authorization
if (isChecking) {
return (
@@ -20,39 +380,272 @@ export default function OrganisationsPage() {
);
}
- // Don't render anything if not authorized
+ // Don't render anything if not authorized (redirect handled by hook)
if (!isAuthorized) {
return null;
}
return (
-
-
-
Organisations
-
Manage service providers and organisations
-
-
-
-
-
-
Organisations Management
-
- This page will allow you to manage service providers and organisations.
-
-
-
- Add New Organisation
-
+
+ {/* Header - Always show but with conditional content */}
+
+
+
+
{isOrgAdmin ? 'My Organisation' : 'Organisations'}
+ {!isOrgAdmin && (
+
setIsAddOrganisationModalOpen(true)}>
+
+ Add Organisation
+
+ )}
+
-
+
+
+ {/* Filters - Hidden for OrgAdmin */}
+ {!isOrgAdmin && (
+
+
+
+
+
+
+ setNameInput(e.target.value)}
+ onKeyPress={handleSearchKeyPress}
+ className="pl-10"
+ />
+
+
+ Search
+
+
+
+
+
+ handleLocationFilter(e.target.value)}
+ className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-48"
+ >
+ All Locations
+ {locations.map(city => (
+ {city.Name}
+ ))}
+
+
+ handleIsVerifiedFilter(e.target.value)}
+ className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-48"
+ >
+ Verified: Either
+ Verified: Yes
+ Verified: No
+
+
+ handleIsPublishedFilter(e.target.value)}
+ className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-48"
+ >
+ Published: Either
+ Published: Yes
+ Published: No
+
+
+
+
+ )}
+
+ {/* Results Summary - Hidden for OrgAdmin */}
+ {!isOrgAdmin && (
+
+
+ {loading ? '' : `${total} organisation${total !== 1 ? 's' : ''} found`}
+
+ {!loading && total > 0 && (
+
+ Export Organisations
+
+ )}
+
+ )}
+
+ {/* Loading State */}
+ {loading && (
+
+ )}
+
+ {/* Error State */}
+ {error && !loading && (
+
+
Error Loading Organisations
+
{error}
+
+ Try Again
+
+
+ )}
+
+ {/* Empty State */}
+ {!loading && !error && organisations.length === 0 && (
+
+
No Organisations Found
+
+ {searchName || isVerifiedFilter || isPublishedFilter || locationFilter ? (
+
No organisations match your current filters. Try adjusting your search criteria.
+ ) : (
+
No organisations available.
+ )}
+
+
+ )}
+
+ {/* Organisations Grid */}
+ {!loading && !error && organisations.length > 0 && (
+
+ {organisations.map((organisation) => (
+
+ ))}
+
+ )}
+
+ {/* Pagination - Hidden for OrgAdmin */}
+ {!isOrgAdmin && !loading && !error && totalPages > 1 && (
+
+
+
+ Showing {(currentPage - 1) * limit + 1} - {Math.min(currentPage * limit, total)} of {total} organisations
+
+
+ )}
+
+
+ {/* Add User to Organisation Modal */}
+
{
+ setIsAddUserModalOpen(false);
+ setSelectedOrganisation(null);
+ }}
+ onSuccess={() => {
+ // Refresh the organisations list
+ fetchOrganisations();
+ }}
+ organisation={selectedOrganisation}
+ />
+
+ {/* Add Organisation Modal */}
+ setIsAddOrganisationModalOpen(false)}
+ onSuccess={() => {
+ // Refresh the organisations list
+ fetchOrganisations();
+ }}
+ />
+
+ {/* Edit Organisation Modal */}
+ {selectedOrganisation && (
+ {
+ setIsEditOrganisationModalOpen(false);
+ setSelectedOrganisation(null);
+ }}
+ organisation={selectedOrganisation}
+ onOrganisationUpdated={() => {
+ // Refresh the organisations list
+ fetchOrganisations();
+ }}
+ />
+ )}
+
+ {/* View Organisation Modal (Read-only) */}
+ {selectedOrganisation && (
+ {
+ setIsViewOrganisationModalOpen(false);
+ setSelectedOrganisation(null);
+ }}
+ organisation={selectedOrganisation}
+ onOrganisationUpdated={() => {
+ // Refresh the organisations list
+ fetchOrganisations();
+ }}
+ viewMode={true}
+ />
+ )}
+
+ {/* Notes Modal */}
+ {
+ setIsNotesModalOpen(false);
+ setSelectedOrganisation(null);
+ }}
+ onClearNotes={handleClearNotesClick}
+ organisation={selectedOrganisation}
+ />
+
+ {/* Disable Organisation Modal */}
+ {
+ setShowDisableModal(false);
+ setOrganisationToDisable(null);
+ }}
+ onConfirm={confirmDisable}
+ organisation={organisationToDisable}
+ />
+
+ {/* Clear Notes Confirmation Modal */}
+ setShowClearNotesConfirmModal(false)}
+ onConfirm={confirmClearNotes}
+ title="Clear All Notes"
+ message={`Are you sure you want to clear all notes for "${selectedOrganisation?.Name}"? This action cannot be undone.`}
+ variant="warning"
+ confirmLabel="Clear Notes"
+ cancelLabel="Cancel"
+ />
);
}
diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx
index 1b618ef..308a349 100644
--- a/src/app/users/page.tsx
+++ b/src/app/users/page.tsx
@@ -16,6 +16,7 @@ import { errorToast, successToast, loadingToast, toastUtils } from '@/utils/toas
import { authenticatedFetch } from '@/utils/authenticatedFetch';
import { ROLES, getRoleOptions } from '@/constants/roles';
import { HTTP_METHODS } from '@/constants/httpMethods';
+import { ICity } from '@/types';
export default function UsersPage() {
// Check authorization FIRST before any other logic
@@ -39,7 +40,7 @@ export default function UsersPage() {
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState
(null);
- const [locations, setLocations] = useState>([]);
+ const [locations, setLocations] = useState([]);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [showDeactivateConfirmModal, setShowDeactivateConfirmModal] = useState(false);
const [userToDelete, setUserToDelete] = useState(null);
@@ -74,8 +75,15 @@ export default function UsersPage() {
const data = await response.json();
setLocations(data.data || []);
}
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to fetch locations');
+ }
} catch (err) {
- console.error('Failed to fetch locations:', err);
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch locations';
+ setError(errorMessage);
+ errorToast.generic(errorMessage);
}
};
@@ -103,7 +111,7 @@ export default function UsersPage() {
setTotal(result.pagination?.total || 0);
setTotalPages(result.pagination?.pages || 1);
} catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Failed to load users';
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users';
setError(errorMessage);
errorToast.generic(errorMessage);
} finally {
@@ -281,6 +289,7 @@ export default function UsersPage() {
handleRoleFilter(e.target.value)}
className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-48"
@@ -294,6 +303,7 @@ export default function UsersPage() {
handleLocationFilter(e.target.value)}
className="block w-full px-3 py-2 border border-brand-q rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-brand-k bg-white min-w-48"
diff --git a/src/components/banners/BannerEditor.tsx b/src/components/banners/BannerEditor.tsx
index 9550367..579b632 100644
--- a/src/components/banners/BannerEditor.tsx
+++ b/src/components/banners/BannerEditor.tsx
@@ -527,6 +527,7 @@ export function BannerEditor({ initialData, onDataChange, onSave, saving = false
{
const file = e.target.files?.[0];
@@ -1108,11 +1109,11 @@ export function BannerEditor({ initialData, onDataChange, onSave, saving = false
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={confirmCancel}
- title="Cancel Changes"
- message="Are you sure you want to cancel? All unsaved changes will be lost."
+ title="Close without saving?"
+ message="You may lose unsaved changes."
variant="warning"
- confirmLabel="Yes, Cancel"
- cancelLabel="No, Keep Editing"
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
/>
);
diff --git a/src/components/organisations/AccommodationForm.tsx b/src/components/organisations/AccommodationForm.tsx
new file mode 100644
index 0000000..305430a
--- /dev/null
+++ b/src/components/organisations/AccommodationForm.tsx
@@ -0,0 +1,354 @@
+'use client';
+
+import React, { useState, useEffect, useImperativeHandle, useCallback } from 'react';
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import { ValidationError } from '@/components/ui/ErrorDisplay';
+import { IAccommodation, IAccommodationFormData, DiscretionaryValue } from '@/types/organisations/IAccommodation';
+import { AccommodationType } from '@/types/organisations/IAccommodation';
+import { validateAccommodation } from '@/schemas/accommodationSchema';
+import { GeneralInfoSection } from './accommodation-sections/GeneralInfoSection';
+import { ContactDetailsSection } from './accommodation-sections/ContactDetailsSection';
+import { LocationSection } from './accommodation-sections/LocationSection';
+import { PricingSection } from './accommodation-sections/PricingSection';
+import { FeaturesSection } from './accommodation-sections/FeaturesSection';
+import { SupportSection } from './accommodation-sections/SupportSection';
+import { SuitableForSection } from './accommodation-sections/SuitableForSection';
+
+export interface AccommodationFormRef {
+ validate: () => boolean;
+ getFormData: () => IAccommodationFormData;
+ resetForm: () => void;
+}
+
+interface AccommodationFormProps {
+ initialData?: IAccommodation | null;
+ providerId: string;
+ availableCities: Array<{ _id: string; Name: string; Key: string }>;
+ onValidationChange?: (errors: ValidationError[]) => void;
+ viewMode?: boolean;
+}
+
+interface CollapsibleSectionProps {
+ title: string;
+ isOpen: boolean;
+ onToggle: () => void;
+ children: React.ReactNode;
+ hasErrors?: boolean;
+}
+
+function CollapsibleSection({ title, isOpen, onToggle, children, hasErrors }: CollapsibleSectionProps) {
+ return (
+
+
+
+ {title}
+ {hasErrors && (Has errors) }
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+ {isOpen &&
{children}
}
+
+ );
+}
+
+export const AccommodationForm = React.forwardRef(({
+ initialData,
+ providerId,
+ availableCities,
+ onValidationChange,
+ viewMode = false
+}, ref) => {
+ const [validationErrors, setValidationErrors] = useState([]);
+
+ // Section collapse states
+ const [openSections, setOpenSections] = useState({
+ generalInfo: true,
+ contact: false,
+ location: false,
+ pricing: false,
+ features: false,
+ support: false,
+ suitableFor: false
+ });
+
+ // Initialize form data based on add/edit mode
+ const getInitialFormData = useCallback((): IAccommodationFormData => {
+ if (initialData) {
+ return {
+ GeneralInfo: initialData.GeneralInfo,
+ PricingAndRequirementsInfo: initialData.PricingAndRequirementsInfo,
+ ContactInformation: initialData.ContactInformation,
+ Address: initialData.Address,
+ FeaturesWithDiscretionary: initialData.FeaturesWithDiscretionary,
+ ResidentCriteriaInfo: initialData.ResidentCriteriaInfo,
+ SupportProvidedInfo: initialData.SupportProvidedInfo
+ };
+ }
+ return {
+ GeneralInfo: {
+ Name: '',
+ Synopsis: '',
+ Description: '',
+ AccommodationType: '' as AccommodationType, // Empty string for placeholder, will be validated on submit
+ ServiceProviderId: providerId,
+ ServiceProviderName: '',
+ IsOpenAccess: false,
+ IsPubliclyVisible: true,
+ IsPublished: false
+ },
+ PricingAndRequirementsInfo: {
+ ReferralIsRequired: false,
+ ReferralNotes: '',
+ Price: '',
+ FoodIsIncluded: DiscretionaryValue.DontKnowAsk,
+ AvailabilityOfMeals: ''
+ },
+ ContactInformation: {
+ Name: '',
+ Email: '',
+ Telephone: '',
+ AdditionalInfo: ''
+ },
+ Address: {
+ Street1: '',
+ Street2: '',
+ Street3: '',
+ City: '',
+ Postcode: '',
+ AssociatedCityId: ''
+ },
+ FeaturesWithDiscretionary: {
+ AcceptsHousingBenefit: DiscretionaryValue.DontKnowAsk,
+ AcceptsPets: DiscretionaryValue.DontKnowAsk,
+ AcceptsCouples: DiscretionaryValue.DontKnowAsk,
+ HasDisabledAccess: DiscretionaryValue.DontKnowAsk,
+ IsSuitableForWomen: DiscretionaryValue.DontKnowAsk,
+ IsSuitableForYoungPeople: DiscretionaryValue.DontKnowAsk,
+ HasSingleRooms: DiscretionaryValue.DontKnowAsk,
+ HasSharedRooms: DiscretionaryValue.DontKnowAsk,
+ HasShowerBathroomFacilities: DiscretionaryValue.DontKnowAsk,
+ HasAccessToKitchen: DiscretionaryValue.DontKnowAsk,
+ HasLaundryFacilities: DiscretionaryValue.DontKnowAsk,
+ HasLounge: DiscretionaryValue.DontKnowAsk,
+ AllowsVisitors: DiscretionaryValue.DontKnowAsk,
+ HasOnSiteManager: DiscretionaryValue.DontKnowAsk,
+ AdditionalFeatures: ''
+ },
+ ResidentCriteriaInfo: {
+ AcceptsMen: false,
+ AcceptsWomen: false,
+ AcceptsCouples: false,
+ AcceptsYoungPeople: false,
+ AcceptsFamilies: false,
+ AcceptsBenefitsClaimants: false
+ },
+ SupportProvidedInfo: {
+ SupportOffered: [],
+ SupportInfo: ''
+ }
+ };
+ }, [initialData, providerId]);
+
+ const [formData, setFormData] = useState(getInitialFormData);
+
+ // Reset form when initialData changes
+ useEffect(() => {
+ setFormData(getInitialFormData());
+ setValidationErrors([]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialData, providerId]);
+
+ // Notify parent of validation changes
+ useEffect(() => {
+ if (onValidationChange) {
+ onValidationChange(validationErrors);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [validationErrors]);
+
+ const toggleSection = (section: keyof typeof openSections) => {
+ setOpenSections(prev => ({ ...prev, [section]: !prev[section] }));
+ };
+
+ const handleFieldChange = (field: string, value: string | boolean | number | DiscretionaryValue | AccommodationType | string[]) => {
+ const keys = field.split('.');
+ setFormData(prev => {
+ const newData = { ...prev };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let current: any = newData;
+
+ for (let i = 0; i < keys.length - 1; i++) {
+ current[keys[i]] = { ...current[keys[i]] };
+ current = current[keys[i]];
+ }
+
+ current[keys[keys.length - 1]] = value;
+ return newData;
+ });
+ };
+
+ const getErrorsForSection = (prefix: string): Record => {
+ const errors: Record = {};
+ validationErrors.forEach(error => {
+ if (error.Path.startsWith(prefix)) {
+ errors[error.Path] = error.Message;
+ }
+ });
+ return errors;
+ };
+
+ const hasSectionErrors = (prefix: string): boolean => {
+ return validationErrors.some(error => error.Path.startsWith(prefix));
+ };
+
+ // Expose methods via ref
+ useImperativeHandle(ref, () => ({
+ validate: () => {
+ // Use schema validation
+ const result = validateAccommodation(formData);
+
+ if (!result.success) {
+ const errors = result.errors.map((error: { path: string[] | string; message: string }) => ({
+ Path: Array.isArray(error.path) ? error.path.join('.') : error.path,
+ Message: error.message
+ }));
+ setValidationErrors(errors);
+ return false;
+ }
+
+ setValidationErrors([]);
+ return true;
+ },
+ getFormData: () => formData,
+ resetForm: () => {
+ setFormData(getInitialFormData());
+ setValidationErrors([]);
+ setOpenSections({
+ generalInfo: true,
+ contact: false,
+ location: false,
+ pricing: false,
+ features: false,
+ support: false,
+ suitableFor: false
+ });
+ }
+ }));
+
+ return (
+
+ {/* General Information */}
+
toggleSection('generalInfo')}
+ hasErrors={hasSectionErrors('GeneralInfo')}
+ >
+
+
+
+ {/* Contact Details */}
+
toggleSection('contact')}
+ hasErrors={hasSectionErrors('ContactInformation')}
+ >
+
+
+
+ {/* Location */}
+
toggleSection('location')}
+ hasErrors={hasSectionErrors('Address')}
+ >
+
+
+
+ {/* Pricing & Requirements */}
+
toggleSection('pricing')}
+ hasErrors={hasSectionErrors('PricingAndRequirementsInfo')}
+ >
+
+
+
+ {/* Features */}
+
toggleSection('features')}
+ hasErrors={hasSectionErrors('FeaturesWithDiscretionary')}
+ >
+
+
+
+ {/* Support */}
+
toggleSection('support')}
+ hasErrors={hasSectionErrors('SupportProvidedInfo')}
+ >
+
+
+
+ {/* Suitable For */}
+
toggleSection('suitableFor')}
+ hasErrors={hasSectionErrors('ResidentCriteriaInfo')}
+ >
+
+
+
+ );
+});
+
+AccommodationForm.displayName = 'AccommodationForm';
diff --git a/src/components/organisations/AddAccommodationModal.tsx b/src/components/organisations/AddAccommodationModal.tsx
new file mode 100644
index 0000000..bdab6a4
--- /dev/null
+++ b/src/components/organisations/AddAccommodationModal.tsx
@@ -0,0 +1,219 @@
+'use client';
+
+import { useState, useRef } from 'react';
+import { X } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import { IAccommodation } from '@/types/organisations/IAccommodation';
+import { AccommodationForm, AccommodationFormRef } from './AccommodationForm';
+import { successToast, errorToast } from '@/utils/toast';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { transformErrorPath } from '@/schemas/accommodationSchema';
+
+interface AddAccommodationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ organisationId: string;
+ providerId: string;
+ availableCities: Array<{ _id: string; Name: string; Key: string }>;
+ accommodation?: IAccommodation | null;
+ viewMode?: boolean; // When true, all inputs are disabled and save button hidden
+}
+
+export function AddAccommodationModal({
+ isOpen,
+ onClose,
+ onSuccess,
+ organisationId,
+ providerId,
+ availableCities,
+ accommodation,
+ viewMode = false
+}: AddAccommodationModalProps) {
+ const formRef = useRef(null);
+ const isEditMode = !!accommodation;
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [validationErrors, setValidationErrors] = useState([]);
+ const [showCancelConfirm, setShowConfirmModal] = useState(false);
+
+ const handleValidationChange = (errors: ValidationError[]) => {
+ // Transform client-side validation paths to user-friendly names
+ const transformed = errors.map((e) => ({
+ Path: transformErrorPath(e.Path),
+ Message: e.Message,
+ }));
+ setValidationErrors(transformed);
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!formRef.current) return;
+
+ // Validate form
+ if (!formRef.current.validate()) {
+ errorToast.validation();
+ return;
+ }
+
+ setIsSubmitting(true);
+ setErrorMessage('');
+ setValidationErrors([]);
+
+ try {
+ const formData = formRef.current.getFormData();
+
+ const url = isEditMode
+ ? `/api/organisations/${organisationId}/accommodations/${accommodation._id}`
+ : `/api/organisations/${organisationId}/accommodations`;
+
+ const method = isEditMode ? 'PUT' : 'POST';
+
+ const response = await authenticatedFetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(formData)
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ if (data.errors && Array.isArray(data.errors)) {
+ const transformedErrors = data.errors.map((error: { path?: string | string[]; Path?: string; message?: string; Message?: string }) => {
+ const originalPath = Array.isArray(error.path) ? error.path.join('.') : (error.Path || '');
+ return {
+ Path: transformErrorPath(originalPath),
+ Message: error.Message || error.message || ''
+ };
+ });
+ setValidationErrors(transformedErrors);
+ errorToast.validation();
+ } else {
+ setErrorMessage(data.error || 'Failed to save accommodation');
+ errorToast.create('accommodation', data.error);
+ }
+ return;
+ }
+
+ successToast[isEditMode ? 'update' : 'create'](isEditMode ? 'Accommodation' : 'Accommodation');
+ onSuccess();
+ handleClose();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to save accommodation';
+ setErrorMessage(message);
+ errorToast.generic(message);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (formRef.current) {
+ formRef.current.resetForm();
+ }
+ setValidationErrors([]);
+ setErrorMessage('');
+ onClose();
+ };
+
+ const handleCancelClick = () => {
+ setShowConfirmModal(true);
+ };
+
+ const confirmCancel = () => {
+ setShowConfirmModal(false);
+ handleClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
+ {viewMode ? 'View Accommodation' : (isEditMode ? 'Edit Accommodation' : 'Add Accommodation')}
+
+
+
+
+
+
+ {/* Form Content - Scrollable */}
+
+
+
+
+ {/* Confirmation Modal */}
+ setShowConfirmModal(false)}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/components/organisations/AddLocationModal.tsx b/src/components/organisations/AddLocationModal.tsx
new file mode 100644
index 0000000..d3b8489
--- /dev/null
+++ b/src/components/organisations/AddLocationModal.tsx
@@ -0,0 +1,446 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { X } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { Checkbox } from '@/components/ui/Checkbox';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import { IAddressFormData, IOpeningTimeFormData } from '@/types/organisations/IOrganisation';
+import { OpeningTimeFormSchema, AddressSchema } from '@/schemas/organisationSchema';
+import { OpeningTimesManager } from './OpeningTimesManager';
+import { errorToast } from '@/utils/toast';
+import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay';
+import { decodeText } from '@/utils/htmlDecode';
+
+interface AddLocationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: (location: IAddressFormData) => void;
+ editingLocation?: IAddressFormData | null;
+ validationErrors?: ValidationError[];
+ viewMode?: boolean; // When true, all inputs are disabled and save button hidden
+}
+
+export function AddLocationModal({
+ isOpen,
+ onClose,
+ onSave,
+ editingLocation = null,
+ validationErrors: _validationErrors = [],
+ viewMode = false
+}: AddLocationModalProps) {
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [locationErrors, setLocationErrors] = useState([]);
+ const [currentLocation, setCurrentLocation] = useState({
+ Street: '',
+ Street1: '',
+ Street2: '',
+ Street3: '',
+ City: '',
+ Postcode: '',
+ Telephone: '',
+ IsOpen247: false,
+ IsAppointmentOnly: false,
+ OpeningTimes: []
+ });
+
+ // Initialize form when modal opens or editing location changes
+ useEffect(() => {
+ if (isOpen) {
+ if (editingLocation) {
+ setCurrentLocation({
+ ...editingLocation,
+ Street: decodeText(editingLocation.Street || ''),
+ Street1: decodeText(editingLocation.Street1 || ''),
+ Street2: decodeText(editingLocation.Street2 || ''),
+ Street3: decodeText(editingLocation.Street3 || '')
+ });
+ } else {
+ resetForm();
+ }
+ }
+ }, [isOpen, editingLocation]);
+
+ const resetForm = () => {
+ setCurrentLocation({
+ Street: '',
+ Street1: '',
+ Street2: '',
+ Street3: '',
+ City: '',
+ Postcode: '',
+ Telephone: '',
+ IsOpen247: false,
+ IsAppointmentOnly: false,
+ OpeningTimes: []
+ });
+ setLocationErrors([]);
+ };
+
+ const generateLocationKey = (location: IAddressFormData): string => {
+ const parts = [location.Street, location.City, location.Postcode].filter(part => part && part.trim());
+ return parts.join('-').toLowerCase().replace(/[^a-z0-9-]/g, '');
+ };
+
+ const validateLocation = (): boolean => {
+ const errors: ValidationError[] = [];
+
+ // Validate using AddressSchema for comprehensive validation including Postcode format
+ const addressValidation = AddressSchema.safeParse(currentLocation);
+
+ if (!addressValidation.success) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ addressValidation.error.issues.forEach((issue: any) => {
+ const fieldName = issue.path[0] as string;
+ // Skip Key validation errors since it's auto-generated
+ if (fieldName !== 'Key') {
+ errors.push({
+ Path: fieldName,
+ Message: issue.message
+ });
+ }
+ });
+ }
+
+ // Validate opening times if not 24/7
+ if (!currentLocation.IsOpen247) {
+ if (currentLocation.OpeningTimes.length === 0) {
+ errors.push({ Path: 'Opening Times', Message: 'At least one opening time is required when location is not open 24/7' });
+ } else {
+ // Validate each opening time using OpeningTimeFormSchema
+ for (const openingTime of currentLocation.OpeningTimes) {
+ const result = OpeningTimeFormSchema.safeParse(openingTime);
+ if (!result.success) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ result.error.issues.forEach((issue: any) => {
+ errors.push({ Path: 'Opening Times', Message: issue.message });
+ });
+ }
+ }
+ }
+ }
+
+ if (errors.length > 0) {
+ setLocationErrors(errors);
+ return false;
+ }
+
+ setLocationErrors([]);
+ return true;
+ };
+
+ const handleSave = () => {
+ if (!validateLocation()) {
+ errorToast.validation();
+ return;
+ }
+
+ const locationWithKey = {
+ ...currentLocation,
+ Key: generateLocationKey(currentLocation)
+ };
+
+ onSave(locationWithKey);
+ onClose();
+ };
+
+ const confirmCancel = () => {
+ setShowConfirmModal(false);
+ resetForm();
+ onClose();
+ };
+
+ const handleOpeningTimesChange = (openingTimes: IOpeningTimeFormData[]) => {
+ setCurrentLocation({
+ ...currentLocation,
+ OpeningTimes: openingTimes
+ });
+ // Clear errors when opening times change to give immediate feedback
+ if (locationErrors.length > 0) {
+ setLocationErrors([]);
+ }
+ };
+
+ const handle24x7Change = (checked: boolean) => {
+ let openingTimes: IOpeningTimeFormData[] = [];
+
+ if (checked) {
+ // Generate 24/7 opening times for all days
+ // IsOpen247 has priority - even if IsAppointmentOnly is also checked
+ openingTimes = Array.from({ length: 7 }, (_, day) => ({
+ Day: day,
+ StartTime: '00:00',
+ EndTime: '23:59'
+ }));
+ } else if (currentLocation.IsAppointmentOnly) {
+ // Keep existing opening times when unchecking
+ openingTimes = currentLocation.OpeningTimes;
+ }
+
+ setCurrentLocation({
+ ...currentLocation,
+ IsOpen247: checked,
+ OpeningTimes: openingTimes
+ });
+ };
+
+ const handleAppointmentOnlyChange = (checked: boolean) => {
+ // IsAppointmentOnly can now coexist with opening times - don't clear them
+ setCurrentLocation({
+ ...currentLocation,
+ IsAppointmentOnly: checked
+ });
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
+ {viewMode ? 'View Location' : (editingLocation ? 'Edit Location' : 'Add New Location')}
+
+ viewMode ? onClose() : setShowConfirmModal(true)}
+ className="p-2"
+ title="Close"
+ >
+
+
+
+
+ {/* Content - scrollable */}
+
+
+ {/* Address Fields */}
+
+
Address Information
+
+
+
+
+ Street *
+
+ {viewMode ? (
+
+ {currentLocation.Street || '-'}
+
+ ) : (
+
setCurrentLocation({
+ ...currentLocation,
+ Street: e.target.value
+ })}
+ placeholder="Main street address"
+ />
+ )}
+
+
+
+
+ Street Line 1
+
+ {viewMode ? (
+
+ {currentLocation.Street1 || '-'}
+
+ ) : (
+
setCurrentLocation({
+ ...currentLocation,
+ Street1: e.target.value
+ })}
+ placeholder="Building name, floor, etc."
+ />
+ )}
+
+
+
+
+ Street Line 2
+
+ {viewMode ? (
+
+ {currentLocation.Street2 || '-'}
+
+ ) : (
+
setCurrentLocation({
+ ...currentLocation,
+ Street2: e.target.value
+ })}
+ placeholder="Additional address info"
+ />
+ )}
+
+
+
+
+ Street Line 3
+
+ {viewMode ? (
+
+ {currentLocation.Street3 || '-'}
+
+ ) : (
+
setCurrentLocation({
+ ...currentLocation,
+ Street3: e.target.value
+ })}
+ placeholder="Additional address info"
+ />
+ )}
+
+
+
+
+ City
+
+ setCurrentLocation({
+ ...currentLocation,
+ City: e.target.value
+ })}
+ placeholder={viewMode ? '' : 'City'}
+ disabled={viewMode}
+ />
+
+
+
+
+ Postcode *
+
+ setCurrentLocation({
+ ...currentLocation,
+ Postcode: e.target.value
+ })}
+ placeholder={viewMode ? '' : 'Postcode'}
+ disabled={viewMode}
+ />
+
+
+
+
+ Telephone
+
+ setCurrentLocation({
+ ...currentLocation,
+ Telephone: e.target.value
+ })}
+ placeholder={viewMode ? '' : 'Telephone number'}
+ type="tel"
+ disabled={viewMode}
+ />
+
+
+
+
+ {/* Opening Times Section */}
+
+
Opening Times
+
+
+ handle24x7Change(e.target.checked)}
+ label="Open 24/7"
+ disabled={viewMode}
+ />
+
+ handleAppointmentOnlyChange(e.target.checked)}
+ label="Appointment Only"
+ disabled={viewMode}
+ />
+
+
+ {!currentLocation.IsOpen247 && (
+
+ )}
+
+
+
+
+ {/* Footer - fixed at bottom */}
+ {!viewMode && (
+
+ {/* Error Display */}
+ {locationErrors.length > 0 && (
+
+ )}
+
+
+ setShowConfirmModal(true)}
+ className="w-full sm:w-auto sm:min-w-24 order-2 sm:order-1"
+ >
+ Cancel
+
+
+ {editingLocation ? 'Update Location' : 'Add Location'}
+
+
+
+ )}
+
+
+
+ {/* Confirmation Modal */}
+ setShowConfirmModal(false)}
+ onConfirm={confirmCancel}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
+ >
+ );
+}
diff --git a/src/components/organisations/AddOrganisationModal.tsx b/src/components/organisations/AddOrganisationModal.tsx
new file mode 100644
index 0000000..764e1da
--- /dev/null
+++ b/src/components/organisations/AddOrganisationModal.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { useState, useRef } from 'react';
+import { X } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import { OrganisationForm, OrganisationFormRef } from './OrganisationForm';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { errorToast, successToast, loadingToast, toastUtils } from '@/utils/toast';
+import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay';
+
+interface AddOrganisationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+export function AddOrganisationModal({ isOpen, onClose, onSuccess }: AddOrganisationModalProps) {
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [validationErrors, setValidationErrors] = useState([]);
+ const [error, setError] = useState('');
+ const [saving, setSaving] = useState(false);
+ const formRef = useRef(null);
+
+ const handleValidationChange = (errors: ValidationError[]) => {
+ setValidationErrors(errors);
+ };
+
+ // Helper function to convert time string (HH:MM) to number (HHMM)
+ const timeStringToNumber = (timeString: string): number => {
+ return parseInt(timeString.replace(':', ''));
+ };
+
+ const handleSave = async () => {
+ setError('');
+
+ // Validate form using ref
+ if (!formRef.current?.validate()) {
+ errorToast.validation();
+ return;
+ }
+
+ // Get form data using ref
+ const formData = formRef.current?.getFormData();
+ if (!formData) {
+ errorToast.generic('Failed to get form data');
+ return;
+ }
+
+ setSaving(true);
+ const toastId = loadingToast.create('organisation');
+
+ try {
+ // Convert Tags array to comma-separated string for API
+ const apiData = {
+ ...formData,
+ Tags: formData.Tags.join(','),
+ // Convert opening times from form format (string) to API format (number)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Addresses: formData.Addresses.map((address: any) => ({
+ ...address,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ OpeningTimes: address.OpeningTimes.map((time: any) => ({
+ Day: time.Day,
+ StartTime: timeStringToNumber(time.StartTime),
+ EndTime: timeStringToNumber(time.EndTime)
+ }))
+ }))
+ };
+
+ const response = await authenticatedFetch('/api/organisations', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(apiData),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to create organisation');
+ }
+
+ // Dismiss loading toast before showing success
+ toastUtils.dismiss(toastId);
+ successToast.create('Organisation');
+ onSuccess();
+ onClose();
+ } catch (error) {
+ // Dismiss loading toast before showing error
+ toastUtils.dismiss(toastId);
+ const errorMessage = error instanceof Error ? error.message : 'Failed to create organisation';
+ setError(errorMessage);
+ errorToast.create('organisation', errorMessage);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const confirmCancel = () => {
+ setShowConfirmModal(false);
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
Add Organisation
+ setShowConfirmModal(true)}
+ className="p-2"
+ title="Close"
+ >
+
+
+
+
+ {/* Content - scrollable */}
+
+
+
+
+ {/* Footer - fixed at bottom */}
+
+ {/* Error Display */}
+
+
+
+ setShowConfirmModal(true)}
+ disabled={saving}
+ className="w-full sm:w-auto sm:min-w-24 order-2 sm:order-1"
+ >
+ Cancel
+
+
+ {saving ? 'Saving...' : 'Save Organisation'}
+
+
+
+
+
+
+ {/* Confirmation Modal */}
+ setShowConfirmModal(false)}
+ onConfirm={confirmCancel}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
+ >
+ );
+}
diff --git a/src/components/organisations/AddServiceModal.tsx b/src/components/organisations/AddServiceModal.tsx
new file mode 100644
index 0000000..49e1c9f
--- /dev/null
+++ b/src/components/organisations/AddServiceModal.tsx
@@ -0,0 +1,858 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { X } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { Textarea } from '@/components/ui/Textarea';
+import { Checkbox } from '@/components/ui/Checkbox';
+import { MultiSelect } from '@/components/ui/MultiSelect';
+import { OpeningTimesManager } from '@/components/organisations/OpeningTimesManager';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import { IGroupedService } from '@/types/organisations/IGroupedService';
+import { IServiceCategory } from '@/types/organisations/IServiceCategory';
+import { IOpeningTimeFormData } from '@/types/organisations/IOrganisation';
+import { IGroupedServiceFormData, validateGroupedService, transformErrorPath } from '@/schemas/groupedServiceSchema';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { errorToast, successToast } from '@/utils/toast';
+import { decodeText } from '@/utils/htmlDecode';
+
+interface AddServiceModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ organisation: IOrganisation;
+ service?: IGroupedService | null;
+ onServiceSaved: () => void;
+ viewMode?: boolean; // When true, all inputs are disabled and save button hidden
+}
+
+const AddServiceModal: React.FC = ({
+ isOpen,
+ onClose,
+ organisation,
+ service,
+ onServiceSaved,
+ viewMode = false
+}) => {
+ const [formData, setFormData] = useState({
+ ProviderId: organisation._id,
+ ProviderName: organisation.Name,
+ IsPublished: false,
+ IsVerified: false,
+ CategoryId: '',
+ Location: {
+ IsOutreachLocation: false,
+ Description: '',
+ StreetLine1: '',
+ Postcode: ''
+ },
+ IsOpen247: false,
+ SubCategories: [],
+ IsTelephoneService: false,
+ IsAppointmentOnly: false
+ });
+
+ const [categories, setCategories] = useState([]);
+ const [selectedCategory, setSelectedCategory] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [validationErrors, setValidationErrors] = useState([]);
+ const [showCancelConfirm, setShowConfirmModal] = useState(false);
+ const [originalData, setOriginalData] = useState(null);
+
+ // Initialize form data when service prop changes
+ useEffect(() => {
+ if (service) {
+ // Convert existing service to form data format
+ const openingTimes = service.OpeningTimes?.map(ot => ({
+ Day: ot.Day,
+ StartTime: typeof ot.StartTime === 'number' ?
+ `${Math.floor(ot.StartTime / 100).toString().padStart(2, '0')}:${(ot.StartTime % 100).toString().padStart(2, '0')}` :
+ ot.StartTime,
+ EndTime: typeof ot.EndTime === 'number' ?
+ `${Math.floor(ot.EndTime / 100).toString().padStart(2, '0')}:${(ot.EndTime % 100).toString().padStart(2, '0')}` :
+ ot.EndTime
+ })) || [];
+
+ const initialData: IGroupedServiceFormData = {
+ _id: service._id,
+ ProviderId: service.ProviderId,
+ ProviderName: service.ProviderName || organisation.Name,
+ IsPublished: service.IsPublished,
+ IsVerified: service.IsVerified,
+ CategoryId: service.CategoryId,
+ CategoryName: decodeText(service.CategoryName || ''),
+ CategorySynopsis: decodeText(service.CategorySynopsis || ''),
+ Info: decodeText(service.Info || ''),
+ Tags: service.Tags,
+ Location: {
+ IsOutreachLocation: service.Location.IsOutreachLocation || false,
+ Description: decodeText(service.Location.Description || ''),
+ StreetLine1: decodeText(service.Location.StreetLine1 || ''),
+ StreetLine2: service.Location.StreetLine2 || '',
+ StreetLine3: service.Location.StreetLine3 || '',
+ StreetLine4: service.Location.StreetLine4 || '',
+ City: service.Location.City || '',
+ Postcode: service.Location.Postcode || '',
+ Location: service.Location.Location ? {
+ type: service.Location.Location.type,
+ coordinates: service.Location.Location.coordinates
+ } : undefined
+ },
+ IsOpen247: service.IsOpen247,
+ OpeningTimes: openingTimes,
+ SubCategories: service.SubCategories?.map(sub => ({
+ ...sub,
+ Name: sub.Name || '',
+ Synopsis: sub.Synopsis || ''
+ })) || [],
+ IsTelephoneService: service.IsTelephoneService,
+ IsAppointmentOnly: service.IsAppointmentOnly,
+ Telephone: service.Telephone || ''
+ };
+ setFormData(initialData);
+ setOriginalData(JSON.parse(JSON.stringify(initialData)));
+ } else {
+ // Reset form for new service
+ const initialData: IGroupedServiceFormData = {
+ ProviderId: organisation.Key,
+ ProviderName: organisation.Name,
+ IsPublished: organisation.IsPublished,
+ IsVerified: organisation.IsVerified,
+ CategoryId: '',
+ Location: {
+ IsOutreachLocation: false,
+ Description: '',
+ StreetLine1: '',
+ Postcode: ''
+ },
+ IsOpen247: false,
+ SubCategories: [],
+ IsTelephoneService: false,
+ IsAppointmentOnly: false,
+ Telephone: ''
+ };
+ setFormData(initialData);
+ setOriginalData(JSON.parse(JSON.stringify(initialData)));
+ }
+ }, [service, organisation._id, organisation.IsPublished, organisation.IsVerified, organisation.Key, organisation.Name]);
+
+ // Fetch service categories
+ useEffect(() => {
+ if (!organisation.Key) return;
+
+ const fetchCategories = async () => {
+ try {
+ const response = await authenticatedFetch('/api/service-categories');
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to fetch service categories');
+ }
+ const data = await response.json();
+ setCategories(data.data || []);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to load service categories';
+ errorToast.generic(errorMessage);
+ }
+ };
+
+ if (isOpen) {
+ fetchCategories();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen]);
+
+ // Update selected category when category ID changes
+ useEffect(() => {
+ if (formData.CategoryId && categories.length > 0) {
+ const category = categories.find(cat => cat._id === formData.CategoryId);
+ setSelectedCategory(category || null);
+ } else {
+ setSelectedCategory(null);
+ }
+ }, [formData.CategoryId, categories]);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const updateFormData = (field: keyof IGroupedServiceFormData, value: any) => {
+ setFormData(prev => ({
+ ...prev,
+ [field]: value
+ }));
+ // Clear validation errors for this field
+ setValidationErrors(prev => prev.filter(error => !error.Path.startsWith(field)));
+ };
+
+ const handleIsOpen247Change = (checked: boolean) => {
+ let openingTimes: IOpeningTimeFormData[] = [];
+
+ if (checked) {
+ // Generate 24/7 opening times for all days (Sunday=0 to Saturday=6)
+ // IsOpen247 has priority - even if IsAppointmentOnly is also checked
+ openingTimes = Array.from({ length: 7 }, (_, day) => ({
+ Day: day,
+ StartTime: '00:00',
+ EndTime: '23:59'
+ }));
+ } else {
+ // Keep existing opening times when unchecking
+ openingTimes = formData.OpeningTimes || [];
+ }
+
+ setFormData(prev => ({
+ ...prev,
+ IsOpen247: checked,
+ OpeningTimes: openingTimes
+ }));
+ // Clear validation errors
+ setValidationErrors(prev => prev.filter(error => !error.Path.startsWith('Opening Times')));
+ };
+
+ const handleIsAppointmentOnlyChange = (checked: boolean) => {
+ // IsAppointmentOnly can now coexist with opening times - don't clear them
+ setFormData(prev => ({
+ ...prev,
+ IsAppointmentOnly: checked
+ }));
+ // Clear validation errors
+ setValidationErrors(prev => prev.filter(error => !error.Path.startsWith('Opening Times')));
+ };
+
+ const handleCategoryChange = (categoryId: string) => {
+ const category = categories.find(cat => cat._id === categoryId);
+ if (category) {
+ setFormData(prev => ({
+ ...prev,
+ CategoryId: categoryId,
+ CategoryName: category.Name,
+ CategorySynopsis: category.Synopsis,
+ SubCategories: []
+ }));
+ // Clear validation errors
+ setValidationErrors(prev => prev.filter(error =>
+ !error.Path.startsWith('Category') && !error.Path.startsWith('Sub Categories')
+ ));
+ }
+ };
+
+ const handleSubCategoriesChange = (selectedIds: string[]) => {
+ if (!selectedCategory) return;
+
+ const selectedSubCategories = selectedCategory.SubCategories.filter(sub =>
+ selectedIds.includes(sub.Key)
+ ).map(sub => ({
+ _id: sub.Key,
+ Name: sub.Name,
+ Synopsis: sub.Synopsis
+ }));
+
+ setFormData(prev => ({
+ ...prev,
+ SubCategories: selectedSubCategories
+ }));
+
+ // Clear validation errors for subcategories
+ setValidationErrors(prev => prev.filter(error => !error.Path.startsWith('Sub Categories')));
+ };
+
+ const handleIsOutreachLocationChange = (isOutreach: boolean) => {
+ setFormData(prev => ({
+ ...prev,
+ Location: {
+ ...prev.Location,
+ IsOutreachLocation: isOutreach,
+ // Clear fields based on type
+ Description: isOutreach ? prev.Location.Description : '',
+ StreetLine1: isOutreach ? '' : prev.Location.StreetLine1,
+ StreetLine2: isOutreach ? '' : prev.Location.StreetLine2,
+ StreetLine3: isOutreach ? '' : prev.Location.StreetLine3,
+ StreetLine4: isOutreach ? '' : prev.Location.StreetLine4,
+ City: isOutreach ? '' : prev.Location.City,
+ Postcode: isOutreach ? '' : prev.Location.Postcode,
+ Location: isOutreach ? undefined : prev.Location.Location
+ },
+ // Clear opening times when switching to outreach or fixed location
+ IsOpen247: false,
+ IsAppointmentOnly: false,
+ OpeningTimes: []
+ }));
+ };
+
+ const handleAddressSelect = (addressIndex: string) => {
+ if (addressIndex === '') {
+ // Clear address fields
+ setFormData(prev => ({
+ ...prev,
+ Location: {
+ ...prev.Location,
+ StreetLine1: '',
+ StreetLine2: '',
+ StreetLine3: '',
+ StreetLine4: '',
+ City: '',
+ Postcode: '',
+ Location: undefined
+ },
+ IsOpen247: false,
+ IsAppointmentOnly: false,
+ OpeningTimes: []
+ }));
+ return;
+ }
+
+ const index = parseInt(addressIndex);
+ const address = organisation.Addresses[index];
+ if (address) {
+ // Auto-populate location fields AND opening times from selected address
+ const openingTimes = address.OpeningTimes?.map(ot => ({
+ Day: ot.Day,
+ StartTime: typeof ot.StartTime === 'number' ?
+ `${Math.floor(ot.StartTime / 100).toString().padStart(2, '0')}:${(ot.StartTime % 100).toString().padStart(2, '0')}` :
+ String(ot.StartTime),
+ EndTime: typeof ot.EndTime === 'number' ?
+ `${Math.floor(ot.EndTime / 100).toString().padStart(2, '0')}:${(ot.EndTime % 100).toString().padStart(2, '0')}` :
+ String(ot.EndTime)
+ })) || [];
+
+ setFormData(prev => ({
+ ...prev,
+ Location: {
+ ...prev.Location,
+ StreetLine1: address.Street || '',
+ StreetLine2: address.Street1 || '',
+ StreetLine3: address.Street2 || '',
+ StreetLine4: address.Street3 || '',
+ City: address.City || '',
+ Postcode: address.Postcode || '',
+ Location: address.Location ? {
+ type: address.Location.type,
+ coordinates: address.Location.coordinates
+ } : undefined
+ },
+ IsOpen247: address.IsOpen247 || false,
+ IsAppointmentOnly: address.IsAppointmentOnly || false,
+ OpeningTimes: openingTimes
+ }));
+ }
+ };
+
+ const handleOpeningTimesChange = (openingTimes: IOpeningTimeFormData[]) => {
+ updateFormData('OpeningTimes', openingTimes);
+ };
+
+ // Helper function to convert time string (HH:MM) to number (HHMM)
+ const timeStringToNumber = (timeString: string): number => {
+ return parseInt(timeString.replace(':', ''));
+ };
+
+ const validateForm = (): boolean => {
+
+ // Then validate the rest using GroupedServiceSchema
+ const result = validateGroupedService(formData);
+
+ // Combine all errors
+ const allErrors: { Path: string; Message: string }[] = [];
+ if (!result.success) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const serviceErrors = (result.errors || []).map((error: any) => {
+ const originalPath = Array.isArray(error.path) ? error.path.join('.') : error.path;
+ return {
+ Path: transformErrorPath(originalPath),
+ Message: error.message
+ };
+ });
+ allErrors.push(...serviceErrors);
+ }
+
+ if (allErrors.length > 0) {
+ setValidationErrors(allErrors);
+ return false;
+ }
+
+ setValidationErrors([]);
+ return true;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateForm()) {
+ errorToast.validation();
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // Convert opening times from string format to number format for API
+ const submissionData = {
+ ...formData,
+ OpeningTimes: formData.OpeningTimes?.map(ot => ({
+ Day: ot.Day,
+ StartTime: timeStringToNumber(ot.StartTime),
+ EndTime: timeStringToNumber(ot.EndTime)
+ }))
+ };
+
+ const url = service
+ ? `/api/organisations/${service.ProviderId}/services/${service._id}`
+ : `/api/organisations/${submissionData.ProviderId}/services`;
+
+ const method = service ? 'PUT' : 'POST';
+
+ const response = await authenticatedFetch(url, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(submissionData),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Failed to ${service ? 'update' : 'create'} service`);
+ }
+
+ if (service) {
+ successToast.update('Service');
+ } else {
+ successToast.create('Service');
+ }
+
+ // Reset form to initial state for next use
+ const initialData: IGroupedServiceFormData = {
+ ProviderId: organisation.Key,
+ ProviderName: organisation.Name,
+ IsPublished: organisation.IsPublished,
+ IsVerified: organisation.IsVerified,
+ CategoryId: '',
+ Location: {
+ IsOutreachLocation: false,
+ Description: '',
+ StreetLine1: '',
+ Postcode: ''
+ },
+ IsOpen247: false,
+ SubCategories: [],
+ IsTelephoneService: false,
+ IsAppointmentOnly: false,
+ Telephone: ''
+ };
+ setFormData(initialData);
+ setOriginalData(JSON.parse(JSON.stringify(initialData)));
+ setValidationErrors([]);
+
+ onServiceSaved(); // Trigger parent refresh
+ onClose(); // Close the modal
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : `Failed to ${service ? 'update' : 'create'} service`;
+ errorToast.generic(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleConfirmCancel = () => {
+ setValidationErrors([]);
+ setShowConfirmModal(false);
+ if (originalData) {
+ setFormData(JSON.parse(JSON.stringify(originalData)));
+ }
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ const isOutreachLocation = formData.Location.IsOutreachLocation === true;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
+ {viewMode ? 'View Service' : (service ? 'Edit Service' : 'Add Service')}
+
+ viewMode ? onClose() : setShowConfirmModal(true)}
+ className="p-2"
+ title="Close"
+ >
+
+
+
+
+ {/* Content - scrollable */}
+
+
+
+
+ {/* Confirmation Modal */}
+ setShowConfirmModal(false)}
+ onConfirm={handleConfirmCancel}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
+ >
+ );
+};
+
+export default AddServiceModal;
diff --git a/src/components/organisations/AddUserToOrganisationModal.tsx b/src/components/organisations/AddUserToOrganisationModal.tsx
new file mode 100644
index 0000000..3a1057b
--- /dev/null
+++ b/src/components/organisations/AddUserToOrganisationModal.tsx
@@ -0,0 +1,214 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { X } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import toastUtils, { errorToast, loadingToast, successToast } from '@/utils/toast';
+import { HTTP_METHODS } from '@/constants/httpMethods';
+import { ROLE_PREFIXES, ROLES } from '@/constants/roles';
+import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+
+interface AddUserToOrganisationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ organisation: IOrganisation | null;
+}
+
+export default function AddUserToOrganisationModal({
+ isOpen,
+ onClose,
+ onSuccess,
+ organisation
+}: AddUserToOrganisationModalProps) {
+ const [email, setEmail] = useState('');
+ const [validationErrors, setValidationErrors] = useState([]);
+ const [generalError, setGeneralError] = useState('');
+
+ useEffect(() => {
+ if (isOpen) {
+ // Reset form when modal opens
+ setEmail('');
+ setValidationErrors([]);
+ setGeneralError('');
+ }
+ }, [isOpen]);
+
+ if (!isOpen || !organisation) return null;
+
+ const validateForm = (): boolean => {
+ const errors: ValidationError[] = [];
+
+ // Email validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!email) {
+ errors.push({ Path: 'Email', Message: 'Email is required' });
+ } else if (!emailRegex.test(email)) {
+ errors.push({ Path: 'Email', Message: 'Please enter a valid email address' });
+ }
+
+ setValidationErrors(errors);
+ return errors.length === 0;
+ };
+
+ const handleCreate = async () => {
+ // Clear previous errors
+ setValidationErrors([]);
+ setGeneralError('');
+
+ // Validate form
+ if (!validateForm()) {
+ errorToast.validation();
+ return;
+ }
+
+ const toastId = loadingToast.create('user');
+
+ try {
+ // Automatically create AuthClaims with OrgAdmin and AdminFor:organisationKey
+ const authClaims = [
+ ROLES.ORG_ADMIN,
+ `${ROLE_PREFIXES.ADMIN_FOR}${organisation.Key}`
+ ];
+
+ const userData = {
+ Email: email,
+ UserName: email.split('@')[0], // Use email prefix as username
+ AuthClaims: authClaims,
+ };
+
+ const response = await authenticatedFetch('/api/users', {
+ method: HTTP_METHODS.POST,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(userData),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to create user');
+ }
+
+ toastUtils.dismiss(toastId);
+ successToast.create('User');
+
+ // Reset form
+ setEmail('');
+ setValidationErrors([]);
+ setGeneralError('');
+
+ onSuccess();
+ onClose();
+ } catch (error) {
+ toastUtils.dismiss(toastId);
+ const errorMessage = error instanceof Error ? error.message : 'Failed to create user';
+ setGeneralError(errorMessage);
+ errorToast.create('user', errorMessage);
+ }
+ };
+
+ const handleClose = () => {
+ setEmail('');
+ setValidationErrors([]);
+ setGeneralError('');
+ onClose();
+ };
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
+
Add User to Organisation
+
{organisation.Name}
+
+
+
+
+
+
+ {/* Content - scrollable */}
+
+ {generalError && (
+
+ )}
+
+
+
+
+ Email *
+
+
setEmail(e.target.value)}
+ placeholder="user@example.com"
+ className={validationErrors.find(e => e.Path === 'Email') ? 'border-brand-g' : ''}
+ autoComplete="email"
+ />
+
+ User will be invited to create an account with this email
+
+
+
+
+
+ Role: Organisation Administrator
+
+
+ This user will automatically be assigned the Organisation Administrator role for {organisation.Name} and will have access to manage this organisation.
+
+
+
+
+ {validationErrors.length > 0 && (
+
+
+
+ )}
+
+
+ {/* Footer - fixed at bottom */}
+
+
+ Cancel
+
+
+ Add User
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/organisations/DisableOrganisationModal.tsx b/src/components/organisations/DisableOrganisationModal.tsx
new file mode 100644
index 0000000..0c3c027
--- /dev/null
+++ b/src/components/organisations/DisableOrganisationModal.tsx
@@ -0,0 +1,188 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { X } from 'lucide-react';
+
+interface DisableOrganisationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: (staffName: string, reason: string, disablingDate: Date) => void;
+ organisation: IOrganisation | null;
+}
+
+export const DisableOrganisationModal: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ organisation
+}) => {
+ const [staffName, setStaffName] = useState('');
+ const [reason, setReason] = useState('');
+ const [disablingDate, setDisablingDate] = useState('');
+ const [errors, setErrors] = useState<{ staffName?: string; reason?: string; disablingDate?: string }>({});
+
+ // Get today's date in YYYY-MM-DD format for min attribute
+ const getTodayString = () => {
+ const today = new Date();
+ return today.toISOString().split('T')[0];
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ // Reset form when modal opens
+ setStaffName('');
+ setReason('');
+ setDisablingDate(getTodayString()); // Default to today
+ setErrors({});
+ }
+ }, [isOpen]);
+
+ const validate = (): boolean => {
+ const newErrors: { staffName?: string; reason?: string; disablingDate?: string } = {};
+
+ if (!staffName.trim()) {
+ newErrors.staffName = 'Staff name is required';
+ }
+
+ if (!reason.trim()) {
+ newErrors.reason = 'Reason is required';
+ }
+
+ if (!disablingDate) {
+ newErrors.disablingDate = 'Disabling date is required';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (validate()) {
+ // Convert string date to Date object
+ const dateObj = new Date(disablingDate);
+ onConfirm(staffName.trim(), reason.trim(), dateObj);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
Disable Organisation
+
+
+
+
+
+ {/* Content - form wrapper */}
+
+ {/* Scrollable content */}
+
+
+ You are about to disable {organisation?.Name} . Please provide the following information:
+
+
+
+
+
+ Disabling Date *
+
+
setDisablingDate(e.target.value)}
+ min={getTodayString()}
+ className={errors.disablingDate ? 'border-brand-g' : ''}
+ />
+ {errors.disablingDate && (
+
{errors.disablingDate}
+ )}
+
+ Select today to disable immediately, or a future date to schedule disabling
+
+
+
+
+
+ Staff Name *
+
+
setStaffName(e.target.value)}
+ placeholder="Enter your name"
+ className={errors.staffName ? 'border-brand-g' : ''}
+ />
+ {errors.staffName && (
+
{errors.staffName}
+ )}
+
+
+
+
+ Reason for Disabling *
+
+
setReason(e.target.value)}
+ placeholder="Please provide a reason for disabling this organisation"
+ rows={4}
+ className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-a focus:border-brand-a text-brand-k ${
+ errors.reason ? 'border-brand-g' : 'border-brand-q'
+ }`}
+ />
+ {errors.reason && (
+ {errors.reason}
+ )}
+
+
+
+
+ {/* Footer - fixed at bottom */}
+
+
+
+ Cancel
+
+
+ Disable Organisation
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/organisations/EditOrganisationModal.tsx b/src/components/organisations/EditOrganisationModal.tsx
new file mode 100644
index 0000000..b2d9878
--- /dev/null
+++ b/src/components/organisations/EditOrganisationModal.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { X } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import OrganisationTab from './tabs/OrganisationTab';
+import ServicesTab from './tabs/ServicesTab';
+import AccommodationsTab from './tabs/AccommodationsTab';
+
+interface EditOrganisationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ organisation: IOrganisation;
+ onOrganisationUpdated: () => void;
+ viewMode?: boolean; // When true, all inputs are disabled and edit actions hidden
+}
+
+type TabType = 'organisation' | 'services' | 'accommodations';
+
+const EditOrganisationModal: React.FC = ({
+ isOpen,
+ onClose,
+ organisation,
+ onOrganisationUpdated,
+ viewMode = false
+}) => {
+ const [activeTab, setActiveTab] = useState('organisation');
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [showTabSwitchConfirm, setShowTabSwitchConfirm] = useState(false);
+ const [pendingTab, setPendingTab] = useState(null);
+
+ // Reset to first tab when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ setActiveTab('organisation');
+ }
+ }, [isOpen]);
+
+ const handleTabClick = (tab: TabType) => {
+ if (tab !== activeTab) {
+ // In view mode, switch tabs immediately without confirmation
+ if (viewMode) {
+ setActiveTab(tab);
+ } else {
+ setShowTabSwitchConfirm(true);
+ setPendingTab(tab);
+ }
+ }
+ };
+
+ const confirmTabSwitch = () => {
+ if (pendingTab) {
+ setActiveTab(pendingTab);
+ setPendingTab(null);
+ }
+ setShowTabSwitchConfirm(false);
+ };
+
+ const cancelTabSwitch = () => {
+ setPendingTab(null);
+ setShowTabSwitchConfirm(false);
+ };
+
+ const handleClose = () => {
+ setActiveTab('organisation');
+ onClose();
+ };
+
+ const confirmCancel = () => {
+ setShowConfirmModal(false);
+ handleClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
+ {viewMode ? 'View Organisation' : 'Edit Organisation'}
+
+ viewMode ? handleClose() : setShowConfirmModal(true)}
+ className="p-2"
+ title="Close"
+ >
+
+
+
+
+ {/* Tab Navigation */}
+
+
+ handleTabClick('organisation')}
+ className={`py-4 px-1 border-b-2 font-medium text-sm cursor-pointer ${
+ activeTab === 'organisation'
+ ? 'border-brand-a text-brand-a'
+ : 'border-transparent text-brand-f hover:text-brand-k hover:border-brand-q'
+ }`}
+ >
+ Organisation
+
+ handleTabClick('services')}
+ className={`py-4 px-1 border-b-2 font-medium text-sm cursor-pointer ${
+ activeTab === 'services'
+ ? 'border-brand-a text-brand-a'
+ : 'border-transparent text-brand-f hover:text-brand-k hover:border-brand-q'
+ }`}
+ >
+ Services
+
+ handleTabClick('accommodations')}
+ className={`py-4 px-1 border-b-2 font-medium text-sm cursor-pointer ${
+ activeTab === 'accommodations'
+ ? 'border-brand-a text-brand-a'
+ : 'border-transparent text-brand-f hover:text-brand-k hover:border-brand-q'
+ }`}
+ >
+ Accommodations
+
+
+
+
+ {/* Content - scrollable */}
+
+ {activeTab === 'organisation' && (
+
setShowConfirmModal(true)} // Show confirmation on manual cancel
+ viewMode={viewMode}
+ />
+ )}
+ {activeTab === 'services' && (
+
+ )}
+ {activeTab === 'accommodations' && (
+
+ )}
+
+
+
+
+ {/* Close Confirmation Modal */}
+ setShowConfirmModal(false)}
+ onConfirm={confirmCancel}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
+
+ {/* Tab Switch Confirmation Modal */}
+
+ >
+ );
+};
+
+export default EditOrganisationModal;
diff --git a/src/components/organisations/LocationManager.tsx b/src/components/organisations/LocationManager.tsx
new file mode 100644
index 0000000..f183132
--- /dev/null
+++ b/src/components/organisations/LocationManager.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+import { useState } from 'react';
+import { Plus, Edit, Trash } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { IAddressFormData } from '@/types/organisations/IOrganisation';
+import { ValidationError } from '@/components/ui/ErrorDisplay';
+import { AddLocationModal } from './AddLocationModal';
+
+interface LocationManagerProps {
+ locations: IAddressFormData[];
+ onChange: (locations: IAddressFormData[]) => void;
+ validationErrors?: ValidationError[];
+ viewMode?: boolean; // When true, hide add/edit/delete actions
+}
+
+export function LocationManager({ locations, onChange, validationErrors = [], viewMode = false }: LocationManagerProps) {
+ const [showModal, setShowModal] = useState(false);
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [editingLocation, setEditingLocation] = useState(null);
+
+ const handleAddLocation = () => {
+ setEditingLocation(null);
+ setEditingIndex(null);
+ setShowModal(true);
+ };
+
+ const handleSaveLocation = (location: IAddressFormData) => {
+ if (editingIndex !== null) {
+ const updated = [...locations];
+ updated[editingIndex] = location;
+ onChange(updated);
+ } else {
+ onChange([...locations, location]);
+ }
+
+ setEditingIndex(null);
+ setEditingLocation(null);
+ };
+
+ const handleEdit = (index: number) => {
+ setEditingLocation({ ...locations[index] });
+ setEditingIndex(index);
+ setShowModal(true);
+ };
+
+ const handleView = (index: number) => {
+ setEditingLocation({ ...locations[index] });
+ setEditingIndex(index);
+ setShowModal(true);
+ };
+
+ const handleRemove = (index: number) => {
+ const updated = locations.filter((_, i) => i !== index);
+ onChange(updated);
+ };
+
+ const handleCloseModal = () => {
+ setShowModal(false);
+ setEditingIndex(null);
+ setEditingLocation(null);
+ };
+
+ const formatLocationDisplay = (location: IAddressFormData): string => {
+ const parts = [
+ location.Street,
+ location.Street1,
+ location.Street2,
+ location.Street3,
+ location.City,
+ location.Postcode
+ ].filter(Boolean);
+
+ return parts.join(', ');
+ };
+
+ return (
+
+
+
Locations
+ {!viewMode && (
+
+
+ Add Location
+
+ )}
+
+
+ {/* Locations List */}
+ {locations.length > 0 && (
+
+
Added Locations:
+ {locations.map((location, index) => (
+
+
+
+ {formatLocationDisplay(location)}
+
+ {location.Telephone && (
+
+ Tel: {location.Telephone}
+
+ )}
+
+ {location.IsOpen247 && (
+
+ 24/7
+
+ )}
+ {location.IsAppointmentOnly && (
+
+ Appointment Only
+
+ )}
+ {!location.IsOpen247 && location.OpeningTimes.length > 0 && (
+
+ {location.OpeningTimes.length} Opening Times
+
+ )}
+
+
+
+ {viewMode ? (
+ handleView(index)}
+ title="View"
+ >
+ View
+
+ ) : (
+ <>
+ handleEdit(index)}
+ className="p-2"
+ title="Edit"
+ >
+
+
+ handleRemove(index)}
+ className="p-2 text-brand-g border-brand-g hover:bg-brand-g hover:text-white"
+ title="Delete"
+ >
+
+
+ >
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Add Location Modal */}
+
+
+ {locations.length === 0 && (
+
+
No locations added yet
+
Click "Add Location" to get started
+
+ )}
+
+ );
+}
diff --git a/src/components/organisations/NotesModal.tsx b/src/components/organisations/NotesModal.tsx
new file mode 100644
index 0000000..e68456b
--- /dev/null
+++ b/src/components/organisations/NotesModal.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import React from 'react';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import { Button } from '@/components/ui/Button';
+import { X, Calendar, User } from 'lucide-react';
+
+interface NotesModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onClearNotes: () => void;
+ organisation: IOrganisation | null;
+}
+
+export const NotesModal: React.FC = ({
+ isOpen,
+ onClose,
+ onClearNotes,
+ organisation
+}) => {
+ if (!isOpen || !organisation) return null;
+
+ const formatDate = (date: Date | string): string => {
+ const d = new Date(date);
+ return d.toLocaleDateString('en-GB', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric'
+ });
+ };
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
+
Notes
+
{organisation.Name}
+
+
+
+
+
+
+ {/* Content - scrollable */}
+
+ {organisation?.Notes?.length === 0 ? (
+
+
No notes available for this organisation.
+
+ ) : (
+
+ {organisation.Notes.map((note, index) => (
+
+
+
+
+ {note.StaffName}
+
+
+
+ {formatDate(note.Date)}
+
+
+
{note.Reason}
+
+ ))}
+
+ )}
+
+
+ {/* Footer - fixed at bottom */}
+
+
+ Close
+
+ {organisation?.Notes?.length > 0 && (
+
+ Clear All Notes
+
+ )}
+
+
+
+ >
+ );
+};
diff --git a/src/components/organisations/OpeningTimesManager.tsx b/src/components/organisations/OpeningTimesManager.tsx
new file mode 100644
index 0000000..a50d922
--- /dev/null
+++ b/src/components/organisations/OpeningTimesManager.tsx
@@ -0,0 +1,243 @@
+'use client';
+
+import { useState } from 'react';
+import { Plus, Trash, Edit, Copy } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { Select } from '@/components/ui/Select';
+import { IOpeningTimeFormData, DAYS_OF_WEEK } from '@/types/organisations/IOrganisation';
+
+interface OpeningTimesManagerProps {
+ openingTimes: IOpeningTimeFormData[];
+ onChange: (openingTimes: IOpeningTimeFormData[]) => void;
+ viewMode?: boolean; // When true, hide add/edit/delete actions
+}
+
+export function OpeningTimesManager({ openingTimes, onChange, viewMode = false }: OpeningTimesManagerProps) {
+ const [showForm, setShowForm] = useState(false);
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [formData, setFormData] = useState({
+ Day: 1, // Monday
+ StartTime: '09:00',
+ EndTime: '17:00'
+ });
+
+ const handleAdd = () => {
+ if (editingIndex !== null) {
+ // Update existing opening time
+ const updated = [...openingTimes];
+ updated[editingIndex] = { ...formData };
+ onChange(updated);
+ setEditingIndex(null);
+ } else {
+ // Add new opening time
+ onChange([...openingTimes, { ...formData }]);
+ }
+
+ // Reset form
+ setFormData({
+ Day: 1,
+ StartTime: '09:00',
+ EndTime: '17:00'
+ });
+ setShowForm(false);
+ };
+
+ const handleRemove = (index: number) => {
+ const updated = openingTimes.filter((_, i) => i !== index);
+ onChange(updated);
+ };
+
+ const handleEdit = (index: number) => {
+ setFormData({ ...openingTimes[index] });
+ setEditingIndex(index);
+ setShowForm(true);
+ };
+
+ const handleDuplicate = (index: number) => {
+ const itemToDuplicate = openingTimes[index];
+ onChange([...openingTimes, { ...itemToDuplicate }]);
+ };
+
+ const handleCancel = () => {
+ setShowForm(false);
+ setEditingIndex(null);
+ setFormData({
+ Day: 1,
+ StartTime: '09:00',
+ EndTime: '17:00'
+ });
+ };
+
+ const getDayLabel = (dayNumber: number) => {
+ return DAYS_OF_WEEK.find(d => d.value === dayNumber)?.label || 'Unknown';
+ };
+
+ const formatTime = (time: string) => {
+ // Convert 24-hour format (HH:MM) to 12-hour format with AM/PM
+ const [hours, minutes] = time.split(':').map(Number);
+ const period = hours >= 12 ? 'PM' : 'AM';
+ const displayHours = hours % 12 || 12; // Convert 0 to 12 for midnight
+ return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
+ };
+
+ return (
+
+ {!viewMode && (
+
+
setShowForm(true)}
+ className="flex items-center gap-2"
+ >
+
+ Add Opening Time
+
+
+ )}
+
+ {/* Opening Times List */}
+ {openingTimes.length > 0 && (
+
+ {openingTimes.map((time, index) => (
+
+
+
+ {getDayLabel(time.Day)}
+
+
+ {formatTime(time.StartTime)} - {formatTime(time.EndTime)}
+
+
+ {!viewMode && (
+
+ handleEdit(index)}
+ className="p-2"
+ title="Edit"
+ >
+
+
+ handleDuplicate(index)}
+ className="p-2"
+ title="Duplicate"
+ >
+
+
+ handleRemove(index)}
+ className="p-2 text-brand-g border-brand-g hover:bg-brand-g hover:text-white"
+ title="Remove"
+ >
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Add/Edit Opening Time Form */}
+ {showForm && (
+
+
+ {editingIndex !== null ? 'Edit Opening Time' : 'Add Opening Time'}
+
+
+
+
+
+ Day of Week
+
+ setFormData({
+ ...formData,
+ Day: parseInt(e.target.value)
+ })}
+ options={DAYS_OF_WEEK.map(day => ({
+ value: day.value.toString(),
+ label: day.label
+ }))}
+ />
+
+
+
+
+ Opening Time
+
+ setFormData({
+ ...formData,
+ StartTime: e.target.value
+ })}
+ className="w-full"
+ />
+
+
+
+
+ Closing Time
+
+ setFormData({
+ ...formData,
+ EndTime: e.target.value
+ })}
+ className="w-full"
+ />
+
+
+
+
+
+ Cancel
+
+
+ {editingIndex !== null ? 'Update Opening Time' : 'Add Opening Time'}
+
+
+
+ )}
+
+ {openingTimes.length === 0 && !showForm && (
+
+
No opening times added yet
+
Click "Add Opening Time" to get started
+
+ )}
+
+ );
+}
diff --git a/src/components/organisations/OrganisationCard.tsx b/src/components/organisations/OrganisationCard.tsx
new file mode 100644
index 0000000..6a123f9
--- /dev/null
+++ b/src/components/organisations/OrganisationCard.tsx
@@ -0,0 +1,307 @@
+'use client';
+
+import React from 'react';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import { Button } from '@/components/ui/Button';
+import { Edit, Calendar, MapPin, Eye, CheckCircle, XCircle, UserPlus, FileText } from 'lucide-react';
+import { decodeText } from '@/utils/htmlDecode';
+
+interface OrganisationCardProps {
+ organisation: IOrganisation;
+ isLoading?: boolean;
+ onView?: (organisation: IOrganisation) => void;
+ onEdit?: (organisation: IOrganisation) => void;
+ onTogglePublished?: (organisation: IOrganisation) => void;
+ onToggleVerified?: (organisation: IOrganisation) => void;
+ onAddUser?: (organisation: IOrganisation) => void;
+ onViewNotes?: (organisation: IOrganisation) => void;
+ onDisableClick?: (organisation: IOrganisation) => void;
+ isTogglingPublish?: boolean;
+ isTogglingVerify?: boolean;
+ isOrgAdmin?: boolean;
+}
+
+const OrganisationCard = React.memo(function OrganisationCard({
+ organisation,
+ isLoading = false,
+ onView,
+ onEdit,
+ onTogglePublished,
+ onToggleVerified,
+ onAddUser,
+ onViewNotes,
+ onDisableClick,
+ isTogglingPublish = false,
+ isTogglingVerify = false,
+ isOrgAdmin = false
+}: OrganisationCardProps) {
+
+ const formatDate = (date: Date | string): string => {
+ const d = new Date(date);
+ return d.toLocaleDateString('en-GB', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric'
+ });
+ };
+
+ // Calculate days since last update
+ const daysSinceUpdate = React.useMemo(() => {
+ if (!organisation.DocumentModifiedDate) return 0;
+ const lastUpdate = new Date(organisation.DocumentModifiedDate);
+ const today = new Date();
+ return Math.floor((today.getTime() - lastUpdate.getTime()) / (1000 * 60 * 60 * 24));
+ }, [organisation.DocumentModifiedDate]);
+
+ // Get warning level
+ const getWarningLevel = () => {
+ if (daysSinceUpdate >= 100) return 'expired';
+ if (daysSinceUpdate >= 90) return 'warning';
+ if (daysSinceUpdate >= 75) return 'info';
+ return 'ok';
+ };
+
+ const warningLevel = getWarningLevel();
+
+ const handleView = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (onView) {
+ onView(organisation);
+ }
+ };
+
+ const handleEdit = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (onEdit) {
+ onEdit(organisation);
+ }
+ };
+
+
+ const handleTogglePublished = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Show disable modal only when disabling
+ if (organisation.IsPublished) {
+ onDisableClick?.(organisation);
+ } else {
+ // Publish without confirmation
+ onTogglePublished?.(organisation);
+ }
+ };
+
+ const handleToggleVerified = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (onToggleVerified) {
+ onToggleVerified(organisation);
+ }
+ };
+
+ const handleAddUser = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (onAddUser) {
+ onAddUser(organisation);
+ }
+ };
+
+ const handleViewNotes = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (onViewNotes) {
+ onViewNotes(organisation);
+ }
+ };
+
+ // Format locations - show first 3 and then "..."
+ const displayLocations = organisation.AssociatedLocationIds.slice(0, 3);
+ const hasMoreLocations = organisation.AssociatedLocationIds.length > 3;
+
+ return (
+
+ {/* Organisation Header */}
+
+ {/* Action Buttons - First Row */}
+
+
+
+ View
+
+
+
+
+
+
+
+ {/* Action Buttons - Second Row */}
+ {/* Hide publish button for OrgAdmin-only users */}
+
+ {!isOrgAdmin && (
+
+ {isTogglingPublish ? (
+
+ ) : organisation.IsPublished ? (
+
+ ) : (
+
+ )}
+ {organisation.IsPublished ? 'Disable' : 'Publish'}
+
+ )}
+
+ {/* Hide verify button for OrgAdmin-only users */}
+ {!isOrgAdmin && (
+
+ {isTogglingVerify ? (
+
+ ) : organisation.IsVerified ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ {organisation?.Notes?.length > 0 && (
+ {organisation.Notes.length}
+ )}
+
+
+
+ {/* Header */}
+
+
{decodeText(organisation.Name)}
+ {/* Status Badges on separate line */}
+
+
+ {organisation.IsVerified ? 'Verified' : 'Under Review'}
+
+
+ {organisation.IsPublished ? 'Published' : 'Disabled'}
+
+
+
+
+ {/* Verification Warning Messages */}
+ {warningLevel !== 'ok' && (
+
+
+ {warningLevel === 'expired' && (
+ <>⚠️ Unverified - No update for {daysSinceUpdate} days>
+ )}
+ {warningLevel === 'warning' && (
+ <>⚠️ Reminder sent - {100 - daysSinceUpdate} days until unverified>
+ )}
+ {warningLevel === 'info' && (
+ <>ℹ️ Review due soon - {daysSinceUpdate} days since last update>
+ )}
+
+
+ )}
+
+ {/* Short Description */}
+ {organisation.ShortDescription && (
+
+ {decodeText(organisation.ShortDescription)}
+
+ )}
+
+ {/* Locations */}
+ {organisation.AssociatedLocationIds.length > 0 && (
+
+
+
+ Locations
+
+
+ {displayLocations.map((location, index) => (
+
+ {location}
+
+ ))}
+ {hasMoreLocations && (
+
+ ... +{organisation.AssociatedLocationIds.length - 3} more
+
+ )}
+
+
+ )}
+
+ {/* Created/Modified Dates */}
+
+
+
+ Created: {formatDate(organisation.DocumentCreationDate)}
+
+ {organisation.DocumentModifiedDate && (
+
+
+ Modified: {formatDate(organisation.DocumentModifiedDate)}
+
+ )}
+
+
+
+ );
+});
+
+export default OrganisationCard;
diff --git a/src/components/organisations/OrganisationForm.tsx b/src/components/organisations/OrganisationForm.tsx
new file mode 100644
index 0000000..e378167
--- /dev/null
+++ b/src/components/organisations/OrganisationForm.tsx
@@ -0,0 +1,394 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import { Input } from '@/components/ui/Input';
+import { Textarea } from '@/components/ui/Textarea';
+import { Checkbox } from '@/components/ui/Checkbox';
+import { MultiSelect } from '@/components/ui/MultiSelect';
+import { IOrganisationFormData, IAddressFormData, ORGANISATION_TAGS } from '@/types/organisations/IOrganisation';
+import { ICity } from '@/types';
+import { validateOrganisation, transformErrorPath } from '@/schemas/organisationSchema';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { errorToast } from '@/utils/toast';
+import { ValidationError } from '@/components/ui/ErrorDisplay';
+import { LocationManager } from './LocationManager';
+
+
+export interface OrganisationFormRef {
+ validate: () => boolean;
+ getFormData: () => IOrganisationFormData;
+ resetForm: () => void;
+}
+
+interface OrganisationFormProps {
+ initialData?: Partial;
+ onFormDataChange?: (formData: IOrganisationFormData, isValid: boolean) => void;
+ onValidationChange?: (errors: ValidationError[]) => void;
+ viewMode?: boolean; // When true, all inputs are disabled (read-only)
+}
+
+export const OrganisationForm = React.forwardRef(({
+ initialData,
+ onFormDataChange,
+ onValidationChange,
+ viewMode = false
+}, ref) => {
+ const [locations, setLocations] = useState([]);
+ const [validationErrors, setValidationErrors] = useState([]);
+
+ const [formData, setFormData] = useState({
+ Key: '',
+ AssociatedLocationIds: [],
+ Name: '',
+ ShortDescription: '',
+ Description: '',
+ Tags: [],
+ IsVerified: false,
+ IsPublished: false,
+ Telephone: '',
+ Email: '',
+ Website: '',
+ Facebook: '',
+ Twitter: '',
+ Bluesky: '',
+ Addresses: [],
+ Administrators: [],
+ ...initialData
+ });
+
+ // Fetch locations on mount
+ useEffect(() => {
+ fetchLocations();
+ }, []);
+
+ // Memoize the validation function to prevent unnecessary re-renders
+ const validateFormMemoized = useCallback((setErrors: boolean = true): boolean => {
+ const result = validateOrganisation(formData);
+
+ const allErrors = [];
+
+ if (!result.success) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const organisationErrors = result.errors.map((error: any) => {
+ const originalPath = Array.isArray(error.path) ? error.path.join('.') : error.path;
+ return {
+ Path: transformErrorPath(originalPath),
+ Message: error.message
+ };
+ });
+ allErrors.push(...organisationErrors);
+ }
+
+ if (setErrors) {
+ setValidationErrors(allErrors);
+ }
+
+ return allErrors.length === 0;
+ }, [formData]);
+
+ // Update parent when form data changes
+ useEffect(() => {
+ if (onFormDataChange) {
+ const isValid = validateFormMemoized(false); // Don't set errors, just check validity
+ onFormDataChange(formData, isValid);
+ }
+ }, [formData, onFormDataChange, validateFormMemoized]);
+
+ // Update parent when validation errors change
+ useEffect(() => {
+ if (onValidationChange) {
+ onValidationChange(validationErrors);
+ }
+ }, [validationErrors, onValidationChange]);
+
+ const fetchLocations = async () => {
+ try {
+ const response = await authenticatedFetch('/api/cities');
+
+ if (response.ok) {
+ const data = await response.json();
+ setLocations(data.data || []);
+ }
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to fetch locations');
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch locations';
+ errorToast.generic(errorMessage);
+ }
+ };
+
+ const generateOrganisationKey = (name: string): string => {
+ return name.toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
+ .trim();
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const updateFormData = (field: keyof IOrganisationFormData, value: any) => {
+ setFormData(prev => ({
+ ...prev,
+ [field]: value,
+ // Auto-generate key when name changes ONLY if creating new organisation (no initial Key)
+ ...(field === 'Name' && !initialData?.Key && { Key: generateOrganisationKey(value) })
+ }));
+ };
+
+ const handleTagChange = (tagValue: string, checked: boolean) => {
+ const updatedTags = checked
+ ? [...formData.Tags, tagValue]
+ : formData.Tags.filter(tag => tag !== tagValue);
+
+ updateFormData('Tags', updatedTags);
+ };
+
+ const handleAddressesChange = (addresses: IAddressFormData[]) => {
+ updateFormData('Addresses', addresses);
+ };
+
+ // Public method to validate form
+ const validate = () => {
+ return validateFormMemoized(true);
+ };
+
+ // Public method to get form data
+ const getFormData = () => {
+ return formData;
+ };
+
+ // Public method to reset form
+ const resetForm = () => {
+ setFormData({
+ Key: '',
+ AssociatedLocationIds: [],
+ Name: '',
+ ShortDescription: '',
+ Description: '',
+ Tags: [],
+ IsVerified: false,
+ IsPublished: false,
+ // Telephone: '',
+ // Email: '',
+ // Website: '',
+ // Facebook: '',
+ // Twitter: '',
+ Addresses: [],
+ Administrators: [],
+ ...initialData
+ });
+ setValidationErrors([]);
+ };
+
+ // Expose methods to parent via ref
+ React.useImperativeHandle(ref, () => ({
+ validate,
+ getFormData,
+ resetForm
+ }));
+
+ // Filter tags based on selected locations
+ const getAvailableTags = () => {
+ // AssociatedLocationIds contains location Keys (not _id)
+ const hasManchesterLocation = formData.AssociatedLocationIds.includes('manchester');
+
+ return ORGANISATION_TAGS.filter(tag => {
+ // Show mcr-only tags only if manchester is selected
+ if (tag.value === 'coalition-of-relief' || tag.value === 'big-change') {
+ return hasManchesterLocation;
+ }
+ return true;
+ });
+ };
+
+ return (
+
+ {/* General Details Section */}
+
+
General Details
+
+
+
+
+ Name *
+
+ updateFormData('Name', e.target.value)}
+ placeholder={viewMode ? '' : 'Organisation name'}
+ disabled={viewMode}
+ />
+
+
+
+
+ Associated Locations *
+
+ ({
+ value: location.Key,
+ label: location.Name
+ })) || []}
+ value={formData.AssociatedLocationIds || []}
+ onChange={(selectedIds) => updateFormData('AssociatedLocationIds', selectedIds)}
+ placeholder={viewMode ? '' : (locations.length === 0 ? "Loading locations..." : "Select locations...")}
+ disabled={viewMode}
+ />
+
+
+
+
+ Short Description *
+
+ updateFormData('ShortDescription', e.target.value)}
+ placeholder={viewMode ? '' : 'Short description'}
+ rows={2}
+ disabled={viewMode}
+ />
+
+
+
+
+ Description *
+
+ updateFormData('Description', e.target.value)}
+ placeholder={viewMode ? '' : 'Detailed description of the organisation'}
+ rows={4}
+ disabled={viewMode}
+ />
+
+
+
+ {/* Tags Section */}
+
+
Tags
+
+ {getAvailableTags().map((tag) => (
+ handleTagChange(tag.value, e.target.checked)}
+ label={tag.label}
+ disabled={viewMode}
+ />
+ ))}
+
+
+
+
+ {/* Contact Information Section */}
+
+
Contact Information
+
+
+
+
+ {/* Locations Section */}
+
+
+
+
+ );
+});
+
+OrganisationForm.displayName = 'OrganisationForm';
diff --git a/src/components/organisations/accommodation-sections/ContactDetailsSection.tsx b/src/components/organisations/accommodation-sections/ContactDetailsSection.tsx
new file mode 100644
index 0000000..4d55cd0
--- /dev/null
+++ b/src/components/organisations/accommodation-sections/ContactDetailsSection.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { FormField } from '@/components/ui/FormField';
+import { IAccommodationFormData } from '@/types';
+
+interface ContactDetailsSectionProps {
+ formData: IAccommodationFormData['ContactInformation'];
+ onChange: (field: string, value: string | boolean | number) => void;
+ viewMode?: boolean;
+}
+
+export function ContactDetailsSection({ formData, onChange, viewMode = false }: ContactDetailsSectionProps) {
+ return (
+
+
+ onChange('ContactInformation.Name', e.target.value)}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Enter contact person name'}
+ disabled={viewMode}
+ />
+
+
+
+ onChange('ContactInformation.Email', e.target.value)}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'contact@example.com'}
+ disabled={viewMode}
+ />
+
+
+
+ onChange('ContactInformation.Telephone', e.target.value)}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Telephone number'}
+ disabled={viewMode}
+ />
+
+
+
+ onChange('ContactInformation.AdditionalInfo', e.target.value)}
+ rows={3}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Any additional contact information'}
+ disabled={viewMode}
+ />
+
+
+ );
+}
diff --git a/src/components/organisations/accommodation-sections/FeaturesSection.tsx b/src/components/organisations/accommodation-sections/FeaturesSection.tsx
new file mode 100644
index 0000000..9eddd94
--- /dev/null
+++ b/src/components/organisations/accommodation-sections/FeaturesSection.tsx
@@ -0,0 +1,162 @@
+'use client';
+
+import { FormField } from '@/components/ui/FormField';
+import { IAccommodationFormData, DISCRETIONARY_OPTIONS, DiscretionaryValue } from '@/types/organisations/IAccommodation';
+
+interface FeaturesSectionProps {
+ formData?: IAccommodationFormData['FeaturesWithDiscretionary'] | null;
+ onChange: (field: string, value: string | boolean | number) => void;
+ errors: Record;
+ viewMode?: boolean;
+}
+
+interface FeatureRowProps {
+ label: string;
+ field: string;
+ value: number | undefined;
+ onChange: (field: string, value: number) => void;
+ disabled?: boolean;
+}
+
+function FeatureRow({ label, field, value, onChange, disabled = false }: FeatureRowProps) {
+ return (
+
+ {label}
+ onChange(field, parseInt(e.target.value))}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ disabled={disabled}
+ >
+ {DISCRETIONARY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+}
+
+export function FeaturesSection({ formData, onChange, errors, viewMode = false }: FeaturesSectionProps) {
+ const safeFormData = (formData ?? {}) as IAccommodationFormData['FeaturesWithDiscretionary'];
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onChange('FeaturesWithDiscretionary.AdditionalFeatures', e.target.value)}
+ rows={3}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Describe any additional features (optional)'}
+ disabled={viewMode}
+ />
+
+
+ );
+}
+
diff --git a/src/components/organisations/accommodation-sections/GeneralInfoSection.tsx b/src/components/organisations/accommodation-sections/GeneralInfoSection.tsx
new file mode 100644
index 0000000..b30e459
--- /dev/null
+++ b/src/components/organisations/accommodation-sections/GeneralInfoSection.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { FormField } from '@/components/ui/FormField';
+import { Select } from '@/components/ui/Select';
+import { ACCOMMODATION_TYPES } from '@/types/organisations/IAccommodation';
+import { IAccommodationFormData } from '@/types';
+
+interface GeneralInfoSectionProps {
+ formData: IAccommodationFormData['GeneralInfo'];
+ onChange: (field: string, value: string | boolean | number) => void;
+ viewMode?: boolean;
+}
+
+export function GeneralInfoSection({ formData, onChange, viewMode = false }: GeneralInfoSectionProps) {
+ return (
+
+
+ onChange('GeneralInfo.Name', e.target.value)}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Accommodation name'}
+ disabled={viewMode}
+ />
+
+
+
+ onChange('GeneralInfo.AccommodationType', e.target.value)}
+ placeholder={viewMode ? '' : 'Select accommodation type'}
+ disabled={viewMode}
+ />
+
+
+
+ onChange('GeneralInfo.Synopsis', e.target.value)}
+ rows={2}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Short description'}
+ disabled={viewMode}
+ />
+
+
+
+ onChange('GeneralInfo.Description', e.target.value)}
+ rows={4}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Detailed description of the accommodation'}
+ disabled={viewMode}
+ />
+
+
+
+ onChange('GeneralInfo.IsOpenAccess', e.target.checked)}
+ className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ disabled={viewMode}
+ />
+
+ Open Access (No referral required)
+
+
+
+ );
+}
diff --git a/src/components/organisations/accommodation-sections/LocationSection.tsx b/src/components/organisations/accommodation-sections/LocationSection.tsx
new file mode 100644
index 0000000..acf2e77
--- /dev/null
+++ b/src/components/organisations/accommodation-sections/LocationSection.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import { FormField } from '@/components/ui/FormField';
+import { IAccommodationFormData } from '@/types';
+
+interface LocationSectionProps {
+ formData: IAccommodationFormData['Address'];
+ onChange: (field: string, value: string | boolean | number) => void;
+ availableCities: Array<{ _id: string; Name: string; Key: string }>;
+ viewMode?: boolean;
+}
+
+export function LocationSection({ formData, onChange, availableCities, viewMode = false }: LocationSectionProps) {
+ return (
+
+ );
+}
diff --git a/src/components/organisations/accommodation-sections/PricingSection.tsx b/src/components/organisations/accommodation-sections/PricingSection.tsx
new file mode 100644
index 0000000..ee100e8
--- /dev/null
+++ b/src/components/organisations/accommodation-sections/PricingSection.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { FormField } from '@/components/ui/FormField';
+import { IAccommodationFormData, DISCRETIONARY_OPTIONS } from '@/types/organisations/IAccommodation';
+
+interface PricingSectionProps {
+ formData: IAccommodationFormData['PricingAndRequirementsInfo'];
+ onChange: (field: string, value: string | boolean | number) => void;
+ errors: Record;
+ viewMode?: boolean;
+}
+
+export function PricingSection({ formData, onChange, errors, viewMode = false }: PricingSectionProps) {
+ return (
+
+
+ onChange('PricingAndRequirementsInfo.ReferralIsRequired', e.target.checked)}
+ className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ disabled={viewMode}
+ />
+
+ Referral Required
+
+
+
+ {formData.ReferralIsRequired && (
+
+
+ Please describe how someone gets a referral – e.g. "ask your key worker" or "speak to the council's housing team"
+
+
+ onChange('PricingAndRequirementsInfo.ReferralNotes', e.target.value)}
+ rows={3}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Provide details about referral requirements'}
+ disabled={viewMode}
+ />
+
+
+ )}
+
+
+ onChange('PricingAndRequirementsInfo.Price', e.target.value)}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ disabled={viewMode}
+ />
+
+
+
+ onChange('PricingAndRequirementsInfo.FoodIsIncluded', parseInt(e.target.value))}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ disabled={viewMode}
+ >
+ {DISCRETIONARY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ onChange('PricingAndRequirementsInfo.AvailabilityOfMeals', e.target.value)}
+ rows={3}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Describe meal availability'}
+ disabled={viewMode}
+ />
+
+
+ );
+}
diff --git a/src/components/organisations/accommodation-sections/SuitableForSection.tsx b/src/components/organisations/accommodation-sections/SuitableForSection.tsx
new file mode 100644
index 0000000..6d01f04
--- /dev/null
+++ b/src/components/organisations/accommodation-sections/SuitableForSection.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { IAccommodationFormData } from "@/types";
+
+interface SuitableForSectionProps {
+ formData?: IAccommodationFormData['ResidentCriteriaInfo'] | null;
+ onChange: (field: string, value: string | boolean | number) => void;
+ viewMode?: boolean;
+}
+
+interface CheckboxRowProps {
+ label: string;
+ field: string;
+ value: boolean | undefined;
+ onChange: (field: string, value: boolean) => void;
+ disabled?: boolean;
+}
+
+function CheckboxRow({ label, field, value, onChange, disabled = false }: CheckboxRowProps) {
+ return (
+
+ onChange(field, e.target.checked)}
+ className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ disabled={disabled}
+ />
+
+ {label}
+
+
+ );
+}
+
+export function SuitableForSection({ formData, onChange, viewMode = false }: SuitableForSectionProps) {
+ const safeFormData = (formData ?? {}) as IAccommodationFormData['ResidentCriteriaInfo'];
+ return (
+
+
+
Resident Criteria
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/components/organisations/accommodation-sections/SupportSection.tsx b/src/components/organisations/accommodation-sections/SupportSection.tsx
new file mode 100644
index 0000000..9dd5934
--- /dev/null
+++ b/src/components/organisations/accommodation-sections/SupportSection.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { FormField } from '@/components/ui/FormField';
+import { IAccommodationFormData, SUPPORT_OFFERED_OPTIONS, DISCRETIONARY_OPTIONS, DiscretionaryValue, SupportOfferedType } from '@/types/organisations/IAccommodation';
+
+interface SupportSectionProps {
+ formData?: IAccommodationFormData['SupportProvidedInfo'] | null;
+ onChange: (field: string, value: string | boolean | number | string[]) => void;
+ viewMode?: boolean;
+}
+
+export function SupportSection({ formData, onChange, viewMode = false }: SupportSectionProps) {
+ const safeFormData = (formData ?? {}) as IAccommodationFormData['SupportProvidedInfo'];
+
+ const handleSupportToggle = (supportValue: SupportOfferedType) => {
+ const currentSupport = safeFormData.SupportOffered || [];
+ const newSupport = currentSupport.includes(supportValue)
+ ? currentSupport.filter(s => s !== supportValue)
+ : [...currentSupport, supportValue];
+ onChange('SupportOffered', newSupport);
+ onChange('SupportProvidedInfo.SupportOffered', newSupport);
+ };
+
+ return (
+
+
+
+ {SUPPORT_OFFERED_OPTIONS.map((option) => (
+
+ handleSupportToggle(option.value)}
+ className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ disabled={viewMode}
+ />
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+ onChange('SupportProvidedInfo.SupportInfo', e.target.value)}
+ rows={4}
+ className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
+ placeholder={viewMode ? '' : 'Provide details about support services available'}
+ disabled={viewMode}
+ />
+
+
+ );
+}
+
diff --git a/src/components/organisations/sections/AdminDetailsSection.tsx b/src/components/organisations/sections/AdminDetailsSection.tsx
new file mode 100644
index 0000000..5acefa1
--- /dev/null
+++ b/src/components/organisations/sections/AdminDetailsSection.tsx
@@ -0,0 +1,236 @@
+'use client';
+
+import React, { useState } from 'react';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import { Button } from '@/components/ui/Button';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { errorToast } from '@/utils/toast';
+import toast from 'react-hot-toast';
+
+interface AdminDetailsSectionProps {
+ organisation: IOrganisation;
+ onUpdate?: (updatedOrg: IOrganisation) => void;
+}
+
+export function AdminDetailsSection({
+ organisation,
+ onUpdate,
+}: AdminDetailsSectionProps) {
+ const [selectedEmail, setSelectedEmail] = useState(
+ organisation.Administrators?.find(admin => admin.IsSelected)?.Email || ''
+ );
+
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [isUpdatingAdmin, setIsUpdatingAdmin] = useState(false);
+ const [localAdministrators, setLocalAdministrators] = useState(organisation.Administrators || []);
+
+ // Sync local administrators and selectedEmail when organisation updates
+ React.useEffect(() => {
+ if (organisation.Administrators && organisation.Administrators.length > 0) {
+ setLocalAdministrators(organisation.Administrators);
+ const currentlySelected = organisation.Administrators.find(admin => admin.IsSelected)?.Email || '';
+ if (currentlySelected) {
+ setSelectedEmail(currentlySelected);
+ }
+ }
+ }, [organisation.Administrators]);
+
+ // Calculate days since last update
+ const daysSinceUpdate = React.useMemo(() => {
+ if (!organisation.DocumentModifiedDate) return 0;
+ const lastUpdate = new Date(organisation.DocumentModifiedDate);
+ const today = new Date();
+ return Math.floor((today.getTime() - lastUpdate.getTime()) / (1000 * 60 * 60 * 24));
+ }, [organisation.DocumentModifiedDate]);
+
+ // Get warning level based on days
+ const getWarningLevel = () => {
+ if (daysSinceUpdate >= 100) return 'expired';
+ if (daysSinceUpdate >= 90) return 'warning';
+ if (daysSinceUpdate >= 75) return 'info';
+ return 'ok';
+ };
+
+ const warningLevel = getWarningLevel();
+
+ const handleAdministratorChange = async (email: string) => {
+ if (!email || email === selectedEmail) return;
+
+ setIsUpdatingAdmin(true);
+ try {
+ const response = await authenticatedFetch(
+ `/api/organisations/${organisation._id}/administrator`,
+ {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ selectedEmail: email })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to update administrator');
+ }
+
+ const updatedOrg = await response.json();
+
+ // Update local administrators state with new selection
+ const updatedAdmins = localAdministrators.map(admin => ({
+ ...admin,
+ IsSelected: admin.Email === email
+ }));
+ setLocalAdministrators(updatedAdmins);
+ setSelectedEmail(email);
+
+ // If API response includes updated Administrators, use that instead
+ if (updatedOrg.Administrators && updatedOrg.Administrators.length > 0) {
+ setLocalAdministrators(updatedOrg.Administrators);
+ }
+
+ if (onUpdate) {
+ // Merge updated administrators into the org object
+ onUpdate({
+ ...updatedOrg,
+ Administrators: updatedOrg.Administrators && updatedOrg.Administrators.length > 0
+ ? updatedOrg.Administrators
+ : updatedAdmins
+ });
+ }
+
+ toast.success('Administrator updated successfully');
+ } catch (error) {
+ console.error('Error updating administrator:', error);
+ errorToast.generic('Failed to update administrator');
+ // Revert selection on error
+ setSelectedEmail(localAdministrators.find(admin => admin.IsSelected)?.Email || '');
+ } finally {
+ setIsUpdatingAdmin(false);
+ }
+ };
+
+ const handleConfirmInfo = async () => {
+ setIsConfirming(true);
+ try {
+ const response = await authenticatedFetch(
+ `/api/organisations/${organisation._id}/confirm-info`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to confirm information');
+ }
+
+ const updatedOrg = await response.json();
+
+ if (onUpdate) {
+ onUpdate(updatedOrg);
+ }
+
+ toast.success('Information confirmed as up to date');
+ } catch (error) {
+ console.error('Error confirming information:', error);
+ errorToast.generic('Failed to confirm information');
+ } finally {
+ setIsConfirming(false);
+ }
+ };
+
+ return (
+
+
Admin Details
+
+
+ Please choose the administrator who will be responsible for making updates to your organisation.
+ They will be contacted after 90 days of inactivity to check that the information is still correct.
+
+
+ {/* Administrator Dropdown */}
+
+
+ Administrator
+
+
+
handleAdministratorChange(e.target.value)}
+ disabled={isUpdatingAdmin}
+ className="w-full px-4 py-2 border border-brand-f/30 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-a focus:border-transparent disabled:bg-brand-q disabled:cursor-not-allowed text-brand-k"
+ >
+ {localAdministrators && localAdministrators.length > 0 ? (
+ localAdministrators.map((admin, index) => (
+
+ {admin.Email}
+
+ ))
+ ) : (
+ No administrators available
+ )}
+
+ {isUpdatingAdmin && (
+
+ )}
+
+ {isUpdatingAdmin && (
+
Updating administrator...
+ )}
+
+
+ {/* Days Since Last Update */}
+
+
+
+ {daysSinceUpdate} {daysSinceUpdate === 1 ? 'day' : 'days'} since last update
+
+
+ {warningLevel === 'expired' && (
+
+ ⚠️ Organisation has been unverified due to inactivity over 100 days
+
+ )}
+ {warningLevel === 'warning' && (
+
+ ⚠️ Reminder sent. Organisation will be unverified in {100 - daysSinceUpdate} days without action
+
+ )}
+ {warningLevel === 'info' && (
+
+ ℹ️ Approaching 90-day review period
+
+ )}
+
+
+
+ {/* Information Up to Date Button - Only in edit mode */}
+
+ {isConfirming ? (
+
+ ) : (
+ 'Information up to date'
+ )}
+
+
+ );
+}
diff --git a/src/components/organisations/tabs/AccommodationsTab.tsx b/src/components/organisations/tabs/AccommodationsTab.tsx
new file mode 100644
index 0000000..6ce1d3d
--- /dev/null
+++ b/src/components/organisations/tabs/AccommodationsTab.tsx
@@ -0,0 +1,278 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import { Plus, Edit, Trash } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import { IAccommodation } from '@/types/organisations/IAccommodation';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { errorToast, successToast } from '@/utils/toast';
+import { AddAccommodationModal } from '../AddAccommodationModal';
+
+interface AccommodationsTabProps {
+ organisation: IOrganisation;
+ viewMode?: boolean; // When true, hide add/edit/delete actions
+}
+
+const AccommodationsTab: React.FC = ({ organisation, viewMode = false }) => {
+ const [accommodations, setAccommodations] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isAddModalOpen, setIsAddModalOpen] = useState(false);
+ const [editingAccommodation, setEditingAccommodation] = useState(null);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [accommodationToDelete, setAccommodationToDelete] = useState(null);
+ const [availableCities, setAvailableCities] = useState>([]);
+
+ const fetchOrganisationCities = useCallback(async () => {
+ try {
+ // Fetch all cities
+ const response = await authenticatedFetch('/api/cities');
+ if (!response.ok) {
+ throw new Error('Failed to fetch cities');
+ }
+ const data = await response.json();
+ const allCities = data.data || [];
+
+ // Filter cities based on organisation's AssociatedLocationIds
+ const organisationCityIds = organisation.AssociatedLocationIds || [];
+ const filteredCities = allCities.filter((city: { _id: string; Name: string; Key: string }) =>
+ organisationCityIds.includes(city.Key)
+ );
+
+ setAvailableCities(filteredCities);
+ } catch (error) {
+ console.error('Error fetching cities:', error);
+ }
+ }, [organisation.AssociatedLocationIds]);
+
+ const fetchAccommodations = useCallback(async () => {
+ if (!organisation.Key) return;
+
+ setIsLoading(true);
+ try {
+ const response = await authenticatedFetch(`/api/organisations/${organisation.Key}/accommodations`);
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to fetch accommodations');
+ }
+ const data = await response.json();
+ setAccommodations(data.data || []);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to load accommodations';
+ errorToast.generic(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [organisation.Key]);
+
+ useEffect(() => {
+ fetchAccommodations();
+ fetchOrganisationCities();
+ }, [fetchAccommodations, fetchOrganisationCities]);
+
+ const handleAddAccommodation = () => {
+ setEditingAccommodation(null);
+ setIsAddModalOpen(true);
+ };
+
+ const handleEditAccommodation = (accommodation: IAccommodation) => {
+ setEditingAccommodation(accommodation);
+ setIsAddModalOpen(true);
+ };
+
+ const handleViewAccommodation = (accommodation: IAccommodation) => {
+ setEditingAccommodation(accommodation);
+ setIsAddModalOpen(true);
+ };
+
+ const handleDeleteAccommodation = (accommodation: IAccommodation) => {
+ setAccommodationToDelete(accommodation);
+ setShowDeleteConfirm(true);
+ };
+
+ const confirmDeleteAccommodation = async () => {
+ if (!accommodationToDelete) return;
+
+ try {
+ const response = await authenticatedFetch(
+ `/api/organisations/${organisation.Key}/accommodations/${accommodationToDelete._id}`,
+ {
+ method: 'DELETE',
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to delete accommodation');
+ }
+
+ successToast.delete('Accommodation');
+ fetchAccommodations();
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to delete accommodation';
+ errorToast.generic(errorMessage);
+ } finally {
+ setShowDeleteConfirm(false);
+ setAccommodationToDelete(null);
+ }
+ };
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
Accommodations
+ {!viewMode && (
+
+
+ Add Accommodation
+
+ )}
+
+
+ {/* Accommodations List */}
+ {isLoading ? (
+
+
Loading accommodations...
+
+ ) : accommodations.length === 0 ? (
+
+
No accommodations found for this organisation.
+ {!viewMode && (
+
+
+ Add First Accommodation
+
+ )}
+
+ ) : (
+
+
Accommodations:
+ {accommodations.map((accommodation, index) => (
+
+
+
+ {accommodation.GeneralInfo.Name}
+
+
+ Type: {accommodation.GeneralInfo.AccommodationType}
+
+ {accommodation.GeneralInfo.Synopsis && (
+
+ {accommodation.GeneralInfo.Synopsis.substring(0, 100)}
+ {accommodation.GeneralInfo.Synopsis.length > 100 ? '...' : ''}
+
+ )}
+
+ {accommodation.GeneralInfo.IsOpenAccess && (
+
+ Open Access
+
+ )}
+ {accommodation.GeneralInfo.IsPublished && (
+
+ Published
+
+ )}
+
+ {accommodation.Address.City}
+
+
+
+
+ {viewMode ? (
+ handleViewAccommodation(accommodation)}
+ title="View"
+ >
+ View
+
+ ) : (
+ <>
+ handleEditAccommodation(accommodation)}
+ className="p-2"
+ title="Edit"
+ >
+
+
+ handleDeleteAccommodation(accommodation)}
+ className="p-2 text-brand-g border-brand-g hover:bg-brand-g hover:text-white"
+ title="Delete"
+ >
+
+
+ >
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteConfirm && accommodationToDelete && (
+
{
+ setShowDeleteConfirm(false);
+ setAccommodationToDelete(null);
+ }}
+ variant="danger"
+ />
+ )}
+
+ {/* Add/Edit Accommodation Modal */}
+ {isAddModalOpen && (
+ {
+ setIsAddModalOpen(false);
+ setEditingAccommodation(null);
+ }}
+ onSuccess={fetchAccommodations}
+ organisationId={organisation.Key}
+ providerId={organisation.Key}
+ availableCities={availableCities}
+ accommodation={editingAccommodation}
+ viewMode={viewMode}
+ />
+ )}
+
+ );
+};
+
+export default AccommodationsTab;
diff --git a/src/components/organisations/tabs/OrganisationTab.tsx b/src/components/organisations/tabs/OrganisationTab.tsx
new file mode 100644
index 0000000..4e05d24
--- /dev/null
+++ b/src/components/organisations/tabs/OrganisationTab.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import { Button } from '@/components/ui/Button';
+import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay';
+import { IOrganisation, IOrganisationFormData } from '@/types/organisations/IOrganisation';
+import { timeNumberToString } from '@/schemas/organisationSchema';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { errorToast, successToast } from '@/utils/toast';
+import { OrganisationForm, OrganisationFormRef } from '../OrganisationForm';
+import { AdminDetailsSection } from '../sections/AdminDetailsSection';
+import { decodeText } from '@/utils/htmlDecode';
+
+interface OrganisationTabProps {
+ organisation: IOrganisation;
+ onOrganisationUpdated: () => void;
+ onClose: () => void; // Called after successful update (no confirmation)
+ onCancel: () => void; // Called when user clicks Cancel (shows confirmation)
+ viewMode?: boolean; // When true, all inputs are disabled and save button hidden
+}
+
+const OrganisationTab: React.FC = ({
+ organisation,
+ onOrganisationUpdated,
+ onClose,
+ onCancel,
+ viewMode = false
+}) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [validationErrors, setValidationErrors] = useState([]);
+ const [currentOrganisation, setCurrentOrganisation] = useState(organisation);
+ const formRef = React.useRef(null);
+
+ // Update current organisation when prop changes
+ useEffect(() => {
+ setCurrentOrganisation(organisation);
+ }, [organisation]);
+
+ // Prepare initial data for the form - decode HTML entities
+ const initialData: Partial = {
+ Key: organisation.Key || '',
+ Name: decodeText(organisation.Name || ''),
+ ShortDescription: decodeText(organisation.ShortDescription || ''),
+ Description: decodeText(organisation.Description || ''),
+ AssociatedLocationIds: organisation.AssociatedLocationIds || [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Tags: organisation.Tags ? organisation.Tags.split(',').filter(tag => tag.trim()) as any : [],
+ IsVerified: organisation.IsVerified || false,
+ IsPublished: organisation.IsPublished || false,
+ Telephone: organisation.Telephone || '',
+ Email: organisation.Email || '',
+ Website: organisation.Website || '',
+ Facebook: organisation.Facebook || '',
+ Twitter: organisation.Twitter || '',
+ Addresses: (organisation.Addresses || []).map(address => ({
+ ...address,
+ Street: decodeText(address.Street || ''),
+ Street1: decodeText(address.Street1 || ''),
+ Street2: decodeText(address.Street2 || ''),
+ Street3: decodeText(address.Street3 || ''),
+ OpeningTimes: address.OpeningTimes.map(openingTime => ({
+ Day: openingTime.Day,
+ StartTime: timeNumberToString(openingTime.StartTime),
+ EndTime: timeNumberToString(openingTime.EndTime)
+ }))
+ })),
+ Administrators: organisation.Administrators || []
+ };
+
+ const handleValidationChange = useCallback((errors: ValidationError[]) => {
+ setValidationErrors(errors);
+ }, []);
+
+ // Helper function to convert time string (HH:MM) to number (HHMM)
+ const timeStringToNumber = (timeString: string): number => {
+ return parseInt(timeString.replace(':', ''));
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ // Validate form using ref
+ if (!formRef.current?.validate()) {
+ errorToast.validation();
+ return;
+ }
+
+ // Get form data using ref
+ const formData = formRef.current?.getFormData();
+ if (!formData) {
+ errorToast.generic('Failed to get form data');
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // Convert tags array to comma-separated string for API
+ const submissionData = {
+ ...formData,
+ Tags: Array.isArray(formData.Tags) ? formData.Tags.join(',') : formData.Tags,
+ // Convert opening times from form format (string) to API format (number)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Addresses: formData.Addresses.map((address: any) => ({
+ ...address,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ OpeningTimes: address.OpeningTimes.map((time: any) => ({
+ Day: time.Day,
+ StartTime: timeStringToNumber(time.StartTime),
+ EndTime: timeStringToNumber(time.EndTime)
+ }))
+ }))
+ };
+
+ const response = await authenticatedFetch(`/api/organisations/${organisation._id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(submissionData),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to update organisation');
+ }
+
+ successToast.update('Organisation');
+ onOrganisationUpdated();
+ onClose();
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to update organisation';
+ errorToast.generic(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleCancel = () => {
+ setValidationErrors([]);
+ onCancel(); // Trigger confirmation modal
+ };
+
+ return (
+
+
+ {/* Content */}
+
+ {/* Admin Details Section - Show in both edit and view modes */}
+
+
{
+ setCurrentOrganisation(updatedOrg);
+ onOrganisationUpdated();
+ }}
+ />
+
+
+
+
+
+ {/* Error Display */}
+ {validationErrors.length > 0 && (
+
+
+
+ )}
+
+ {/* Footer */}
+ {!viewMode && (
+
+
+
+ Cancel
+
+
+ {isLoading ? 'Updating...' : 'Update Organisation'}
+
+
+
+ )}
+
+
+ );
+};
+
+export default OrganisationTab;
diff --git a/src/components/organisations/tabs/ServicesTab.tsx b/src/components/organisations/tabs/ServicesTab.tsx
new file mode 100644
index 0000000..a46409a
--- /dev/null
+++ b/src/components/organisations/tabs/ServicesTab.tsx
@@ -0,0 +1,284 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import { Plus, Edit, Trash } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+import { IGroupedService } from '@/types/organisations/IGroupedService';
+import { authenticatedFetch } from '@/utils/authenticatedFetch';
+import { errorToast, successToast } from '@/utils/toast';
+import AddServiceModal from '@/components/organisations/AddServiceModal';
+import { decodeText } from '@/utils/htmlDecode';
+
+interface ServicesTabProps {
+ organisation: IOrganisation;
+ viewMode?: boolean; // When true, hide add/edit/delete actions
+}
+
+const ServicesTab: React.FC = ({ organisation, viewMode = false }) => {
+ const [services, setServices] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isAddModalOpen, setIsAddModalOpen] = useState(false);
+ const [editingService, setEditingService] = useState(null);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [serviceToDelete, setServiceToDelete] = useState(null);
+
+ const fetchServices = useCallback(async () => {
+ if (!organisation.Key) return;
+
+ setIsLoading(true);
+ try {
+ const response = await authenticatedFetch(`/api/organisations/${organisation.Key}/services`);
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to fetch services');
+ }
+ const data = await response.json();
+ setServices(data.data || []);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to load services';
+ errorToast.generic(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [organisation.Key]);
+
+ // Fetch services for the organisation
+ useEffect(() => {
+ fetchServices();
+ }, [fetchServices]);
+
+ const handleAddService = () => {
+ setEditingService(null);
+ setIsAddModalOpen(true);
+ };
+
+ const handleEditService = (service: IGroupedService) => {
+ setEditingService(service);
+ setIsAddModalOpen(true);
+ };
+
+ const handleViewService = (service: IGroupedService) => {
+ setEditingService(service);
+ setIsAddModalOpen(true);
+ };
+
+ const handleDeleteService = (service: IGroupedService) => {
+ setServiceToDelete(service);
+ setShowDeleteConfirm(true);
+ };
+
+ const confirmDeleteService = async () => {
+ if (!serviceToDelete) return;
+
+ try {
+ const response = await authenticatedFetch(
+ `/api/organisations/${organisation.Key}/services/${serviceToDelete._id}`,
+ {
+ method: 'DELETE',
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to delete service');
+ }
+
+ successToast.delete('Service');
+ fetchServices();
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to delete service';
+ errorToast.generic(errorMessage);
+ } finally {
+ setShowDeleteConfirm(false);
+ setServiceToDelete(null);
+ }
+ };
+
+ const handleServiceSaved = () => {
+ setIsAddModalOpen(false);
+ setEditingService(null);
+ fetchServices();
+ };
+
+ const formatServiceDisplay = (service: IGroupedService): string => {
+ const parts: string[] = [];
+
+ if (service.CategoryName) {
+ parts.push(service.CategoryName);
+ }
+
+ if (service.Location?.Postcode) {
+ parts.push(service.Location.Postcode);
+ }
+
+ return parts.join(' - ');
+ };
+
+ const formatSubCategories = (service: IGroupedService): string => {
+ if (!service.SubCategories || service.SubCategories.length === 0) {
+ return 'No subcategories';
+ }
+
+ return service.SubCategories.map(sub => decodeText(sub.Name)).join(', ');
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
Services
+ {!viewMode && (
+
+
+ Add Service
+
+ )}
+
+
+ {/* Services List */}
+ {isLoading ? (
+
+ ) : services.length === 0 ? (
+
+
No services found for this organisation.
+ {!viewMode && (
+
+
+ Add First Service
+
+ )}
+
+ ) : (
+
+
Services:
+ {services.map((service, index) => (
+
+
+
+ {formatServiceDisplay(service)}
+
+
+ Subcategories: {formatSubCategories(service)}
+
+ {service.Info && (
+
+ {decodeText(service.Info).substring(0, 100)}
+ {service.Info.length > 100 ? '...' : ''}
+
+ )}
+
+ {service.IsOpen247 && (
+
+ 24/7
+
+ )}
+ {service.IsAppointmentOnly && (
+
+ Appointment Only
+
+ )}
+ {service.IsTelephoneService && (
+
+ Telephone Service
+
+ )}
+ {!service.IsOpen247 && service.OpeningTimes && service.OpeningTimes.length > 0 && (
+
+ {service.OpeningTimes.length} Opening Times
+
+ )}
+
+
+
+ {viewMode ? (
+ handleViewService(service)}
+ title="View"
+ >
+ View
+
+ ) : (
+ <>
+ handleEditService(service)}
+ className="p-2"
+ title="Edit"
+ >
+
+
+ handleDeleteService(service)}
+ className="p-2 text-brand-g border-brand-g hover:bg-brand-g hover:text-white"
+ title="Delete"
+ >
+
+
+ >
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Add/Edit Service Modal */}
+
{
+ setIsAddModalOpen(false);
+ setEditingService(null);
+ }}
+ organisation={organisation}
+ service={editingService}
+ viewMode={viewMode}
+ onServiceSaved={handleServiceSaved}
+ />
+
+ {/* Delete Confirmation Modal */}
+ {
+ setShowDeleteConfirm(false);
+ setServiceToDelete(null);
+ }}
+ onConfirm={confirmDeleteService}
+ title="Delete Service"
+ message={`Are you sure you want to delete the service "${serviceToDelete?.CategoryName || 'this service'}"? This action cannot be undone.`}
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ variant="danger"
+ />
+
+ );
+};
+
+export default ServicesTab;
diff --git a/src/components/sweps/SwepManagement.tsx b/src/components/sweps/SwepManagement.tsx
index 4a02cf3..d6bc4db 100644
--- a/src/components/sweps/SwepManagement.tsx
+++ b/src/components/sweps/SwepManagement.tsx
@@ -24,7 +24,7 @@ export default function SwepManagement() {
setLoading(true);
setError(null);
// TODO: handle error response. Take example from users
- // const response = await fetch('/api/cities', {
+ // const response = await authenticatedFetch('/api/cities', {
// headers: {
// 'Authorization': `Bearer ${session?.accessToken}`,
// 'Content-Type': 'application/json',
diff --git a/src/components/ui/Breadcrumbs.tsx b/src/components/ui/Breadcrumbs.tsx
index 042d685..5c34b2a 100644
--- a/src/components/ui/Breadcrumbs.tsx
+++ b/src/components/ui/Breadcrumbs.tsx
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useSession } from "next-auth/react";
import { getHomePageForUser } from "@/lib/roleHomePages";
+import { authenticatedFetch } from "@/utils/authenticatedFetch";
function toTitleCase(slug: string) {
return slug
@@ -75,7 +76,7 @@ export default function Breadcrumbs({ items: itemsProp }: { items?: Crumb[] }) {
if (isNewRoute || !looksLikeObjectId) {
return;
}
- const res = await fetch(`/api/banners/${id}`);
+ const res = await authenticatedFetch(`/api/banners/${id}`);
if (!res.ok) return;
const json = await res.json();
const banner = json?.data ?? json; // handle {data: banner} or banner
diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx
index b9a5860..637349e 100644
--- a/src/components/ui/Checkbox.tsx
+++ b/src/components/ui/Checkbox.tsx
@@ -7,16 +7,17 @@ interface CheckboxProps extends InputHTMLAttributes {
className?: string;
}
-export function Checkbox({ label, className = '', ...props }: CheckboxProps) {
+export function Checkbox({ label, className = '', id, ...props }: CheckboxProps) {
return (
{label && (
-
+
{label}
)}
diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx
index 99e7010..87d92f3 100644
--- a/src/components/ui/ConfirmModal.tsx
+++ b/src/components/ui/ConfirmModal.tsx
@@ -77,7 +77,7 @@ export const ConfirmModal: React.FC = ({
return (
{
if (e.target === e.currentTarget && !isLoading && cancelLabel) {
onClose();
@@ -130,6 +130,7 @@ export const ConfirmModal: React.FC
= ({
{cancelLabel && (
= ({
)}
{
onConfirm();
}}
diff --git a/src/components/ui/MultiSelect.tsx b/src/components/ui/MultiSelect.tsx
new file mode 100644
index 0000000..794e311
--- /dev/null
+++ b/src/components/ui/MultiSelect.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+import { ChevronDown, X } from 'lucide-react';
+
+interface MultiSelectOption {
+ value: string;
+ label: string;
+}
+
+interface MultiSelectProps {
+ options: MultiSelectOption[];
+ value: string[];
+ onChange: (value: string[]) => void;
+ placeholder?: string;
+ className?: string;
+ disabled?: boolean; // When true, the multiselect is read-only
+}
+
+export function MultiSelect({ options, value, onChange, placeholder = "Select options...", className = '', disabled = false }: MultiSelectProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleToggleOption = (optionValue: string) => {
+ const newValue = value.includes(optionValue)
+ ? value.filter(v => v !== optionValue)
+ : [...value, optionValue];
+ onChange(newValue);
+ };
+
+ const handleRemoveOption = (optionValue: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ onChange(value.filter(v => v !== optionValue));
+ };
+
+ const selectedOptions = options.filter(option => value.includes(option.value));
+
+ return (
+
+
!disabled && setIsOpen(!isOpen)}
+ >
+
+ {selectedOptions.length === 0 ? (
+ {placeholder}
+ ) : (
+ selectedOptions.map((option, idx) => (
+
+ {option.label}
+ {!disabled && (
+ handleRemoveOption(option.value, e)}
+ className="hover:bg-brand-b rounded-full p-0.5"
+ >
+
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ {isOpen && !disabled && (
+
+ {options.length === 0 ? (
+
+ No options available
+
+ ) : (
+ options.map((option, idx) => (
+
handleToggleOption(option.value)}
+ >
+
+ {option.label}
+ {value.includes(option.value) && (
+ ✓
+ )}
+
+
+ ))
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx
index 7aaabc8..b373518 100644
--- a/src/components/ui/Select.tsx
+++ b/src/components/ui/Select.tsx
@@ -8,7 +8,7 @@ interface SelectOption {
}
interface SelectProps extends SelectHTMLAttributes {
- options: SelectOption[];
+ options: readonly SelectOption[] | SelectOption[];
placeholder?: string;
className?: string;
}
diff --git a/src/components/users/AddRoleModal.tsx b/src/components/users/AddRoleModal.tsx
index 0e34de3..432996d 100644
--- a/src/components/users/AddRoleModal.tsx
+++ b/src/components/users/AddRoleModal.tsx
@@ -2,17 +2,13 @@
import { useState, useEffect } from 'react';
import { X, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { useSession } from 'next-auth/react';
import { errorToast } from '@/utils/toast';
import { authenticatedFetch } from '@/utils/authenticatedFetch';
import { UserAuthClaims } from '@/types/auth';
import { ROLE_PREFIXES, ROLES } from '@/constants/roles';
-
-interface Location {
- _id: string;
- Key: string;
- Name: string;
-}
+import { ICity } from '@/types';
interface AddRoleModalProps {
isOpen: boolean;
@@ -27,8 +23,9 @@ export default function AddRoleModal({ isOpen, onClose, onAdd, currentRoles }: A
const { data: session } = useSession();
const [selectedRole, setSelectedRole] = useState('');
const [selectedLocations, setSelectedLocations] = useState([]);
- const [locations, setLocations] = useState([]);
+ const [locations, setLocations] = useState([]);
const [loadingLocations, setLoadingLocations] = useState(false);
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
const userAuthClaims = (session?.user?.authClaims || { roles: [], specificClaims: [] }) as UserAuthClaims;
const isSuperAdmin = userAuthClaims.roles.includes(ROLES.SUPER_ADMIN);
@@ -143,31 +140,36 @@ export default function AddRoleModal({ isOpen, onClose, onAdd, currentRoles }: A
onClose();
};
+ const confirmCancel = () => {
+ setShowConfirmModal(false);
+ handleClose();
+ };
+
return (
<>
{/* Backdrop */}
-
+
{/* Modal */}
-
-
+
+
{/* Header */}
-
+
Add Role / Select Role
- setShowConfirmModal(true)}
+ className="p-2"
+ title="Close"
>
-
-
+
+
- {/* Content */}
-
+ {/* Content - scrollable */}
+
Role (select)
@@ -176,6 +178,7 @@ export default function AddRoleModal({ isOpen, onClose, onAdd, currentRoles }: A
- Select City{locations.length > 1 ? 's' : ''}
+ Select Location{locations.length > 1 ? 's' : ''}
{loadingLocations ? (
@@ -280,6 +287,7 @@ export default function AddRoleModal({ isOpen, onClose, onAdd, currentRoles }: A
>
handleLocationToggle(location.Key)}
@@ -298,8 +306,8 @@ export default function AddRoleModal({ isOpen, onClose, onAdd, currentRoles }: A
{/* Organisation Administrator Warning - Always show when selected */}
{selectedRole === 'org-admin' && (
-
-
+
+
Organisation Administrator can be created only from Organisation page.
@@ -308,9 +316,9 @@ export default function AddRoleModal({ isOpen, onClose, onAdd, currentRoles }: A
)}
- {/* Footer */}
-
-
+ {/* Footer - fixed at bottom */}
+
+ setShowConfirmModal(true)}>
Cancel
+
+
setShowConfirmModal(false)}
+ onConfirm={confirmCancel}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
>
);
}
diff --git a/src/components/users/AddUserModal.tsx b/src/components/users/AddUserModal.tsx
index 0c3fc87..230442d 100644
--- a/src/components/users/AddUserModal.tsx
+++ b/src/components/users/AddUserModal.tsx
@@ -2,11 +2,12 @@
import { useState } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
+import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { Input } from '@/components/ui/Input';
import AddRoleModal from '@/components/users/AddRoleModal';
import toastUtils, { errorToast, loadingToast, successToast } from '@/utils/toast';
import { authenticatedFetch } from '@/utils/authenticatedFetch';
-import { validateCreateUser } from '@/schemas/userSchema';
+import { validateUserCreate } from '@/schemas/userSchema';
import { HTTP_METHODS } from '@/constants/httpMethods';
import { parseAuthClaimsForDisplay, RoleDisplay } from '@/lib/userService';
import { ROLE_PREFIXES, ROLES } from '@/constants/roles';
@@ -25,6 +26,7 @@ export default function AddUserModal({ isOpen, onClose, onSuccess }: AddUserModa
const [isRoleModalOpen, setIsRoleModalOpen] = useState(false);
const [validationErrors, setValidationErrors] = useState([]);
const [generalError, setGeneralError] = useState('');
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
if (!isOpen) return null;
@@ -124,7 +126,7 @@ export default function AddUserModal({ isOpen, onClose, onSuccess }: AddUserModa
};
// Validate user data before sending
- const validation = validateCreateUser(userData);
+ const validation = validateUserCreate(userData);
if (!validation.success) {
const errors = validation.errors.map(err => ({
Path: err.path || 'Unknown',
@@ -178,35 +180,40 @@ export default function AddUserModal({ isOpen, onClose, onSuccess }: AddUserModa
onClose();
};
+ const confirmCancel = () => {
+ setShowConfirmModal(false);
+ handleClose();
+ };
+
return (
<>
{/* Backdrop */}
-
+
{/* Modal */}
-
-
+
+
{/* Header */}
-
+
Add User
- setShowConfirmModal(true)}
+ className="p-2"
+ title="Close"
>
-
-
+
+
- {/* Content */}
-
+ {/* Content - scrollable */}
+
{/* Email Input */}
- Email
+ Email *
- Roles
+ Roles *
- {/* Footer */}
-
-
-
+ {/* Footer - fixed at bottom */}
+
+
+
setShowConfirmModal(true)}>
Cancel
@@ -293,6 +300,17 @@ export default function AddUserModal({ isOpen, onClose, onSuccess }: AddUserModa
onAdd={handleAddRole}
currentRoles={authClaims}
/>
+
+ setShowConfirmModal(false)}
+ onConfirm={confirmCancel}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
>
);
}
diff --git a/src/components/users/EditUserModal.tsx b/src/components/users/EditUserModal.tsx
index b872678..742f4b9 100644
--- a/src/components/users/EditUserModal.tsx
+++ b/src/components/users/EditUserModal.tsx
@@ -11,7 +11,7 @@ import toastUtils, { errorToast, loadingToast, successToast } from '@/utils/toas
import { authenticatedFetch } from '@/utils/authenticatedFetch';
import { HTTP_METHODS } from '@/constants/httpMethods';
import { ROLES } from '@/constants/roles';
-import { validateUpdateUser } from '@/schemas/userSchema';
+import { validateUserUpdate } from '@/schemas/userSchema';
import { useSession } from 'next-auth/react';
import { getUserLocationSlugs } from '@/utils/locationUtils';
@@ -33,6 +33,7 @@ export default function EditUserModal({
const [isRoleModalOpen, setIsRoleModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showWarningModal, setShowWarningModal] = useState(false);
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
// Get current user's accessible locations
const userAuthClaims = session?.user?.authClaims;
@@ -61,6 +62,11 @@ export default function EditUserModal({
if (!isOpen || !user) return null;
+ const confirmCancel = () => {
+ setShowConfirmModal(false);
+ onClose();
+ };
+
const email = typeof user.Email === 'string' ? user.Email : '';
const handleAddRole = (roleClaims: string[]) => {
@@ -187,9 +193,9 @@ export default function EditUserModal({
};
// Validate update data before sending
- const validation = validateUpdateUser(updateData);
+ const validation = validateUserUpdate(updateData);
if (!validation.success) {
- const errorMessages = validation.errors.map(err => err.message).join(', ');
+ const errorMessages = validation.errors.map((err: { message: string }) => err.message).join(', ');
throw new Error(errorMessages || 'Validation failed');
}
@@ -223,30 +229,29 @@ export default function EditUserModal({
return (
<>
{/* Backdrop */}
-
+
{/* Modal */}
-
-
+
+
{/* Header */}
-
+
Edit User
- setShowConfirmModal(true)}
+ className="p-2"
+ title="Close"
>
-
-
+
+
- {/* Content */}
-
+ {/* Content - scrollable */}
+
{/* Email (Read-only) */}
@@ -342,12 +347,12 @@ export default function EditUserModal({
- {/* Footer */}
-
+ {/* Footer - fixed at bottom */}
+
setShowConfirmModal(true)}
disabled={isSubmitting}
>
Cancel
@@ -383,6 +388,17 @@ export default function EditUserModal({
confirmLabel="OK"
cancelLabel=""
/>
+
+ setShowConfirmModal(false)}
+ onConfirm={confirmCancel}
+ title="Close without saving?"
+ message="You may lose unsaved changes."
+ confirmLabel="Close Without Saving"
+ cancelLabel="Continue Editing"
+ variant="warning"
+ />
>
);
}
diff --git a/src/components/users/UserProfileModal.tsx b/src/components/users/UserProfileModal.tsx
index 0c569dc..68179f8 100644
--- a/src/components/users/UserProfileModal.tsx
+++ b/src/components/users/UserProfileModal.tsx
@@ -22,28 +22,28 @@ export default function UserProfileModal({ isOpen, onClose, email, authClaims }:
return (
<>
{/* Backdrop */}
-
+
{/* Modal */}
-
-
+
+
{/* Header */}
-
+
User Profile
-
-
-
+
+
- {/* Content */}
-
+ {/* Content - scrollable */}
+
{/* Email Section */}
@@ -87,8 +87,8 @@ export default function UserProfileModal({ isOpen, onClose, email, authClaims }:
)}
- {/* Footer */}
-
+ {/* Footer - fixed at bottom */}
+
Close
diff --git a/src/components/users/ViewUserModal.tsx b/src/components/users/ViewUserModal.tsx
index a3cc559..0fe05da 100644
--- a/src/components/users/ViewUserModal.tsx
+++ b/src/components/users/ViewUserModal.tsx
@@ -49,28 +49,28 @@ export default function ViewUserModal({ isOpen, onClose, user }: ViewUserModalPr
return (
<>
{/* Backdrop */}
-
+
{/* Modal */}
-
-
+
+
{/* Header */}
-
+
User Details
-
-
-
+
+
- {/* Content */}
-
+ {/* Content - scrollable */}
+
{/* Email Section */}
@@ -168,8 +168,8 @@ export default function ViewUserModal({ isOpen, onClose, user }: ViewUserModalPr
- {/* Footer */}
-
+ {/* Footer - fixed at bottom */}
+
Close
diff --git a/src/schemas/accommodationSchema.ts b/src/schemas/accommodationSchema.ts
new file mode 100644
index 0000000..3d5efae
--- /dev/null
+++ b/src/schemas/accommodationSchema.ts
@@ -0,0 +1,130 @@
+import { z } from 'zod';
+import { ValidationResult, createValidationResult } from './validationHelpers';
+import { LocationCoordinatesSchema } from './organisationSchema';
+import { AccommodationType, SupportOfferedType, DiscretionaryValue } from '@/types/organisations/IAccommodation';
+import { isValidPostcodeFormat } from '@/utils/postcodeValidation';
+
+// Helper function to transform error paths to user-friendly names
+export function transformErrorPath(path: string): string {
+ const pathMap: Record
= {
+ 'GeneralInfo.Name': 'Accommodation Name',
+ 'GeneralInfo.AccommodationType': 'Accommodation Type',
+ 'ContactInformation.Name': 'Contact Name',
+ 'ContactInformation.Email': 'Email',
+ 'Address.Street1': 'Street',
+ 'Address.City': 'City',
+ 'Address.Postcode': 'Postcode',
+ 'Address.AssociatedCityId': 'Associated Location',
+ 'PricingAndRequirementsInfo.Price': 'Price',
+ };
+
+ return pathMap[path] || path;
+}
+
+// Preprocessing helper to convert null/undefined to empty string
+const preprocessNullableString = (val: unknown) => {
+ if (val === null || val === undefined) return '';
+ return val;
+};
+
+// Preprocessing helper to convert null/undefined to empty string
+const preprocessNullableObject = (val: unknown) => {
+ if (val === null || val === undefined) return {};
+ return val;
+};
+
+// Enum for discretionary values: Use nativeEnum for proper type checking
+const DiscretionaryValueSchema = z.nativeEnum(DiscretionaryValue);
+
+// Nested schemas for accommodation sections
+const GeneralInfoSchema = z.object({
+ Name: z.string().min(1, 'Accommodation Name is required'),
+ Synopsis: z.preprocess(preprocessNullableString, z.string().optional()),
+ Description: z.preprocess(preprocessNullableString, z.string().optional()),
+ AccommodationType: z.nativeEnum(AccommodationType),
+ ServiceProviderId: z.string().min(1, 'Service provider ID is required'),
+ ServiceProviderName: z.string().optional(),
+ IsOpenAccess: z.boolean(),
+ IsPubliclyVisible: z.boolean().optional(),
+ IsPublished: z.boolean().optional(),
+ IsVerified: z.boolean().optional(),
+});
+
+const PricingAndRequirementsInfoSchema = z.object({
+ ReferralIsRequired: z.boolean().default(false),
+ ReferralNotes: z.preprocess(preprocessNullableString, z.string().optional()),
+ Price: z.string().min(1, 'Price is required'),
+ FoodIsIncluded: DiscretionaryValueSchema,
+ AvailabilityOfMeals: z.preprocess(preprocessNullableString, z.string().optional()),
+});
+
+const ContactInformationSchema = z.object({
+ Name: z.string().min(1, 'Contact name is required'),
+ Email: z.string().email('Invalid email address').min(1, 'Email is required'),
+ Telephone: z.preprocess(preprocessNullableString, z.string().optional()),
+ AdditionalInfo: z.preprocess(preprocessNullableString, z.string().optional()),
+});
+
+const AccommodationAddressSchema = z.object({
+ Street1: z.string().min(1, 'Street is required'),
+ Street2: z.preprocess(preprocessNullableString, z.string().optional()),
+ Street3: z.preprocess(preprocessNullableString, z.string().optional()),
+ City: z.string().min(1, 'City is required'),
+ Postcode: z.string().min(1, 'Postcode is required').refine((postcode) => {
+ return isValidPostcodeFormat(postcode);
+ }, {
+ message: 'Invalid postcode format'
+ }),
+ Location: LocationCoordinatesSchema.optional(),
+ AssociatedCityId: z.string().min(1, 'Associated Location is required'),
+});
+
+const FeaturesWithDiscretionarySchema = z.object({
+ AcceptsHousingBenefit: DiscretionaryValueSchema.optional(),
+ AcceptsPets: DiscretionaryValueSchema.optional(),
+ AcceptsCouples: DiscretionaryValueSchema.optional(),
+ HasDisabledAccess: DiscretionaryValueSchema.optional(),
+ IsSuitableForWomen: DiscretionaryValueSchema.optional(),
+ IsSuitableForYoungPeople: DiscretionaryValueSchema.optional(),
+ HasSingleRooms: DiscretionaryValueSchema.optional(),
+ HasSharedRooms: DiscretionaryValueSchema.optional(),
+ HasShowerBathroomFacilities: DiscretionaryValueSchema.optional(),
+ HasAccessToKitchen: DiscretionaryValueSchema.optional(),
+ HasLaundryFacilities: DiscretionaryValueSchema.optional(),
+ HasLounge: DiscretionaryValueSchema.optional(),
+ AllowsVisitors: DiscretionaryValueSchema.optional(),
+ HasOnSiteManager: DiscretionaryValueSchema.optional(),
+ AdditionalFeatures: z.preprocess(preprocessNullableString, z.string().optional()),
+});
+
+const ResidentCriteriaInfoSchema = z.object({
+ AcceptsMen: z.boolean().optional(),
+ AcceptsWomen: z.boolean().optional(),
+ AcceptsCouples: z.boolean().optional(),
+ AcceptsYoungPeople: z.boolean().optional(),
+ AcceptsFamilies: z.boolean().optional(),
+ AcceptsBenefitsClaimants: z.boolean().optional(),
+});
+
+const SupportProvidedInfoSchema = z.object({
+ SupportOffered: z.array(z.nativeEnum(SupportOfferedType)).optional(),
+ SupportInfo: z.preprocess(preprocessNullableString, z.string().optional()),
+});
+
+// Accommodation schema (works for both create and update)
+// Form should validate these requirements for create operations
+export const AccommodationSchema = z.object({
+ GeneralInfo: GeneralInfoSchema,
+ PricingAndRequirementsInfo: PricingAndRequirementsInfoSchema,
+ ContactInformation: ContactInformationSchema,
+ Address: AccommodationAddressSchema,
+ FeaturesWithDiscretionary: z.preprocess(preprocessNullableObject, FeaturesWithDiscretionarySchema.optional()),
+ ResidentCriteriaInfo: z.preprocess(preprocessNullableObject, ResidentCriteriaInfoSchema.optional()),
+ SupportProvidedInfo: z.preprocess(preprocessNullableObject, SupportProvidedInfoSchema.optional()),
+});
+
+// Validation function
+export function validateAccommodation(data: unknown): ValidationResult> {
+ const result = AccommodationSchema.safeParse(data);
+ return createValidationResult(result);
+}
diff --git a/src/schemas/bannerSchema.ts b/src/schemas/bannerSchema.ts
index 5ead6f1..0f8d7f7 100644
--- a/src/schemas/bannerSchema.ts
+++ b/src/schemas/bannerSchema.ts
@@ -6,11 +6,8 @@ import {
} from './bannerSchemaCore';
import { getFieldErrors } from './validationHelpers';
-// Type exports
-export type BannerFormData = z.infer;
-
// Validation function for frontend forms
-export function validateBannerForm(data: unknown): ValidationResult {
+export function validateBannerForm(data: unknown): ValidationResult> {
const result = BannerSchemaCore.safeParse(data);
return createValidationResult(result);
}
diff --git a/src/schemas/groupedServiceSchema.ts b/src/schemas/groupedServiceSchema.ts
new file mode 100644
index 0000000..22a775f
--- /dev/null
+++ b/src/schemas/groupedServiceSchema.ts
@@ -0,0 +1,214 @@
+import { z } from 'zod';
+import { isValidPostcodeFormat } from '@/utils/postcodeValidation';
+
+// Preprocessing helper to convert null/undefined to empty string
+const preprocessNullableString = (val: unknown) => {
+ if (val === null || val === undefined) return '';
+ return val;
+};
+
+// Location Schema for services with conditional validation based on IsOutreachLocation
+export const ServiceLocationSchema = z.object({
+ IsOutreachLocation: z.boolean().optional(),
+ Description: z.preprocess(preprocessNullableString, z.string().optional()),
+ StreetLine1: z.preprocess(preprocessNullableString, z.string().optional()),
+ StreetLine2: z.preprocess(preprocessNullableString, z.string().optional()),
+ StreetLine3: z.preprocess(preprocessNullableString, z.string().optional()),
+ StreetLine4: z.preprocess(preprocessNullableString, z.string().optional()),
+ City: z.preprocess(preprocessNullableString, z.string().optional()),
+ Postcode: z.preprocess(preprocessNullableString, z.string().optional()),
+ Location: z.object({
+ type: z.string(),
+ coordinates: z.tuple([z.number(), z.number()])
+ }).optional()
+}).refine((data) => {
+ // If IsOutreachLocation is true, Description is required
+ if (data.IsOutreachLocation === true) {
+ return data.Description && data.Description.trim().length > 0;
+ }
+ return true;
+}, {
+ message: 'Description is required for outreach locations',
+ path: ['Description']
+}).refine((data) => {
+ // If IsOutreachLocation is false/undefined, StreetLine1 and Postcode are required
+ if (!data.IsOutreachLocation) {
+ return data.StreetLine1 && data.StreetLine1.trim().length > 0;
+ }
+ return true;
+}, {
+ message: 'Street address is required for fixed locations',
+ path: ['StreetLine1']
+}).refine((data) => {
+ // If IsOutreachLocation is false/undefined, Postcode is required
+ if (!data.IsOutreachLocation) {
+ return data.Postcode && data.Postcode.trim().length > 0;
+ }
+ return true;
+}, {
+ message: 'Postcode is required for fixed locations',
+ path: ['Postcode']
+}).refine((data) => {
+ // Validate postcode format for fixed locations
+ if (!data.IsOutreachLocation && data.Postcode && data.Postcode.trim().length > 0) {
+ return isValidPostcodeFormat(data.Postcode);
+ }
+ return true;
+}, {
+ message: 'Invalid postcode format',
+ path: ['Postcode']
+});
+
+// Opening Time Schema (using string format for form inputs)
+export const OpeningTimeFormSchema = z.object({
+ Day: z.number().min(0).max(6, 'Day must be between 0 (Sunday) and 6 (Saturday)'),
+ StartTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Start time must be in HH:MM format'),
+ EndTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'End time must be in HH:MM format'),
+}).refine((data) => {
+ const timeStringToNumber = (timeString: string): number => {
+ return parseInt(timeString.replace(':', ''));
+ };
+ const startNum = timeStringToNumber(data.StartTime);
+ const endNum = timeStringToNumber(data.EndTime);
+ return startNum < endNum;
+}, {
+ message: 'End time must be after start time',
+ path: ['EndTime']
+});
+
+// Service Sub Category Schema
+export const ServiceSubCategorySchema = z.object({
+ _id: z.string(),
+ Name: z.string(),
+ Synopsis: z.preprocess(preprocessNullableString, z.string().optional())
+});
+
+// Main Grouped Service Schema
+export const GroupedServiceSchema = z.object({
+ DocumentCreationDate: z.date().optional(),
+ DocumentModifiedDate: z.date().optional(),
+ CreatedBy: z.preprocess(preprocessNullableString, z.string().optional()),
+ IsPublished: z.boolean().default(false),
+ IsVerified: z.boolean().default(false),
+ ProviderId: z.string().min(1, 'Provider ID is required'),
+ ProviderName: z.preprocess(preprocessNullableString, z.string().optional()),
+ CategoryId: z.string().min(1, 'Category is required'),
+ CategoryName: z.preprocess(preprocessNullableString, z.string().optional()),
+ CategorySynopsis: z.preprocess(preprocessNullableString, z.string().optional()),
+ Info: z.preprocess(preprocessNullableString, z.string().optional()),
+ Location: ServiceLocationSchema,
+ IsOpen247: z.boolean().default(false),
+ OpeningTimes: z.array(OpeningTimeFormSchema).optional(),
+ SubCategories: z.array(ServiceSubCategorySchema).min(1, 'At least one subcategory is required'),
+ IsTelephoneService: z.boolean().optional().default(false),
+ IsAppointmentOnly: z.boolean().optional().default(false),
+ Telephone: z.preprocess(preprocessNullableString, z.string().optional())
+}).refine((data) => {
+ // If not open 24/7 and not outreach location, require opening times
+ if (!data.IsOpen247 && !data.Location.IsOutreachLocation) {
+ return data.OpeningTimes && data.OpeningTimes.length > 0;
+ }
+ return true;
+}, {
+ message: 'Opening times are required when service is not open 24/7 and not an outreach location',
+ path: ['OpeningTimes']
+});
+
+// Form data interface for frontend
+export interface IGroupedServiceFormData {
+ _id?: string;
+ ProviderId: string;
+ ProviderName?: string;
+ IsPublished: boolean;
+ IsVerified: boolean;
+ CategoryId: string;
+ CategoryName?: string;
+ CategorySynopsis?: string;
+ Info?: string;
+ Tags?: string[];
+ Location: {
+ IsOutreachLocation?: boolean;
+ Description?: string; // Used as outreach description when IsOutreachLocation is true
+ StreetLine1?: string;
+ StreetLine2?: string;
+ StreetLine3?: string;
+ StreetLine4?: string;
+ City?: string;
+ Postcode?: string;
+ Location?: {
+ type?: string;
+ coordinates?: [number, number];
+ };
+ };
+ IsOpen247: boolean;
+ OpeningTimes?: Array<{
+ Day: number;
+ StartTime: string;
+ EndTime: string;
+ }>;
+ SubCategories: Array<{
+ _id: string;
+ Name: string;
+ Synopsis?: string;
+ }>;
+ IsTelephoneService?: boolean;
+ IsAppointmentOnly?: boolean;
+ Telephone?: string;
+}
+
+// Helper function to transform error paths to user-friendly names
+export function transformErrorPath(path: string): string {
+ // Handle CategoryId
+ if (path === 'CategoryId') {
+ return 'Category';
+ }
+
+ // Handle SubCategories
+ if (path.startsWith('SubCategories')) {
+ return path.replace('SubCategories', 'Sub Categories');
+ }
+
+ // Handle Location.StreetLine1
+ if (path === 'Location.StreetLine1') {
+ return 'Street';
+ }
+
+ // Handle Location.Postcode
+ if (path === 'Location.Postcode') {
+ return 'Postcode';
+ }
+
+ // Handle OpeningTimes
+ if (path.startsWith('OpeningTimes')) {
+ return path.replace('OpeningTimes', 'Opening Times');
+ }
+
+ // Handle Location.Description
+ if (path === 'Location.Description') {
+ return 'Outreach Location Description';
+ }
+
+ // Return original path for fields without transformation
+ return path;
+}
+
+// Validation function
+export const validateGroupedService = (data: unknown) => {
+ const result = GroupedServiceSchema.safeParse(data);
+
+ if (!result.success) {
+ return {
+ success: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ errors: result.error.issues.map((issue: any) => ({
+ path: Array.isArray(issue.path) ? issue.path.join('.') : issue.path,
+ message: issue.message
+ }))
+ };
+ }
+
+ return {
+ success: true,
+ data: result.data
+ };
+};
diff --git a/src/schemas/organisationSchema.ts b/src/schemas/organisationSchema.ts
new file mode 100644
index 0000000..712d3f8
--- /dev/null
+++ b/src/schemas/organisationSchema.ts
@@ -0,0 +1,137 @@
+import { z } from 'zod';
+import { ValidationResult, createValidationResult } from './validationHelpers';
+import { isValidPostcodeFormat } from '../utils/postcodeValidation';
+import { OrganisationTag } from '@/types/organisations/IOrganisation';
+
+// Time validation helper
+const timeStringSchema = z.string().regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Time must be in HH:MM format');
+
+// Convert time string (HH:MM) to number (HHMM)
+export function timeStringToNumber(timeStr: string): number {
+ const [hours, minutes] = timeStr.split(':').map(Number);
+ return hours * 100 + minutes;
+}
+
+// We don't use it now. Maybe we need it for Edit Organisations
+// Convert number (HHMM) to time string (HH:MM)
+export function timeNumberToString(timeNum: number): string {
+ const hours = Math.floor(timeNum / 100);
+ const minutes = timeNum % 100;
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
+}
+
+// Preprocessing helper to convert null/undefined to empty string
+const preprocessNullableString = (val: unknown) => {
+ if (val === null || val === undefined) return '';
+ return val;
+};
+
+// Nested schemas for organisation components
+export const LocationCoordinatesSchema = z.object({
+ type: z.string().min(1, 'Location type is required'),
+ coordinates: z.tuple([z.number(), z.number()]),
+});
+
+// Form schema: Uses string times (HH:MM format) for form inputs
+export const OpeningTimeFormSchema = z.object({
+ Day: z.number().min(0).max(6, 'Day must be between 0 (Sunday) and 6 (Saturday)'),
+ StartTime: timeStringSchema,
+ EndTime: timeStringSchema,
+}).refine((data) => {
+ const startNum = timeStringToNumber(data.StartTime);
+ const endNum = timeStringToNumber(data.EndTime);
+ return startNum < endNum;
+}, {
+ message: 'End time must be after start time',
+ path: ['EndTime']
+});
+
+// API schema: Uses number times (e.g., 900 for 09:00) for database storage
+export const OpeningTimeSchema = z.object({
+ Day: z.number().min(0).max(6, 'Day must be between 0 (Sunday) and 6 (Saturday)'),
+ StartTime: z.number().min(0).max(2359, 'Start time must be between 0 and 2359'),
+ EndTime: z.number().min(0).max(2359, 'End time must be between 0 and 2359'),
+}).refine((data) => {
+ return data.StartTime < data.EndTime;
+}, {
+ message: 'End time must be after start time',
+ path: ['EndTime']
+});
+
+export const AddressSchema = z.object({
+ Street: z.string().min(1, 'Street is required'),
+ Street1: z.preprocess(preprocessNullableString, z.string().optional()),
+ Street2: z.preprocess(preprocessNullableString, z.string().optional()),
+ Street3: z.preprocess(preprocessNullableString, z.string().optional()),
+ City: z.preprocess(preprocessNullableString, z.string().optional()),
+ Postcode: z.string().min(1, 'Postcode is required').refine((postcode) => {
+ return isValidPostcodeFormat(postcode);
+ }, {
+ message: 'Invalid postcode format'
+ }),
+ Telephone: z.preprocess(preprocessNullableString, z.string().optional()),
+ IsOpen247: z.boolean().optional(),
+ IsAppointmentOnly: z.boolean().optional(),
+ Location: LocationCoordinatesSchema.optional(),
+ // We don't need to validate opening times in the form, because we validate them directly in the form via OpeningTimeFormSchema and also in the API
+ //OpeningTimes: z.array(OpeningTimeSchema).default([]),
+});
+
+// Organisation form validation schema
+export const OrganisationSchema = z.object({
+ // General Details
+ // It's not user friendly to show validation message in form about Key. It's validated on API.
+ // Key: z.string().min(1, 'Key is required').trim(),
+ Name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or less'),
+ AssociatedLocationIds: z.array(z.string()).min(1, 'At least one associated location is required'),
+ ShortDescription: z.string().min(1, 'Short description is required'),
+ Description: z.string().min(1, 'Description is required'),
+ Tags: z.array(z.nativeEnum(OrganisationTag)).default([]),
+
+ // Contact Information
+ Telephone: z.preprocess(preprocessNullableString, z.string().optional()),
+ Email: z.preprocess(preprocessNullableString, z.string().email('Invalid email address').optional().or(z.literal(''))),
+ Website: z.preprocess(preprocessNullableString, z.string().url('Invalid website URL').optional().or(z.literal(''))),
+ Facebook: z.preprocess(preprocessNullableString, z.string().url('Invalid Facebook URL').optional().or(z.literal(''))),
+ Twitter: z.preprocess(preprocessNullableString, z.string().url('Invalid Twitter URL').optional().or(z.literal(''))),
+ Bluesky: z.preprocess(preprocessNullableString, z.string().url('Invalid Bluesky URL').optional().or(z.literal(''))),
+
+ // Locations
+ Addresses: z.array(AddressSchema).default([]),
+
+ // System fields
+ IsVerified: z.boolean().default(false),
+ IsPublished: z.boolean().default(false)
+});
+
+// Service Provider schema (for API)
+
+// Helper function to transform error paths to user-friendly names
+export function transformErrorPath(path: string): string {
+ // Handle nested address errors (e.g., "Addresses.0.Postcode" -> "Location 1 - Postcode")
+ const addressMatch = path.match(/^Addresses\.(\d+)\.(.+)$/);
+ if (addressMatch) {
+ const locationIndex = parseInt(addressMatch[1]) + 1;
+ const fieldName = addressMatch[2];
+ return `Location ${locationIndex} - ${fieldName}`;
+ }
+
+ // Handle AssociatedLocationIds
+ if (path === 'AssociatedLocationIds') {
+ return 'Associated Locations';
+ }
+
+ // Handle AssociatedLocationIds
+ if (path === 'ShortDescription') {
+ return 'Short Description';
+ }
+
+ // Return original path for fields without transformation
+ return path;
+}
+
+// Validation functions
+export function validateOrganisation(data: unknown): ValidationResult> {
+ const result = OrganisationSchema.safeParse(data);
+ return createValidationResult(result);
+}
diff --git a/src/schemas/userSchema.ts b/src/schemas/userSchema.ts
index 11cee31..7e92974 100644
--- a/src/schemas/userSchema.ts
+++ b/src/schemas/userSchema.ts
@@ -2,8 +2,8 @@ import { z } from 'zod';
import { ROLE_VALIDATION_PATTERN } from '@/constants/roles';
import { ValidationResult, createValidationResult } from './validationHelpers';
-// User creation schema for admin frontend
-export const CreateUserSchema = z.object({
+// User schema for admin frontend (for creation)
+export const UserSchema = z.object({
Email: z
.string()
.min(1, 'Email is required')
@@ -57,16 +57,14 @@ export const UpdateUserSchema = z.object({
message: 'At least one field must be provided for update',
});
-export type CreateUserInput = z.infer;
-export type UpdateUserInput = z.infer;
-
-// Validation functions
-export function validateCreateUser(data: unknown): ValidationResult {
- const result = CreateUserSchema.safeParse(data);
+// Validation function for creation
+export function validateUserCreate(data: unknown): ValidationResult> {
+ const result = UserSchema.safeParse(data);
return createValidationResult(result);
}
-export function validateUpdateUser(data: unknown): ValidationResult {
+// Validation function for updates
+export function validateUserUpdate(data: unknown): ValidationResult> {
const result = UpdateUserSchema.safeParse(data);
return createValidationResult(result);
}
diff --git a/src/types/IService.ts b/src/types/IService.ts
deleted file mode 100644
index ad283a7..0000000
--- a/src/types/IService.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Temporary placeholder type for Service until full schema is defined
-export interface IService {}
diff --git a/src/types/IServiceProvider.ts b/src/types/IServiceProvider.ts
deleted file mode 100644
index f9355e0..0000000
--- a/src/types/IServiceProvider.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Temporary placeholder type for ServiceProvider until full schema is defined
-export interface IServiceProvider {}
diff --git a/src/types/auth.ts b/src/types/auth.ts
index 0b89a24..7b7c137 100644
--- a/src/types/auth.ts
+++ b/src/types/auth.ts
@@ -29,35 +29,41 @@ export const ROLE_PERMISSIONS: Record = {
pages: ['/cities', '/organisations', '/advice', '/banners', '/swep-banners', '/users', '/resources'],
apiEndpoints: [
{ path: '/api/cities', methods: ['*'] },
- { path: '/api/service-providers', methods: ['*'] },
+ { path: '/api/organisations', methods: ['*'] },
{ path: '/api/services', methods: ['*'] },
+ { path: '/api/accommodations', methods: ['*'] },
{ path: '/api/faqs', methods: ['*'] },
{ path: '/api/banners', methods: ['*'] },
{ path: '/api/swep-banners', methods: ['*'] },
{ path: '/api/resources', methods: ['*'] },
- { path: '/api/users', methods: ['*'] }
+ { path: '/api/users', methods: ['*'] },
+ { path: '/api/service-categories', methods: [HTTP_METHODS.GET] },
]
},
[ROLES.VOLUNTEER_ADMIN]: {
pages: ['/cities', '/organisations', '/advice', '/banners', '/swep-banners', '/resources'],
apiEndpoints: [
{ path: '/api/cities', methods: [HTTP_METHODS.GET] },
- { path: '/api/service-providers', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
+ { path: '/api/organisations', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
{ path: '/api/services', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
+ { path: '/api/accommodations', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
{ path: '/api/faqs', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
{ path: '/api/banners', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
{ path: '/api/swep-banners', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
{ path: '/api/resources', methods: [HTTP_METHODS.GET, HTTP_METHODS.POST, HTTP_METHODS.PUT, HTTP_METHODS.PATCH] },
- { path: '/api/users', methods: [HTTP_METHODS.POST] }
+ { path: '/api/users', methods: [HTTP_METHODS.POST] },
+ { path: '/api/service-categories', methods: [HTTP_METHODS.GET] },
]
},
[ROLES.ORG_ADMIN]: {
pages: ['/organisations'],
apiEndpoints: [
{ path: '/api/cities', methods: [HTTP_METHODS.GET] },
- { path: '/api/service-providers', methods: ['*'] },
+ { path: '/api/organisations', methods: ['*'] },
{ path: '/api/services', methods: ['*'] },
+ { path: '/api/accommodations', methods: ['*'] },
{ path: '/api/users', methods: [HTTP_METHODS.POST] },
+ { path: '/api/service-categories', methods: [HTTP_METHODS.GET] },
]
},
[ROLES.SWEP_ADMIN]: {
diff --git a/src/types/index.ts b/src/types/index.ts
index 6ec8b48..765d390 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,10 +1,20 @@
// Re-export all types from individual files
export * from './IUser';
export * from './ICity';
-export * from './IService';
-export * from './IServiceProvider';
export * from './IFaq';
+// Service provider related types
+export * from './organisations/IOrganisation';
+export * from './organisations/IGroupedService';
+export * from './organisations/IAccommodation';
+export * from './organisations/IAddress';
+export * from './organisations/IOpeningTime';
+export * from './organisations/ILocationCoordinates';
+export * from './organisations/IAdministrator';
+export * from './organisations/INote';
+export * from './organisations/ILocation';
+export * from './organisations/IServiceSubCategory';
+
// Banner related types
export * from './banners/IBanner';
// TODO: Uncomment if AccentGraphic is needed. In the other case, remove.
diff --git a/src/types/organisations/IAccommodation.ts b/src/types/organisations/IAccommodation.ts
new file mode 100644
index 0000000..aa739e0
--- /dev/null
+++ b/src/types/organisations/IAccommodation.ts
@@ -0,0 +1,140 @@
+// Discretionary Values: 0 = No, 1 = Yes, 2 = Don't Know/Ask
+export enum DiscretionaryValue {
+ No = 0,
+ Yes = 1,
+ DontKnowAsk = 2
+}
+
+// Discretionary Options for Dropdowns
+export const DISCRETIONARY_OPTIONS = [
+ { value: DiscretionaryValue.No, label: 'No' },
+ { value: DiscretionaryValue.Yes, label: 'Yes' },
+ { value: DiscretionaryValue.DontKnowAsk, label: "Don't Know / Ask" }
+];
+
+// Accommodation Type Enum
+export enum AccommodationType {
+ EMERGENCY = 'emergency',
+ HOSTELS = 'hostel',
+ HOSTED = 'hosted',
+ RENTED = 'rented',
+ SUPPORTED = 'supported',
+ SOCIAL_HOUSING = 'social',
+ NIGHT_SHELTER = 'shelter',
+ LETTINGS_AGENCIES = 'lettings-agencies',
+ BNBS = 'b-and-bs'
+}
+
+// Accommodation Type Options for Dropdown
+export const ACCOMMODATION_TYPES = [
+ { value: AccommodationType.EMERGENCY, label: 'Emergency' },
+ { value: AccommodationType.HOSTELS, label: 'Hostels' },
+ { value: AccommodationType.HOSTED, label: 'Hosted' },
+ { value: AccommodationType.RENTED, label: 'Rented' },
+ { value: AccommodationType.SUPPORTED, label: 'Supported' },
+ { value: AccommodationType.SOCIAL_HOUSING, label: 'Social Housing' },
+ { value: AccommodationType.NIGHT_SHELTER, label: 'Night shelter' },
+ { value: AccommodationType.LETTINGS_AGENCIES, label: 'Lettings Agencies' },
+ { value: AccommodationType.BNBS, label: 'B&Bs' }
+] as const;
+
+// Support Offered Enum
+export enum SupportOfferedType {
+ ALCOHOL = 'alcohol',
+ DOMESTIC_VIOLENCE = 'domestic violence',
+ MENTAL_HEALTH = 'mental health',
+ PHYSICAL_HEALTH = 'physical health',
+ DRUG_DEPENDENCY = 'substances'
+}
+
+// Support Offered Options
+export const SUPPORT_OFFERED_OPTIONS = [
+ { value: SupportOfferedType.ALCOHOL, label: 'Alcohol' },
+ { value: SupportOfferedType.DOMESTIC_VIOLENCE, label: 'Domestic Violence' },
+ { value: SupportOfferedType.MENTAL_HEALTH, label: 'Mental Health' },
+ { value: SupportOfferedType.PHYSICAL_HEALTH, label: 'Physical Health' },
+ { value: SupportOfferedType.DRUG_DEPENDENCY, label: 'Drug Dependency' }
+] as const;
+
+export interface IAccommodation {
+ _id: string;
+ DocumentCreationDate: Date;
+ DocumentModifiedDate: Date;
+ CreatedBy: string;
+ GeneralInfo: {
+ Name: string;
+ Synopsis?: string;
+ Description?: string;
+ AccommodationType: AccommodationType;
+ // We have this field in the DB but we use another field SupportProvidedInfo.SupportOffered on WEB.
+ // SupportOffered: string[];
+ ServiceProviderId: string;
+ ServiceProviderName?: string;
+ IsOpenAccess: boolean;
+ IsPubliclyVisible?: boolean;
+ IsPublished?: boolean;
+ IsVerified?: boolean;
+ };
+ PricingAndRequirementsInfo: {
+ ReferralIsRequired: boolean;
+ ReferralNotes?: string;
+ Price: string;
+ FoodIsIncluded: DiscretionaryValue;
+ AvailabilityOfMeals?: string;
+ };
+ ContactInformation: {
+ Name: string;
+ Email: string;
+ Telephone?: string;
+ AdditionalInfo?: string;
+ };
+ Address: {
+ Street1: string;
+ Street2?: string;
+ Street3?: string;
+ City: string;
+ Postcode: string;
+ Location?: {
+ type: string;
+ coordinates: [number, number];
+ };
+ AssociatedCityId: string;
+ // We have these fields i nthe database but we don't need them
+ // PublicTransportInfo: string;
+ // NearestSupportProviderId: string;
+ // IsPubliclyHidden: boolean;
+ };
+ FeaturesWithDiscretionary: {
+ AcceptsHousingBenefit?: DiscretionaryValue;
+ AcceptsPets?: DiscretionaryValue;
+ AcceptsCouples?: DiscretionaryValue;
+ HasDisabledAccess?: DiscretionaryValue;
+ IsSuitableForWomen?: DiscretionaryValue;
+ IsSuitableForYoungPeople?: DiscretionaryValue;
+ HasSingleRooms?: DiscretionaryValue;
+ HasSharedRooms?: DiscretionaryValue;
+ HasShowerBathroomFacilities?: DiscretionaryValue;
+ HasAccessToKitchen?: DiscretionaryValue;
+ HasLaundryFacilities?: DiscretionaryValue;
+ HasLounge?: DiscretionaryValue;
+ AllowsVisitors?: DiscretionaryValue;
+ HasOnSiteManager?: DiscretionaryValue;
+ AdditionalFeatures?: string;
+ };
+ ResidentCriteriaInfo: {
+ AcceptsMen?: boolean;
+ AcceptsWomen?: boolean;
+ AcceptsCouples?: boolean;
+ AcceptsYoungPeople?: boolean;
+ AcceptsFamilies?: boolean;
+ AcceptsBenefitsClaimants?: boolean;
+ };
+ SupportProvidedInfo: {
+ SupportOffered?: SupportOfferedType[];
+ SupportInfo?: string;
+ };
+}
+
+// Form data interface for creating/editing accommodations
+export interface IAccommodationFormData extends Omit {
+}
\ No newline at end of file
diff --git a/src/types/organisations/IAddress.ts b/src/types/organisations/IAddress.ts
new file mode 100644
index 0000000..ea09c2e
--- /dev/null
+++ b/src/types/organisations/IAddress.ts
@@ -0,0 +1,16 @@
+import { ILocationCoordinates } from "./ILocationCoordinates";
+import { IOpeningTime } from "./IOpeningTime";
+
+export interface IAddress {
+ Street: string;
+ Street1?: string;
+ Street2?: string;
+ Street3?: string;
+ City?: string;
+ Postcode: string;
+ Telephone?: string;
+ IsOpen247?: boolean;
+ IsAppointmentOnly?: boolean;
+ Location?: ILocationCoordinates;
+ OpeningTimes: IOpeningTime[];
+}
\ No newline at end of file
diff --git a/src/types/organisations/IAdministrator.ts b/src/types/organisations/IAdministrator.ts
new file mode 100644
index 0000000..d1a7ba4
--- /dev/null
+++ b/src/types/organisations/IAdministrator.ts
@@ -0,0 +1,4 @@
+export interface IAdministrator {
+ IsSelected: boolean;
+ Email: string;
+}
\ No newline at end of file
diff --git a/src/types/organisations/IGroupedService.ts b/src/types/organisations/IGroupedService.ts
new file mode 100644
index 0000000..f0a5a4d
--- /dev/null
+++ b/src/types/organisations/IGroupedService.ts
@@ -0,0 +1,26 @@
+import { ILocation } from "./ILocation";
+import { IOpeningTime } from "./IOpeningTime";
+import { IServiceSubCategory } from "./IServiceSubCategory";
+
+export interface IGroupedService {
+ _id: string;
+ DocumentCreationDate: Date;
+ DocumentModifiedDate: Date;
+ CreatedBy: string;
+ IsPublished: boolean;
+ IsVerified: boolean;
+ ProviderId: string;
+ ProviderName?: string;
+ CategoryId: string;
+ CategoryName?: string;
+ CategorySynopsis?: string;
+ Info?: string;
+ Tags?: string[];
+ Location: ILocation;
+ IsOpen247: boolean;
+ OpeningTimes?: IOpeningTime[];
+ SubCategories: IServiceSubCategory[];
+ IsTelephoneService?: boolean;
+ IsAppointmentOnly?: boolean;
+ Telephone?: string;
+}
\ No newline at end of file
diff --git a/src/types/organisations/ILocation.ts b/src/types/organisations/ILocation.ts
new file mode 100644
index 0000000..3e9467e
--- /dev/null
+++ b/src/types/organisations/ILocation.ts
@@ -0,0 +1,20 @@
+import { ILocationCoordinates } from "./ILocationCoordinates";
+
+/**
+ * ILocation interface for service locations
+ *
+ * Validation rules:
+ * - If IsOutreachLocation is true: Description is required, address fields are optional
+ * - If IsOutreachLocation is false/undefined: StreetLine1 and Postcode are required
+ */
+export interface ILocation {
+ IsOutreachLocation?: boolean;
+ Description: string; // Required if IsOutreachLocation is true
+ StreetLine1: string; // Required if IsOutreachLocation is false/undefined
+ StreetLine2?: string;
+ StreetLine3?: string;
+ StreetLine4?: string;
+ City?: string;
+ Postcode: string; // Required if IsOutreachLocation is false/undefined
+ Location?: ILocationCoordinates;
+}
\ No newline at end of file
diff --git a/src/types/organisations/ILocationCoordinates.ts b/src/types/organisations/ILocationCoordinates.ts
new file mode 100644
index 0000000..ba33e2e
--- /dev/null
+++ b/src/types/organisations/ILocationCoordinates.ts
@@ -0,0 +1,4 @@
+export interface ILocationCoordinates {
+ type: string;
+ coordinates: [number, number];
+}
\ No newline at end of file
diff --git a/src/types/organisations/INote.ts b/src/types/organisations/INote.ts
new file mode 100644
index 0000000..2ec36a7
--- /dev/null
+++ b/src/types/organisations/INote.ts
@@ -0,0 +1,6 @@
+export interface INote {
+ CreationDate: Date;
+ Date: Date;
+ StaffName: string;
+ Reason: string;
+}
\ No newline at end of file
diff --git a/src/types/organisations/IOpeningTime.ts b/src/types/organisations/IOpeningTime.ts
new file mode 100644
index 0000000..90dd3b9
--- /dev/null
+++ b/src/types/organisations/IOpeningTime.ts
@@ -0,0 +1,5 @@
+export interface IOpeningTime {
+ StartTime: number;
+ EndTime: number;
+ Day: number;
+}
\ No newline at end of file
diff --git a/src/types/organisations/IOrganisation.ts b/src/types/organisations/IOrganisation.ts
new file mode 100644
index 0000000..7c58ed6
--- /dev/null
+++ b/src/types/organisations/IOrganisation.ts
@@ -0,0 +1,73 @@
+import { IAddress } from "./IAddress";
+import { IAdministrator } from "./IAdministrator";
+import { INote } from "./INote";
+import { IOpeningTime } from "./IOpeningTime";
+
+export interface IOrganisation {
+ _id: string;
+ DocumentCreationDate: Date;
+ DocumentModifiedDate: Date;
+ CreatedBy: string;
+ Key: string;
+ AssociatedLocationIds: string[];
+ Name: string;
+ ShortDescription: string;
+ Description: string;
+ IsVerified: boolean;
+ IsPublished: boolean;
+ Tags?: string;
+ Email?: string;
+ Telephone?: string;
+ Website?: string;
+ Facebook?: string;
+ Twitter?: string;
+ Bluesky?: string;
+ Addresses: IAddress[];
+ Notes: INote[];
+ Administrators: IAdministrator[];
+}
+
+// Form-specific version of IOrganisation with required fields and form-friendly types
+export interface IOrganisationFormData extends Omit {
+ // Override Tags to be array instead of string for easier form handling
+ Tags: OrganisationTag[];
+
+ // Override Addresses to use form-friendly version
+ Addresses: IAddressFormData[];
+}
+
+// Form-specific version of IAddress with form-friendly opening times
+export interface IAddressFormData extends Omit {
+ OpeningTimes: IOpeningTimeFormData[];
+}
+
+// Form-specific version of IOpeningTime with string times for easier input handling
+export interface IOpeningTimeFormData extends Omit {
+ StartTime: string; // Format: "HH:MM" for time inputs
+ EndTime: string; // Format: "HH:MM" for time inputs
+}
+
+// Organisation Tag Enum
+export enum OrganisationTag {
+ CHARITY = 'charity',
+ NO_WRONG_DOOR = 'no-wrong-door',
+ COALITION_OF_RELIEF = 'coalition-of-relief',
+ BIG_CHANGE = 'big-change'
+}
+
+export const ORGANISATION_TAGS = [
+ { value: OrganisationTag.CHARITY, label: 'Registered Charity' },
+ { value: OrganisationTag.NO_WRONG_DOOR, label: 'No Wrong Door' },
+ { value: OrganisationTag.COALITION_OF_RELIEF, label: 'Coalition of Relief (mcr only)' },
+ { value: OrganisationTag.BIG_CHANGE, label: 'Big Change (mcr only)' }
+];
+
+export const DAYS_OF_WEEK = [
+ { value: 0, label: 'Sunday' },
+ { value: 1, label: 'Monday' },
+ { value: 2, label: 'Tuesday' },
+ { value: 3, label: 'Wednesday' },
+ { value: 4, label: 'Thursday' },
+ { value: 5, label: 'Friday' },
+ { value: 6, label: 'Saturday' }
+];
diff --git a/src/types/organisations/IServiceCategory.ts b/src/types/organisations/IServiceCategory.ts
new file mode 100644
index 0000000..b510ecc
--- /dev/null
+++ b/src/types/organisations/IServiceCategory.ts
@@ -0,0 +1,13 @@
+// Subcategory as returned from category API (uses Key instead of _id)
+export interface ICategorySubCategory {
+ Key: string;
+ Name: string;
+ Synopsis?: string;
+}
+
+export interface IServiceCategory {
+ _id: string;
+ Name: string;
+ Synopsis: string;
+ SubCategories: ICategorySubCategory[];
+}
\ No newline at end of file
diff --git a/src/types/organisations/IServiceSubCategory.ts b/src/types/organisations/IServiceSubCategory.ts
new file mode 100644
index 0000000..da4aae3
--- /dev/null
+++ b/src/types/organisations/IServiceSubCategory.ts
@@ -0,0 +1,5 @@
+export interface IServiceSubCategory {
+ _id: string;
+ Name: string;
+ Synopsis?: string;
+}
\ No newline at end of file
diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts
deleted file mode 100644
index d6203ba..0000000
--- a/src/utils/apiClient.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { getSession } from 'next-auth/react';
-import { Session } from 'next-auth';
-import type { IUser, IService, IServiceProvider, ICity, IFaq } from '@/types';
-import { HTTP_METHODS } from '@/constants/httpMethods';
-
-const API_BASE_URL = `${process.env.API_BASE_URL}/api`;
-
-class ApiClient {
- private async getAuthHeaders(): Promise> {
- const session: Session | null = await getSession();
- const accessToken = session?.accessToken;
-
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
-
- if (accessToken) {
- headers['Authorization'] = `Bearer ${accessToken}`;
- }
-
- return headers;
- }
-
- private async request(endpoint: string, options: RequestInit = {}): Promise {
- const headers = await this.getAuthHeaders();
-
- const response = await fetch(`${API_BASE_URL}${endpoint}`, {
- ...options,
- headers: {
- ...headers,
- ...options.headers,
- },
- });
-
- if (!response.ok) {
- throw new Error(`API Error: ${response.status} ${response.statusText}`);
- }
-
- return response.json();
- }
-
- // Users API
- users = {
- getAll: () => this.request('/users'),
- getById: (id: string) => this.request(`/users/${id}`),
- getByAuth0Id: (auth0Id: string) => this.request(`/users/auth0/${auth0Id}`),
- create: (data: Partial) => this.request('/users', {
- method: HTTP_METHODS.POST,
- body: JSON.stringify(data),
- }),
- update: (id: string, data: Partial) => this.request(`/users/${id}`, {
- method: HTTP_METHODS.PUT,
- body: JSON.stringify(data),
- }),
- delete: (id: string) => this.request(`/users/${id}`, {
- method: HTTP_METHODS.DELETE,
- }),
- };
-
- // Services API
- services = {
- getAll: () => this.request('/services'),
- getById: (id: string) => this.request(`/services/${id}`),
- getByProvider: (providerId: string) => this.request(`/services/provider/${providerId}`),
- create: (data: Partial) => this.request('/services', {
- method: HTTP_METHODS.POST,
- body: JSON.stringify(data),
- }),
- update: (id: string, data: Partial) => this.request(`/services/${id}`, {
- method: HTTP_METHODS.PUT,
- body: JSON.stringify(data),
- }),
- delete: (id: string) => this.request(`/services/${id}`, {
- method: HTTP_METHODS.DELETE,
- }),
- };
-
-
- // Service Providers API
- serviceProviders = {
- getAll: () => this.request('/service-providers'),
- getById: (id: string) => this.request(`/service-providers/${id}`),
- getByLocation: (locationId: string) => this.request(`/service-providers/location/${locationId}`),
- create: (data: Partial) => this.request('/service-providers', {
- method: HTTP_METHODS.POST,
- body: JSON.stringify(data),
- }),
- update: (id: string, data: Partial) => this.request(`/service-providers/${id}`, {
- method: HTTP_METHODS.PUT,
- body: JSON.stringify(data),
- }),
- delete: (id: string) => this.request(`/service-providers/${id}`, {
- method: HTTP_METHODS.DELETE,
- }),
- };
-
- // Cities API
- cities = {
- getAll: () => this.request('/cities'),
- getById: (id: string) => this.request(`/cities/${id}`),
- };
-
- // FAQs API
- faqs = {
- getAll: () => this.request('/faqs'),
- getById: (id: string) => this.request(`/faqs/${id}`),
- create: (data: Partial) => this.request('/faqs', {
- method: HTTP_METHODS.POST,
- body: JSON.stringify(data),
- }),
- update: (id: string, data: Partial) => this.request(`/faqs/${id}`, {
- method: HTTP_METHODS.PUT,
- body: JSON.stringify(data),
- }),
- delete: (id: string) => this.request(`/faqs/${id}`, {
- method: HTTP_METHODS.DELETE,
- }),
- };
-}
-
-export const apiClient = new ApiClient();
diff --git a/src/utils/csvExport.ts b/src/utils/csvExport.ts
new file mode 100644
index 0000000..11331d8
--- /dev/null
+++ b/src/utils/csvExport.ts
@@ -0,0 +1,172 @@
+import { IOrganisation } from '@/types/organisations/IOrganisation';
+
+/**
+ * Escapes special characters in pipe-delimited fields
+ */
+function escapeCsvField(field: string | number | boolean | null | undefined): string {
+ if (field === null || field === undefined) return '';
+
+ const value = String(field);
+
+ // If field contains pipe, quote, or newline, wrap in quotes and escape internal quotes
+ if (value.includes('|') || value.includes('"') || value.includes('\n')) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+
+ return value;
+}
+
+/**
+ * Formats a date for CSV export
+ */
+function formatDate(date: Date | string | undefined | null): string {
+ if (!date) return '';
+ const d = new Date(date);
+ return d.toLocaleDateString('en-GB');
+}
+
+/**
+ * Formats an address object into a comma-separated string
+ */
+function formatAddress(address: IOrganisation['Addresses'][0] | undefined): string {
+ if (!address) return 'NOT_INITIALISED';
+
+ const parts = [
+ address.Street || '',
+ address.Street1 || '',
+ address.Street2 || '',
+ address.Street3 || '',
+ address.City || '',
+ address.Postcode || '',
+ address.Telephone || ''
+ ].filter(part => part.trim() !== '');
+
+ return parts.length > 0 ? parts.join(', ') : 'NOT_INITIALISED';
+}
+
+/**
+ * Formats administrators array into a string
+ */
+function formatAdministrators(administrators: IOrganisation['Administrators'] | undefined): string {
+ if (!administrators || administrators.length === 0) return 'NOT_INITIALISED';
+
+ return administrators
+ .map(admin => `${admin.Email} (${admin.IsSelected ? 'Selected' : 'Not Selected'})`)
+ .join('; ');
+}
+
+/**
+ * Converts array of organisations to CSV string
+ */
+export function organisationsToCsv(organisations: IOrganisation[]): string {
+ // Find the maximum number of addresses across all organisations
+ const maxAddresses = organisations.reduce((max, org) => {
+ const addressCount = org.Addresses?.length || 0;
+ return Math.max(max, addressCount);
+ }, 0);
+
+ // Build dynamic headers with address columns
+ const headers = [
+ 'Name',
+ 'Key',
+ 'Created Date',
+ 'Modified Date',
+ 'Short Description',
+ 'Verified',
+ 'Published',
+ 'Associated Locations',
+ 'Administrators',
+ ];
+
+ // Add remaining static headers
+ headers.push(
+ 'Email',
+ 'Telephone',
+ 'Website',
+ 'Facebook',
+ 'Twitter',
+ 'Bluesky',
+ 'Tags'
+ );
+
+ // Add dynamic address columns
+ for (let i = 1; i <= maxAddresses; i++) {
+ headers.push(`Address ${i}`);
+ }
+
+ // Create header row with pipe delimiter
+ const csvRows = [headers.join('|')];
+
+ // Add data rows
+ organisations.forEach(org => {
+ const row = [
+ escapeCsvField(org.Name || 'NOT_INITIALISED'),
+ escapeCsvField(org.Key || 'NOT_INITIALISED'),
+ escapeCsvField(formatDate(org.DocumentCreationDate) || 'NOT_INITIALISED'),
+ escapeCsvField(formatDate(org.DocumentModifiedDate) || 'NOT_INITIALISED'),
+ escapeCsvField(org.ShortDescription || 'NOT_INITIALISED'),
+ escapeCsvField(org.IsVerified ? 'Yes' : 'No'),
+ escapeCsvField(org.IsPublished ? 'Yes' : 'No'),
+ escapeCsvField(org.AssociatedLocationIds && org.AssociatedLocationIds.length > 0
+ ? org.AssociatedLocationIds.join(', ')
+ : 'NOT_INITIALISED'),
+ escapeCsvField(formatAdministrators(org.Administrators)),
+ ];
+
+ // Add remaining static fields
+ row.push(
+ escapeCsvField(org.Email || 'NOT_INITIALISED'),
+ escapeCsvField(org.Telephone || 'NOT_INITIALISED'),
+ escapeCsvField(org.Website || 'NOT_INITIALISED'),
+ escapeCsvField(org.Facebook || 'NOT_INITIALISED'),
+ escapeCsvField(org.Twitter || 'NOT_INITIALISED'),
+ escapeCsvField(org.Bluesky || 'NOT_INITIALISED'),
+ escapeCsvField(org.Tags || 'NOT_INITIALISED')
+ );
+
+ // Add dynamic address columns
+ for (let i = 0; i < maxAddresses; i++) {
+ row.push(escapeCsvField(formatAddress(org.Addresses?.[i])));
+ }
+
+ csvRows.push(row.join('|'));
+ });
+
+ return csvRows.join('\n');
+}
+
+/**
+ * Triggers browser download of CSV file
+ */
+export function downloadCsv(csvContent: string, filename: string = 'organisations.csv'): void {
+ // Create blob with UTF-8 BOM for Excel compatibility
+ const BOM = '\uFEFF';
+ const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
+
+ // Create download link
+ const link = document.createElement('a');
+ const url = URL.createObjectURL(blob);
+
+ link.setAttribute('href', url);
+ link.setAttribute('download', filename);
+ link.style.visibility = 'hidden';
+
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // Clean up
+ URL.revokeObjectURL(url);
+}
+
+/**
+ * Exports organisations to CSV and triggers download
+ */
+export function exportOrganisationsToCsv(
+ organisations: IOrganisation[],
+ filename?: string
+): void {
+ const csvContent = organisationsToCsv(organisations);
+ const defaultFilename = `organisations_${new Date().toISOString().split('T')[0]}.csv`;
+ downloadCsv(csvContent, filename || defaultFilename);
+}
diff --git a/src/utils/htmlDecode.ts b/src/utils/htmlDecode.ts
new file mode 100644
index 0000000..f194431
--- /dev/null
+++ b/src/utils/htmlDecode.ts
@@ -0,0 +1,50 @@
+// ✅ src/utils/htmlDecode.ts
+
+export function decodeHtmlEntities(str: string): string {
+ if (!str) return '';
+ return str
+ .replace(/(\d+);/g, (_, dec) => String.fromCharCode(dec))
+ .replace(/([a-fA-F0-9]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&')
+ .replace(/ /g, ' ')
+ .replace(/–/g, '–')
+ .replace(/—/g, '—')
+ .replace(/‘/g, '\u2018')
+ .replace(/’/g, '\u2019')
+ .replace(/“/g, '\u201c')
+ .replace(/”/g, '\u201d')
+ .replace(/…/g, '…')
+ .replace(/•/g, '•')
+ .replace(/£/g, '£')
+ .replace(/€/g, '€')
+ .replace(/©/g, '©')
+ .replace(/®/g, '®')
+ .replace(/™/g, '™');
+}
+
+export function decodeMarkdown(str: string): string {
+ if (!str) return '';
+ return str
+ .replace(/\*\*(.*?)\*\*/g, '$1')
+ .replace(/\*(.*?)\*/g, '$1')
+ .replace(/__(.*?)__/g, '$1')
+ .replace(/_(.*?)_/g, '$1')
+ .replace(/`(.*?)`/g, '$1')
+ .replace(/~~(.*?)~~/g, '$1')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/#{1,6}\s*(.*)/g, '$1')
+ .replace(/^\s*[-*+]\s+/gm, '')
+ .replace(/^\s*\d+\.\s+/gm, '')
+ .replace(/^\s*>\s+/gm, '')
+ .replace(/\n{2,}/g, '\n\n')
+ .trim();
+}
+
+export function decodeText(str: string): string {
+ if (!str) return '';
+ return decodeMarkdown(decodeHtmlEntities(str));
+}
diff --git a/src/utils/postcodeValidation.ts b/src/utils/postcodeValidation.ts
new file mode 100644
index 0000000..886de36
--- /dev/null
+++ b/src/utils/postcodeValidation.ts
@@ -0,0 +1,114 @@
+/**
+ * Postcode validation utilities using postcodes.io API
+ */
+
+export interface PostcodeData {
+ postcode: string;
+ quality: number;
+ eastings: number;
+ northings: number;
+ country: string;
+ nhs_ha: string;
+ longitude: number;
+ latitude: number;
+ european_electoral_region: string;
+ primary_care_trust: string;
+ region: string;
+ lsoa: string;
+ msoa: string;
+ incode: string;
+ outcode: string;
+ parliamentary_constituency: string;
+ admin_district: string;
+ parish: string;
+ admin_county: string;
+ admin_ward: string;
+ ced: string;
+ ccg: string;
+ nuts: string;
+ codes: {
+ admin_district: string;
+ admin_county: string;
+ admin_ward: string;
+ parish: string;
+ parliamentary_constituency: string;
+ ccg: string;
+ ccg_id: string;
+ ced: string;
+ nuts: string;
+ lsoa: string;
+ msoa: string;
+ lau2: string;
+ };
+}
+
+export interface PostcodeResponse {
+ status: number;
+ result: PostcodeData;
+}
+
+/**
+ * Validates a UK postcode using the postcodes.io API
+ * @param postcode - The postcode to validate
+ * @returns Promise - Returns postcode data if valid, null if invalid
+ */
+export async function validatePostcode(postcode: string): Promise {
+ try {
+ // Clean the postcode (remove extra spaces, convert to uppercase)
+ const cleanPostcode = postcode.trim().toUpperCase().replace(/\s+/g, ' ');
+
+ if (!cleanPostcode) {
+ return null;
+ }
+
+ const response = await fetch(`https://api.postcodes.io/postcodes/${encodeURIComponent(cleanPostcode)}`);
+
+ if (response.status === 404) {
+ // Postcode not found
+ return null;
+ }
+
+ if (!response.ok) {
+ // API error
+ console.error('Postcode validation API error:', response.status, response.statusText);
+ return null;
+ }
+
+ const data: PostcodeResponse = await response.json();
+ return data.result;
+
+ } catch (error) {
+ console.error('Error validating postcode:', error);
+ return null;
+ }
+}
+
+/**
+ * Basic UK postcode format validation (regex-based, doesn't check if postcode exists)
+ * @param postcode - The postcode to validate
+ * @returns boolean - True if format is valid
+ */
+export function isValidPostcodeFormat(postcode: string): boolean {
+ // UK postcode regex pattern
+ const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}$/i;
+ return postcodeRegex.test(postcode.trim());
+}
+
+/**
+ * Zod custom validation function for postcodes
+ * Can be used in Zod schemas for async validation
+ */
+export async function zodPostcodeValidation(postcode: string) {
+ // First check basic format
+ if (!isValidPostcodeFormat(postcode)) {
+ throw new Error('Invalid postcode format');
+ }
+
+ // Then validate with API (optional - can be disabled for performance)
+ const result = await validatePostcode(postcode);
+ if (!result) {
+ throw new Error('Postcode not found');
+ }
+
+ return postcode;
+}