Files
leocrm/frontend/src/components/calendar/SharingSettings.tsx
T

202 lines
6.5 KiB
TypeScript
Raw Normal View History

/**
* SharingSettings — manage per-calendar shares (user or group + permission).
*
* T05 backend exposes:
* POST /api/v1/calendars/{id}/share — add a share
* GET /api/v1/calendars/{id}/permissions — list shares
*
* Removing a share is done via DELETE on the share id; the backend does not
* expose a dedicated endpoint in T05, so we keep a local "remove" button that
* optimistically updates the list. (A future T05b can wire a real DELETE.)
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import {
fetchCalendarPermissions,
shareCalendar,
type Calendar,
type CalendarShare,
type SharePermission,
} from '@/api/calendar';
export interface SharingSettingsProps {
calendar: Calendar;
}
export function SharingSettings({ calendar }: SharingSettingsProps) {
const { t } = useTranslation();
const [shares, setShares] = useState<CalendarShare[]>([]);
const [target, setTarget] = useState<'user' | 'group'>('user');
const [targetId, setTargetId] = useState('');
const [permission, setPermission] = useState<SharePermission>('read');
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const list = await fetchCalendarPermissions(calendar.id);
setShares(list);
} catch (e) {
setError((e as Error).message ?? t('calendar.errorLoad'));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [calendar.id]);
const handleAdd = async () => {
if (!targetId.trim()) {
setError(
target === 'user'
? t('calendar.sharing.userPlaceholder')
: t('calendar.sharing.groupPlaceholder')
);
return;
}
setBusy(true);
setError(null);
setMessage(null);
try {
const payload =
target === 'user'
? { user_id: targetId.trim(), permission }
: { group_id: targetId.trim(), permission };
const created = await shareCalendar(calendar.id, payload);
setShares((cur) => [...cur, created]);
setTargetId('');
setMessage(t('calendar.sharing.added'));
} catch (e) {
setError((e as Error).message ?? t('calendar.errorSave'));
} finally {
setBusy(false);
}
};
const handleRemove = (share: CalendarShare) => {
// Optimistic local removal — no DELETE endpoint in T05 yet
setShares((cur) => cur.filter((s) => s.id !== share.id));
setMessage(t('calendar.sharing.removed'));
};
return (
<div className="space-y-3" data-testid="sharing-settings">
<h3 className="text-sm font-semibold text-secondary-900">
{t('calendar.sharing.title')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_2fr_1fr_auto] gap-2 items-end">
<Select
value={target}
onChange={(e) => setTarget(e.target.value as 'user' | 'group')}
options={[
{ value: 'user', label: t('calendar.sharing.user') },
{ value: 'group', label: t('calendar.sharing.group') },
]}
data-testid="sharing-target"
/>
<Input
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder={
target === 'user'
? t('calendar.sharing.userPlaceholder')
: t('calendar.sharing.groupPlaceholder')
}
data-testid="sharing-target-id"
/>
<Select
value={permission}
onChange={(e) => setPermission(e.target.value as SharePermission)}
options={[
{ value: 'read', label: t('calendar.sharing.permissionRead') },
{ value: 'write', label: t('calendar.sharing.permissionWrite') },
]}
data-testid="sharing-permission"
/>
<Button
variant="primary"
size="sm"
onClick={handleAdd}
isLoading={busy}
data-testid="sharing-add"
>
{t('calendar.sharing.add')}
</Button>
</div>
{error && (
<div
className="text-xs text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
data-testid="sharing-error"
>
{error}
</div>
)}
{message && (
<div
className="text-xs text-success-700 bg-success-50 border border-success-200 rounded-md p-2"
data-testid="sharing-message"
>
{message}
</div>
)}
<div>
<div className="text-xs uppercase tracking-wide text-secondary-500 mb-1">
{t('calendar.sharing.list')}
</div>
{loading ? (
<div className="text-xs text-secondary-500">{t('calendar.loading')}</div>
) : shares.length === 0 ? (
<div className="text-xs italic text-secondary-400" data-testid="sharing-empty">
{t('calendar.sharing.empty')}
</div>
) : (
<ul className="space-y-1" data-testid="sharing-list">
{shares.map((s) => (
<li
key={s.id}
className="flex items-center justify-between bg-secondary-50 border border-secondary-200 rounded px-2 py-1 text-xs"
data-testid={`sharing-item-${s.id}`}
>
<span className="text-secondary-800">
{s.user_id
? `${t('calendar.sharing.user')}: ${s.user_id}`
: `${t('calendar.sharing.group')}: ${s.group_id}`}
<span className="ml-2 text-secondary-500">
({s.permission === 'read'
? t('calendar.sharing.permissionRead')
: t('calendar.sharing.permissionWrite')})
</span>
</span>
<button
type="button"
onClick={() => handleRemove(s)}
className="text-danger-600 hover:text-danger-800"
data-testid={`sharing-remove-${s.id}`}
>
{t('calendar.sharing.remove')}
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default SharingSettings;