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:
2026-07-12 21:08:29 +03:30
parent 1cdf853d32
commit 0d3fb0a51d
86 changed files with 4758 additions and 4260 deletions

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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;
}