refactor phase 6

This commit is contained in:
alorig
2025-11-20 21:47:03 +05:00
parent b0409d965b
commit 45dc0d1fa2
5 changed files with 305 additions and 121 deletions

View File

@@ -3,6 +3,7 @@
* Displays both site switcher and sector selector side by side with accent colors * Displays both site switcher and sector selector side by side with accent colors
*/ */
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router';
import { Dropdown } from '../ui/dropdown/Dropdown'; import { Dropdown } from '../ui/dropdown/Dropdown';
import { DropdownItem } from '../ui/dropdown/DropdownItem'; import { DropdownItem } from '../ui/dropdown/DropdownItem';
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api'; import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api';
@@ -10,6 +11,7 @@ import { useToast } from '../ui/toast/ToastContainer';
import { useSiteStore } from '../../store/siteStore'; import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import Button from '../ui/button/Button';
interface SiteAndSectorSelectorProps { interface SiteAndSectorSelectorProps {
hideSectorSelector?: boolean; hideSectorSelector?: boolean;
@@ -19,6 +21,7 @@ export default function SiteAndSectorSelector({
hideSectorSelector = false, hideSectorSelector = false,
}: SiteAndSectorSelectorProps) { }: SiteAndSectorSelectorProps) {
const toast = useToast(); const toast = useToast();
const navigate = useNavigate();
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore(); const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore(); const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore();
const { user, refreshUser, isAuthenticated } = useAuthStore(); const { user, refreshUser, isAuthenticated } = useAuthStore();
@@ -32,6 +35,7 @@ export default function SiteAndSectorSelector({
// Sector selector state // Sector selector state
const [sectorsOpen, setSectorsOpen] = useState(false); const [sectorsOpen, setSectorsOpen] = useState(false);
const sectorButtonRef = useRef<HTMLButtonElement>(null); const sectorButtonRef = useRef<HTMLButtonElement>(null);
const noSitesAvailable = !sitesLoading && sites.length === 0;
// Load sites // Load sites
useEffect(() => { useEffect(() => {
@@ -90,9 +94,25 @@ export default function SiteAndSectorSelector({
} }
}; };
// Don't render if no active site const handleCreateSite = () => navigate('/sites');
if (!activeSite) {
return null; if (sitesLoading && sites.length === 0) {
return (
<div className="text-sm text-gray-500 dark:text-gray-400">
Loading sites...
</div>
);
}
if (noSitesAvailable) {
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<span>No active sites yet. Create a site to unlock planner and writer modules.</span>
<Button size="sm" variant="primary" onClick={handleCreateSite}>
Create Site
</Button>
</div>
);
} }
return ( return (
@@ -176,106 +196,122 @@ export default function SiteAndSectorSelector({
</div> </div>
{/* Sector Selector */} {/* Sector Selector */}
{!hideSectorSelector && !sectorsLoading && sectors.length > 0 && ( {!hideSectorSelector && (
<div className="relative inline-block"> <div className="relative inline-block">
<button {!activeSite ? (
ref={sectorButtonRef} <div className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 rounded-lg">
onClick={() => setSectorsOpen(!sectorsOpen)} Select a site to choose sectors
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle" </div>
aria-label="Select sector" ) : sectorsLoading ? (
disabled={sectorsLoading || sectors.length === 0} <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 border border-brand-200 rounded-lg">
> Loading sectors...
<span className="flex items-center gap-2"> </div>
<svg ) : sectors.length === 0 ? (
className="w-4 h-4 text-brand-500 dark:text-brand-400" <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 rounded-lg">
fill="none" This site has no sectors yet.
stroke="currentColor" </div>
viewBox="0 0 24 24" ) : (
<>
<button
ref={sectorButtonRef}
onClick={() => setSectorsOpen(!sectorsOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle"
aria-label="Select sector"
disabled={sectorsLoading || sectors.length === 0}
> >
<path <span className="flex items-center gap-2">
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<span className="max-w-[150px] truncate">
{sectorsLoading ? 'Loading...' : activeSector?.name || 'All Sectors'}
</span>
</span>
<svg
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sectorsOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Dropdown
isOpen={sectorsOpen}
onClose={() => setSectorsOpen(false)}
anchorRef={sectorButtonRef}
placement="bottom-right"
className="w-64 p-2 overflow-y-auto max-h-[300px]"
>
{/* "All Sectors" option */}
<DropdownItem
onItemClick={() => handleSectorSelect(null)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
!activeSector
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">All Sectors</span>
{!activeSector && (
<svg
className="w-4 h-4 text-brand-600 dark:text-brand-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
{sectors.map((sector) => (
<DropdownItem
key={sector.id}
onItemClick={() => handleSectorSelect(sector.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
activeSector?.id === sector.id
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">{sector.name}</span>
{activeSector?.id === sector.id && (
<svg <svg
className="w-4 h-4 text-brand-600 dark:text-brand-400" className="w-4 h-4 text-brand-500 dark:text-brand-400"
fill="currentColor" fill="none"
viewBox="0 0 20 20" stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <path
fillRule="evenodd" strokeLinecap="round"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" strokeLinejoin="round"
clipRule="evenodd" strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/> />
</svg> </svg>
)} <span className="max-w-[150px] truncate">
</DropdownItem> {activeSector?.name || 'All Sectors'}
))} </span>
</Dropdown> </span>
<svg
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sectorsOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Dropdown
isOpen={sectorsOpen}
onClose={() => setSectorsOpen(false)}
anchorRef={sectorButtonRef}
placement="bottom-right"
className="w-64 p-2 overflow-y-auto max-h-[300px]"
>
{/* "All Sectors" option */}
<DropdownItem
onItemClick={() => handleSectorSelect(null)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
!activeSector
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">All Sectors</span>
{!activeSector && (
<svg
className="w-4 h-4 text-brand-600 dark:text-brand-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
{sectors.map((sector) => (
<DropdownItem
key={sector.id}
onItemClick={() => handleSectorSelect(sector.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
activeSector?.id === sector.id
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">{sector.name}</span>
{activeSector?.id === sector.id && (
<svg
className="w-4 h-4 text-brand-600 dark:text-brand-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
))}
</Dropdown>
</>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useLocation } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { Dropdown } from "../ui/dropdown/Dropdown"; import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem"; import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from "../../services/api"; import { fetchSites, Site, setActiveSite as apiSetActiveSite } from "../../services/api";
import { useToast } from "../ui/toast/ToastContainer"; import { useToast } from "../ui/toast/ToastContainer";
import { useSiteStore } from "../../store/siteStore"; import { useSiteStore } from "../../store/siteStore";
import { useAuthStore } from "../../store/authStore"; import { useAuthStore } from "../../store/authStore";
import Button from "../ui/button/Button";
/** /**
* SiteSwitcher Component * SiteSwitcher Component
@@ -45,6 +46,7 @@ interface SiteSwitcherProps {
export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) { export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore(); const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
const { user, refreshUser, isAuthenticated } = useAuthStore(); const { user, refreshUser, isAuthenticated } = useAuthStore();
@@ -131,9 +133,22 @@ export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) {
return null; return null;
} }
// Don't render if loading or no sites const noSitesAvailable = !loading && sites.length === 0;
if (loading || sites.length === 0) {
return null; if (loading && sites.length === 0) {
return (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 px-3 py-2 border border-dashed border-gray-300 rounded-lg">
Loading sites...
</div>
);
}
if (noSitesAvailable) {
return (
<Button size="sm" variant="outline" onClick={() => navigate('/sites')}>
Create Site
</Button>
);
} }
return ( return (

View File

@@ -19,7 +19,8 @@ import {
SeedKeyword, SeedKeyword,
fetchAccountSetting, fetchAccountSetting,
createAccountSetting, createAccountSetting,
updateAccountSetting updateAccountSetting,
AccountSettingsError
} from '../../services/api'; } from '../../services/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
@@ -53,6 +54,15 @@ const formatVolume = (volume: number): string => {
return volume.toString(); return volume.toString();
}; };
const getAccountSettingsPreferenceMessage = (error: AccountSettingsError): string => {
switch (error.type) {
case 'ACCOUNT_SETTINGS_VALIDATION_ERROR':
return error.message || 'The saved preferences could not be loaded because the data is invalid.';
default:
return error.message || 'Unable to load your saved preferences right now.';
}
};
export default function IndustriesSectorsKeywords() { export default function IndustriesSectorsKeywords() {
const toast = useToast(); const toast = useToast();
const { pageSize } = usePageSizeStore(); const { pageSize } = usePageSizeStore();
@@ -98,9 +108,18 @@ export default function IndustriesSectorsKeywords() {
} }
} }
} }
} catch (error) { } catch (error: any) {
// If preferences don't exist yet, that's fine if (error instanceof AccountSettingsError) {
console.log('No user preferences found'); if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') {
console.debug('No saved user preferences yet.');
return;
}
console.warn('Failed to load user preferences:', error);
toast.error(getAccountSettingsPreferenceMessage(error));
return;
}
console.warn('Failed to load user preferences:', error);
toast.error('Unable to load your saved preferences right now.');
} }
}; };
@@ -266,8 +285,7 @@ export default function IndustriesSectorsKeywords() {
is_active: true, is_active: true,
}); });
} catch (error: any) { } catch (error: any) {
// If setting doesn't exist, create it if (error instanceof AccountSettingsError && error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') {
if (error.status === 404) {
await createAccountSetting({ await createAccountSetting({
key: 'user_preferences', key: 'user_preferences',
config: preferences, config: preferences,
@@ -280,7 +298,11 @@ export default function IndustriesSectorsKeywords() {
toast.success('Preferences saved successfully! These will be used when creating new sites.'); toast.success('Preferences saved successfully! These will be used when creating new sites.');
} catch (error: any) { } catch (error: any) {
toast.error(`Failed to save preferences: ${error.message}`); if (error instanceof AccountSettingsError) {
toast.error(getAccountSettingsPreferenceMessage(error));
} else {
toast.error(`Failed to save preferences: ${error.message}`);
}
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@@ -1476,32 +1476,108 @@ export interface AccountSettingsResponse {
results: AccountSetting[]; results: AccountSetting[];
} }
export type AccountSettingsErrorType =
| 'ACCOUNT_SETTINGS_API_ERROR'
| 'ACCOUNT_SETTINGS_NOT_FOUND'
| 'ACCOUNT_SETTINGS_VALIDATION_ERROR';
export class AccountSettingsError extends Error {
type: AccountSettingsErrorType;
status?: number;
details?: unknown;
constructor(type: AccountSettingsErrorType, message: string, status?: number, details?: unknown) {
super(message);
this.name = 'AccountSettingsError';
this.type = type;
this.status = status;
this.details = details;
}
}
function buildAccountSettingsError(error: any, fallbackMessage: string): AccountSettingsError {
const status = error?.status;
const response = error?.response;
const details = response || error;
if (status === 404) {
return new AccountSettingsError(
'ACCOUNT_SETTINGS_NOT_FOUND',
'No account settings were found for this account yet.',
status,
details
);
}
if (status === 400 || response?.errors) {
const validationMessage =
response?.error ||
response?.message ||
response?.detail ||
'The account settings request is invalid. Please review the submitted data.';
return new AccountSettingsError(
'ACCOUNT_SETTINGS_VALIDATION_ERROR',
validationMessage,
status,
details
);
}
return new AccountSettingsError(
'ACCOUNT_SETTINGS_API_ERROR',
error?.message || fallbackMessage,
status,
details
);
}
export async function fetchAccountSettings(): Promise<AccountSettingsResponse> { export async function fetchAccountSettings(): Promise<AccountSettingsResponse> {
return fetchAPI('/v1/system/settings/account/'); try {
return await fetchAPI('/v1/system/settings/account/');
} catch (error: any) {
throw buildAccountSettingsError(error, 'Unable to load account settings right now.');
}
} }
export async function fetchAccountSetting(key: string): Promise<AccountSetting> { export async function fetchAccountSetting(key: string): Promise<AccountSetting> {
return fetchAPI(`/v1/system/settings/account/${key}/`); try {
return await fetchAPI(`/v1/system/settings/account/${key}/`);
} catch (error: any) {
throw buildAccountSettingsError(error, `Account setting "${key}" is not available.`);
}
} }
export async function createAccountSetting(data: { key: string; config: Record<string, any>; is_active?: boolean }): Promise<AccountSetting> { export async function createAccountSetting(data: { key: string; config: Record<string, any>; is_active?: boolean }): Promise<AccountSetting> {
return fetchAPI('/v1/system/settings/account/', { try {
method: 'POST', return await fetchAPI('/v1/system/settings/account/', {
body: JSON.stringify(data), method: 'POST',
}); body: JSON.stringify(data),
});
} catch (error: any) {
throw buildAccountSettingsError(error, 'Unable to create the account setting.');
}
} }
export async function updateAccountSetting(key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<AccountSetting> { export async function updateAccountSetting(key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<AccountSetting> {
return fetchAPI(`/v1/system/settings/account/${key}/`, { try {
method: 'PUT', return await fetchAPI(`/v1/system/settings/account/${key}/`, {
body: JSON.stringify(data), method: 'PUT',
}); body: JSON.stringify(data),
});
} catch (error: any) {
throw buildAccountSettingsError(error, `Unable to update account setting "${key}".`);
}
} }
export async function deleteAccountSetting(key: string): Promise<void> { export async function deleteAccountSetting(key: string): Promise<void> {
return fetchAPI(`/v1/system/settings/account/${key}/`, { try {
method: 'DELETE', await fetchAPI(`/v1/system/settings/account/${key}/`, {
}); method: 'DELETE',
});
} catch (error: any) {
throw buildAccountSettingsError(error, `Unable to delete account setting "${key}".`);
}
} }
// Module Settings // Module Settings

View File

@@ -18,8 +18,20 @@ import {
AccountSetting, AccountSetting,
ModuleSetting, ModuleSetting,
ModuleEnableSettings, ModuleEnableSettings,
AccountSettingsError,
} from '../services/api'; } from '../services/api';
const getAccountSettingsErrorMessage = (error: AccountSettingsError): string => {
switch (error.type) {
case 'ACCOUNT_SETTINGS_NOT_FOUND':
return 'No account-level settings have been created yet.';
case 'ACCOUNT_SETTINGS_VALIDATION_ERROR':
return error.message || 'The account settings request contained invalid data.';
default:
return error.message || 'Unexpected error while loading account settings.';
}
};
interface SettingsState { interface SettingsState {
accountSettings: Record<string, AccountSetting>; accountSettings: Record<string, AccountSetting>;
moduleSettings: Record<string, Record<string, ModuleSetting>>; moduleSettings: Record<string, Record<string, ModuleSetting>>;
@@ -58,7 +70,17 @@ export const useSettingsStore = create<SettingsState>()(
}); });
set({ accountSettings: settingsMap, loading: false }); set({ accountSettings: settingsMap, loading: false });
} catch (error: any) { } catch (error: any) {
set({ error: error.message, loading: false }); if (error instanceof AccountSettingsError) {
const message = getAccountSettingsErrorMessage(error);
// Not found should not be treated as failure just indicate no settings yet
if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') {
set({ accountSettings: {}, loading: false, error: null });
} else {
set({ error: message, loading: false });
}
} else {
set({ error: error.message, loading: false });
}
} }
}, },
@@ -69,6 +91,14 @@ export const useSettingsStore = create<SettingsState>()(
accountSettings: { ...state.accountSettings, [key]: setting } accountSettings: { ...state.accountSettings, [key]: setting }
})); }));
} catch (error: any) { } catch (error: any) {
if (error instanceof AccountSettingsError) {
if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') {
// Silently ignore missing setting it just hasn't been created yet
return;
}
set({ error: getAccountSettingsErrorMessage(error) });
return;
}
set({ error: error.message }); set({ error: error.message });
} }
}, },
@@ -90,6 +120,11 @@ export const useSettingsStore = create<SettingsState>()(
loading: false loading: false
})); }));
} catch (error: any) { } catch (error: any) {
if (error instanceof AccountSettingsError) {
const message = getAccountSettingsErrorMessage(error);
set({ error: message, loading: false });
throw error;
}
set({ error: error.message, loading: false }); set({ error: error.message, loading: false });
throw error; throw error;
} }