298 lines
9.2 KiB
TypeScript
298 lines
9.2 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useEffect } from 'react';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Label } from '@/components/ui/label';
|
||
|
|
import { Textarea } from '@/components/ui/textarea';
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
||
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||
|
|
import {
|
||
|
|
Accordion,
|
||
|
|
AccordionContent,
|
||
|
|
AccordionItem,
|
||
|
|
AccordionTrigger,
|
||
|
|
} from '@/components/ui/accordion';
|
||
|
|
import { apiFetch } from '@/lib/api/client';
|
||
|
|
|
||
|
|
/** Default permissions structure matching RolePermissions type */
|
||
|
|
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||
|
|
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||
|
|
interests: {
|
||
|
|
view: false,
|
||
|
|
create: false,
|
||
|
|
edit: false,
|
||
|
|
delete: false,
|
||
|
|
change_stage: false,
|
||
|
|
generate_eoi: false,
|
||
|
|
export: false,
|
||
|
|
},
|
||
|
|
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||
|
|
documents: {
|
||
|
|
view: false,
|
||
|
|
create: false,
|
||
|
|
send_for_signing: false,
|
||
|
|
upload_signed: false,
|
||
|
|
delete: false,
|
||
|
|
},
|
||
|
|
expenses: {
|
||
|
|
view: false,
|
||
|
|
create: false,
|
||
|
|
edit: false,
|
||
|
|
delete: false,
|
||
|
|
export: false,
|
||
|
|
scan_receipt: false,
|
||
|
|
},
|
||
|
|
invoices: {
|
||
|
|
view: false,
|
||
|
|
create: false,
|
||
|
|
edit: false,
|
||
|
|
delete: false,
|
||
|
|
send: false,
|
||
|
|
record_payment: false,
|
||
|
|
export: false,
|
||
|
|
},
|
||
|
|
files: { view: false, upload: false, delete: false, manage_folders: false },
|
||
|
|
email: { view: false, send: false, configure_account: false },
|
||
|
|
reminders: {
|
||
|
|
view_own: false,
|
||
|
|
view_all: false,
|
||
|
|
create: false,
|
||
|
|
edit_own: false,
|
||
|
|
edit_all: false,
|
||
|
|
assign_others: false,
|
||
|
|
},
|
||
|
|
calendar: { connect: false, view_events: false },
|
||
|
|
reports: { view_dashboard: false, view_analytics: false, export: false },
|
||
|
|
document_templates: { view: false, generate: false, manage: false },
|
||
|
|
admin: {
|
||
|
|
manage_users: false,
|
||
|
|
view_audit_log: false,
|
||
|
|
manage_settings: false,
|
||
|
|
manage_webhooks: false,
|
||
|
|
manage_reports: false,
|
||
|
|
manage_custom_fields: false,
|
||
|
|
manage_forms: false,
|
||
|
|
manage_tags: false,
|
||
|
|
system_backup: false,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const GROUP_LABELS: Record<string, string> = {
|
||
|
|
clients: 'Clients',
|
||
|
|
interests: 'Interests / Pipeline',
|
||
|
|
berths: 'Berths',
|
||
|
|
documents: 'Documents',
|
||
|
|
expenses: 'Expenses',
|
||
|
|
invoices: 'Invoices',
|
||
|
|
files: 'Files',
|
||
|
|
email: 'Email',
|
||
|
|
reminders: 'Reminders',
|
||
|
|
calendar: 'Calendar',
|
||
|
|
reports: 'Reports',
|
||
|
|
document_templates: 'Document Templates',
|
||
|
|
admin: 'Administration',
|
||
|
|
};
|
||
|
|
|
||
|
|
function formatAction(action: string): string {
|
||
|
|
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||
|
|
}
|
||
|
|
|
||
|
|
interface RoleFormProps {
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
role?: {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
description: string | null;
|
||
|
|
isSystem: boolean;
|
||
|
|
permissions: Record<string, Record<string, boolean>>;
|
||
|
|
} | null;
|
||
|
|
onSuccess: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
|
||
|
|
const [name, setName] = useState('');
|
||
|
|
const [description, setDescription] = useState('');
|
||
|
|
const [permissions, setPermissions] = useState<Record<string, Record<string, boolean>>>(
|
||
|
|
structuredClone(DEFAULT_PERMISSIONS),
|
||
|
|
);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const isEdit = !!role;
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (open) {
|
||
|
|
if (role) {
|
||
|
|
setName(role.name);
|
||
|
|
setDescription(role.description ?? '');
|
||
|
|
// Merge role permissions over defaults to fill any missing keys
|
||
|
|
const merged = structuredClone(DEFAULT_PERMISSIONS);
|
||
|
|
for (const [group, actions] of Object.entries(role.permissions)) {
|
||
|
|
if (merged[group]) {
|
||
|
|
for (const [action, value] of Object.entries(actions as Record<string, boolean>)) {
|
||
|
|
merged[group]![action] = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
setPermissions(merged);
|
||
|
|
} else {
|
||
|
|
setName('');
|
||
|
|
setDescription('');
|
||
|
|
setPermissions(structuredClone(DEFAULT_PERMISSIONS));
|
||
|
|
}
|
||
|
|
setError(null);
|
||
|
|
}
|
||
|
|
}, [open, role]);
|
||
|
|
|
||
|
|
function togglePermission(group: string, action: string) {
|
||
|
|
setPermissions((prev) => {
|
||
|
|
const next = structuredClone(prev);
|
||
|
|
next[group]![action] = !next[group]![action];
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleGroup(group: string, value: boolean) {
|
||
|
|
setPermissions((prev) => {
|
||
|
|
const next = structuredClone(prev);
|
||
|
|
for (const action of Object.keys(next[group]!)) {
|
||
|
|
next[group]![action] = value;
|
||
|
|
}
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function isGroupAllChecked(group: string): boolean {
|
||
|
|
return Object.values(permissions[group]!).every(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isGroupPartial(group: string): boolean {
|
||
|
|
const vals = Object.values(permissions[group]!);
|
||
|
|
return vals.some(Boolean) && !vals.every(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSubmit(e: React.FormEvent) {
|
||
|
|
e.preventDefault();
|
||
|
|
setError(null);
|
||
|
|
setLoading(true);
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (isEdit) {
|
||
|
|
await apiFetch(`/api/v1/admin/roles/${role.id}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
body: { name, description: description || null, permissions },
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
await apiFetch('/api/v1/admin/roles', {
|
||
|
|
method: 'POST',
|
||
|
|
body: { name, description: description || undefined, permissions },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
onSuccess();
|
||
|
|
onOpenChange(false);
|
||
|
|
} catch (err: unknown) {
|
||
|
|
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||
|
|
setError(message);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
|
|
<SheetContent className="w-[500px] sm:max-w-[500px]">
|
||
|
|
<SheetHeader>
|
||
|
|
<SheetTitle>{isEdit ? 'Edit Role' : 'New Role'}</SheetTitle>
|
||
|
|
</SheetHeader>
|
||
|
|
|
||
|
|
<form onSubmit={handleSubmit} className="mt-6 flex flex-col h-[calc(100vh-140px)]">
|
||
|
|
<div className="space-y-4 mb-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="role-name">Name</Label>
|
||
|
|
<Input
|
||
|
|
id="role-name"
|
||
|
|
value={name}
|
||
|
|
onChange={(e) => setName(e.target.value)}
|
||
|
|
placeholder="e.g. Sales Manager"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="role-description">Description</Label>
|
||
|
|
<Textarea
|
||
|
|
id="role-description"
|
||
|
|
value={description}
|
||
|
|
onChange={(e) => setDescription(e.target.value)}
|
||
|
|
placeholder="What this role is for..."
|
||
|
|
rows={2}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Label className="mb-2">Permissions</Label>
|
||
|
|
<ScrollArea className="flex-1 rounded-md border">
|
||
|
|
<Accordion type="multiple" className="px-3">
|
||
|
|
{Object.entries(permissions).map(([group, actions]) => (
|
||
|
|
<AccordionItem key={group} value={group}>
|
||
|
|
<AccordionTrigger className="text-sm">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<Checkbox
|
||
|
|
checked={isGroupAllChecked(group)}
|
||
|
|
ref={(el) => {
|
||
|
|
if (el) {
|
||
|
|
(el as unknown as HTMLInputElement).indeterminate =
|
||
|
|
isGroupPartial(group);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onCheckedChange={(checked) => toggleGroup(group, checked === true)}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
/>
|
||
|
|
<span>{GROUP_LABELS[group] ?? group}</span>
|
||
|
|
</div>
|
||
|
|
</AccordionTrigger>
|
||
|
|
<AccordionContent>
|
||
|
|
<div className="grid grid-cols-2 gap-2 pl-8 pb-2">
|
||
|
|
{Object.entries(actions).map(([action, checked]) => (
|
||
|
|
<label
|
||
|
|
key={action}
|
||
|
|
className="flex items-center gap-2 text-sm cursor-pointer"
|
||
|
|
>
|
||
|
|
<Checkbox
|
||
|
|
checked={checked}
|
||
|
|
onCheckedChange={() => togglePermission(group, action)}
|
||
|
|
/>
|
||
|
|
{formatAction(action)}
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</AccordionContent>
|
||
|
|
</AccordionItem>
|
||
|
|
))}
|
||
|
|
</Accordion>
|
||
|
|
</ScrollArea>
|
||
|
|
|
||
|
|
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||
|
|
|
||
|
|
<SheetFooter className="mt-4">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => onOpenChange(false)}
|
||
|
|
disabled={loading}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button type="submit" disabled={loading || !name.trim()}>
|
||
|
|
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Role'}
|
||
|
|
</Button>
|
||
|
|
</SheetFooter>
|
||
|
|
</form>
|
||
|
|
</SheetContent>
|
||
|
|
</Sheet>
|
||
|
|
);
|
||
|
|
}
|