improvement: some files replaced, lots of them i shall say. AGENT.MD file created. some rules and skills added for cursor agent.
This commit is contained in:
@@ -1,190 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Check, Copy, Pencil, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import type { StaffMemberDto } from '@/lib/api/staff';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
|
||||
type StaffMembersMobileListProps = {
|
||||
members: StaffMemberDto[];
|
||||
canEdit: boolean;
|
||||
organizationType: string | undefined;
|
||||
copiedInviteMembershipId: string | null;
|
||||
copyingInviteMembershipId: string | null;
|
||||
enablingMembershipId: string | null;
|
||||
disablingMembershipId: string | null;
|
||||
formatAccessSummary: (member: StaffMemberDto) => string;
|
||||
canShareInviteLink: (member: StaffMemberDto) => boolean;
|
||||
canEnable: (member: StaffMemberDto) => boolean;
|
||||
canDisable: (member: StaffMemberDto) => boolean;
|
||||
onCopyInviteLink: (member: StaffMemberDto) => void;
|
||||
onEnable: (member: StaffMemberDto) => void;
|
||||
onDisable: (member: StaffMemberDto) => void;
|
||||
onEdit: (member: StaffMemberDto) => void;
|
||||
onDelete: (member: StaffMemberDto) => void;
|
||||
labels: {
|
||||
roleOwner: string;
|
||||
roleStaff: string;
|
||||
statusActive: string;
|
||||
statusPending: string;
|
||||
statusDisabled: string;
|
||||
statusExpired: string;
|
||||
allFeatures: string;
|
||||
copyInviteLink: string;
|
||||
enableMemberTitle: string;
|
||||
disableMemberTitle: string;
|
||||
editMemberAria: string;
|
||||
deleteMemberAria: string;
|
||||
};
|
||||
};
|
||||
|
||||
function memberStatusBadge(
|
||||
member: StaffMemberDto,
|
||||
labels: StaffMembersMobileListProps['labels'],
|
||||
) {
|
||||
if (member.isOwner || member.invitationStatus === 'ACTIVE') {
|
||||
return <Badge variant="success">{labels.statusActive}</Badge>;
|
||||
}
|
||||
if (member.invitationStatus === 'PENDING') {
|
||||
return <Badge variant="warning">{labels.statusPending}</Badge>;
|
||||
}
|
||||
if (member.invitationStatus === 'DISABLED') {
|
||||
return <Badge variant="default">{labels.statusDisabled}</Badge>;
|
||||
}
|
||||
return <Badge variant="danger">{labels.statusExpired}</Badge>;
|
||||
}
|
||||
|
||||
export function StaffMembersMobileList({
|
||||
members,
|
||||
canEdit,
|
||||
copiedInviteMembershipId,
|
||||
copyingInviteMembershipId,
|
||||
enablingMembershipId,
|
||||
disablingMembershipId,
|
||||
formatAccessSummary,
|
||||
canShareInviteLink,
|
||||
canEnable,
|
||||
canDisable,
|
||||
onCopyInviteLink,
|
||||
onEnable,
|
||||
onDisable,
|
||||
onEdit,
|
||||
onDelete,
|
||||
labels,
|
||||
}: StaffMembersMobileListProps) {
|
||||
return (
|
||||
<ul className="space-y-3 lg:hidden">
|
||||
{members.map((member) => (
|
||||
<li key={member.id}>
|
||||
<Card padding="sm" className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text-primary truncate">{member.name}</p>
|
||||
<p className="text-sm text-text-secondary truncate mt-0.5">{member.email}</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{member.isOwner ? labels.roleOwner : labels.roleStaff}
|
||||
</p>
|
||||
</div>
|
||||
{memberStatusBadge(member, labels)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-secondary line-clamp-3">
|
||||
{member.isOwner ? labels.allFeatures : formatAccessSummary(member)}
|
||||
</p>
|
||||
|
||||
{!member.isOwner ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1 border-t border-border/60">
|
||||
{canShareInviteLink(member) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
||||
disabled={copyingInviteMembershipId === member.id}
|
||||
onClick={() => onCopyInviteLink(member)}
|
||||
aria-label={labels.copyInviteLink}
|
||||
title={labels.copyInviteLink}
|
||||
>
|
||||
{copiedInviteMembershipId === member.id ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{canEnable(member) ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-md ${
|
||||
canEdit
|
||||
? 'text-text-secondary hover:bg-background-card/80 hover:text-primary'
|
||||
: 'text-text-muted opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
aria-label={labels.enableMemberTitle}
|
||||
disabled={!canEdit || enablingMembershipId === member.id}
|
||||
title={labels.enableMemberTitle}
|
||||
onClick={() => {
|
||||
if (!canEdit) return;
|
||||
onEnable(member);
|
||||
}}
|
||||
>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{canDisable(member) ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-md ${
|
||||
canEdit
|
||||
? 'text-text-secondary hover:bg-background-card/80 hover:text-amber-600'
|
||||
: 'text-text-muted opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
aria-label={labels.disableMemberTitle}
|
||||
disabled={!canEdit || disablingMembershipId === member.id}
|
||||
title={labels.disableMemberTitle}
|
||||
onClick={() => {
|
||||
if (!canEdit) return;
|
||||
onDisable(member);
|
||||
}}
|
||||
>
|
||||
<UserX className="w-4 h-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-md ${
|
||||
canEdit
|
||||
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
|
||||
: 'text-text-muted opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
aria-label={labels.editMemberAria}
|
||||
disabled={!canEdit}
|
||||
onClick={() => {
|
||||
if (!canEdit) return;
|
||||
onEdit(member);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-md ${
|
||||
canEdit
|
||||
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
|
||||
: 'text-text-muted opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
aria-label={labels.deleteMemberAria}
|
||||
disabled={!canEdit}
|
||||
onClick={() => {
|
||||
if (!canEdit) return;
|
||||
onDelete(member);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor';
|
||||
import {
|
||||
blocksFromEditorDays,
|
||||
editorDaysFromBlocks,
|
||||
emptyWorkingHoursEditorDays,
|
||||
validateEditorDays,
|
||||
type WorkingHoursEditorDay,
|
||||
} from '@/components/staff/workingHours';
|
||||
|
||||
interface StaffWorkingHoursStepProps {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
onDaysChange: (days: WorkingHoursEditorDay[]) => void;
|
||||
onAutoRepeatWeeklyChange: (value: boolean) => void;
|
||||
onValidationChange?: (error: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StaffWorkingHoursStep({
|
||||
days,
|
||||
autoRepeatWeekly,
|
||||
onDaysChange,
|
||||
onAutoRepeatWeeklyChange,
|
||||
onValidationChange,
|
||||
disabled,
|
||||
}: StaffWorkingHoursStepProps) {
|
||||
const t = useTranslations('staff.workingHours');
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChange?.(validateEditorDays(days, t));
|
||||
}, [days, onValidationChange, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[var(--radius-md)] border border-primary/25 bg-primary/5 px-3 py-3">
|
||||
<p className="text-sm text-text-primary font-medium">{t('recommendedTitle')}</p>
|
||||
<p className="text-sm text-text-secondary mt-1">{t('recommendedBody')}</p>
|
||||
</div>
|
||||
<WorkingHoursEditor
|
||||
days={days}
|
||||
autoRepeatWeekly={autoRepeatWeekly}
|
||||
onDaysChange={onDaysChange}
|
||||
onAutoRepeatWeeklyChange={onAutoRepeatWeeklyChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createDefaultWorkingHoursState() {
|
||||
return {
|
||||
days: emptyWorkingHoursEditorDays(),
|
||||
autoRepeatWeekly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function workingHoursPayloadFromState(state: {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
}) {
|
||||
return {
|
||||
autoRepeatWeekly: state.autoRepeatWeekly,
|
||||
blocks: blocksFromEditorDays(state.days),
|
||||
};
|
||||
}
|
||||
|
||||
export function workingHoursStateFromApi(data: {
|
||||
autoRepeatWeekly: boolean;
|
||||
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
|
||||
}) {
|
||||
const hasBlocks = data.blocks.length > 0;
|
||||
return {
|
||||
days: hasBlocks ? editorDaysFromBlocks(data.blocks) : emptyWorkingHoursEditorDays(),
|
||||
autoRepeatWeekly: data.autoRepeatWeekly,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkingHoursForm(initial?: {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
}) {
|
||||
const [days, setDays] = useState(initial?.days ?? emptyWorkingHoursEditorDays());
|
||||
const [autoRepeatWeekly, setAutoRepeatWeekly] = useState(initial?.autoRepeatWeekly ?? true);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const reset = useCallback((next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) => {
|
||||
setDays(next?.days ?? emptyWorkingHoursEditorDays());
|
||||
setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true);
|
||||
setValidationError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
days,
|
||||
setDays,
|
||||
autoRepeatWeekly,
|
||||
setAutoRepeatWeekly,
|
||||
validationError,
|
||||
setValidationError,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import {
|
||||
MINUTES_PER_DAY,
|
||||
WEEKDAY_KEYS,
|
||||
minutesToTimeInput,
|
||||
timeInputToMinutes,
|
||||
type WorkingHoursEditorDay,
|
||||
} from '@/components/staff/workingHours';
|
||||
|
||||
interface WorkingHoursEditorProps {
|
||||
days: WorkingHoursEditorDay[];
|
||||
autoRepeatWeekly: boolean;
|
||||
onDaysChange: (days: WorkingHoursEditorDay[]) => void;
|
||||
onAutoRepeatWeeklyChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const timeInputClass =
|
||||
'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||
|
||||
export function WorkingHoursEditor({
|
||||
days,
|
||||
autoRepeatWeekly,
|
||||
onDaysChange,
|
||||
onAutoRepeatWeeklyChange,
|
||||
disabled = false,
|
||||
}: WorkingHoursEditorProps) {
|
||||
const t = useTranslations('staff.workingHours');
|
||||
|
||||
function updateDay(dayOfWeek: number, patch: Partial<WorkingHoursEditorDay>) {
|
||||
onDaysChange(
|
||||
days.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateShift(
|
||||
dayOfWeek: number,
|
||||
shiftIndex: number,
|
||||
field: 'startMinute' | 'endMinute',
|
||||
value: string,
|
||||
) {
|
||||
const minutes = timeInputToMinutes(value);
|
||||
if (minutes == null) return;
|
||||
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
|
||||
if (!day) return;
|
||||
const shifts = day.shifts.map((shift, index) =>
|
||||
index === shiftIndex ? { ...shift, [field]: minutes } : shift,
|
||||
);
|
||||
updateDay(dayOfWeek, { shifts });
|
||||
}
|
||||
|
||||
function addShift(dayOfWeek: number) {
|
||||
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
|
||||
if (!day) return;
|
||||
const last = day.shifts[day.shifts.length - 1];
|
||||
const startMinute = last ? Math.min(last.endMinute + 60, MINUTES_PER_DAY - 60) : 9 * 60;
|
||||
updateDay(dayOfWeek, {
|
||||
shifts: [...day.shifts, { startMinute, endMinute: Math.min(startMinute + 120, MINUTES_PER_DAY) }],
|
||||
});
|
||||
}
|
||||
|
||||
function removeShift(dayOfWeek: number, shiftIndex: number) {
|
||||
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
|
||||
if (!day || day.shifts.length <= 1) return;
|
||||
updateDay(dayOfWeek, {
|
||||
shifts: day.shifts.filter((_, index) => index !== shiftIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">{t('intro')}</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.dayOfWeek}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-background-card/40 px-3 py-3 space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-sm font-medium text-text-primary w-10 shrink-0">
|
||||
{t(WEEKDAY_KEYS[day.dayOfWeek])}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={day.isWorking}
|
||||
disabled={disabled}
|
||||
label={t('workingDay')}
|
||||
onChange={(checked) => {
|
||||
updateDay(day.dayOfWeek, {
|
||||
isWorking: checked,
|
||||
shifts: checked
|
||||
? day.shifts.length > 0
|
||||
? day.shifts
|
||||
: [{ startMinute: 9 * 60, endMinute: 17 * 60 }]
|
||||
: day.shifts,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{day.isWorking && (
|
||||
<div className="space-y-2 pl-0 sm:pl-10">
|
||||
{day.shifts.map((shift, shiftIndex) => (
|
||||
<div key={shiftIndex} className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-muted mb-1">{t('start')}</label>
|
||||
<input
|
||||
type="time"
|
||||
step={300}
|
||||
disabled={disabled}
|
||||
className={timeInputClass}
|
||||
value={minutesToTimeInput(shift.startMinute)}
|
||||
onChange={(e) =>
|
||||
updateShift(day.dayOfWeek, shiftIndex, 'startMinute', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-muted mb-1">{t('end')}</label>
|
||||
<input
|
||||
type="time"
|
||||
step={300}
|
||||
disabled={disabled}
|
||||
className={timeInputClass}
|
||||
value={minutesToTimeInput(shift.endMinute)}
|
||||
onChange={(e) =>
|
||||
updateShift(day.dayOfWeek, shiftIndex, 'endMinute', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || day.shifts.length <= 1}
|
||||
className="p-2 rounded-md text-text-muted hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label={t('removeShift')}
|
||||
onClick={() => removeShift(day.dayOfWeek, shiftIndex)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => addShift(day.dayOfWeek)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
{t('addShift')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
checked={autoRepeatWeekly}
|
||||
disabled={disabled}
|
||||
label={t('autoRepeatWeekly')}
|
||||
onChange={onAutoRepeatWeeklyChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/** Feature groups for staff invite/edit UI — matches backend seed */
|
||||
export const STAFF_FEATURE_GROUPS = [
|
||||
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
|
||||
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
|
||||
{
|
||||
label: 'Organizations',
|
||||
read: 'TAB_ORGANIZATIONS_READ',
|
||||
edit: 'TAB_ORGANIZATIONS_EDIT',
|
||||
},
|
||||
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
|
||||
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
|
||||
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
|
||||
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
|
||||
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
|
||||
] as const;
|
||||
|
||||
/** Map EDIT key -> { read, edit } for checkbox grid */
|
||||
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
|
||||
|
||||
export function emptyFeaturePermissionState(): FeaturePermState {
|
||||
const s: FeaturePermState = {};
|
||||
for (const g of STAFF_FEATURE_GROUPS) {
|
||||
s[g.edit] = { read: false, edit: false };
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function featureStateFromPermissionNames(names: string[]): FeaturePermState {
|
||||
const set = new Set(names);
|
||||
const s = emptyFeaturePermissionState();
|
||||
for (const g of STAFF_FEATURE_GROUPS) {
|
||||
const hasEdit = set.has(g.edit);
|
||||
const hasRead = set.has(g.read) || hasEdit;
|
||||
s[g.edit] = { read: hasRead, edit: hasEdit };
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function permissionNamesFromFeatureState(state: FeaturePermState): string[] {
|
||||
const out: string[] = [];
|
||||
for (const g of STAFF_FEATURE_GROUPS) {
|
||||
const cell = state[g.edit];
|
||||
if (!cell) continue;
|
||||
if (cell.edit) out.push(g.edit);
|
||||
else if (cell.read) out.push(g.read);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user