bugfix: a small refactor done in folder structure and naming conventions.
This commit is contained in:
59
frontend/src/components/ui/shared/Badge.tsx
Normal file
59
frontend/src/components/ui/shared/Badge.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'default';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
/**
|
||||
* Same pixel width for every badge (table columns).
|
||||
* Set false only when the pill should shrink to the label.
|
||||
*/
|
||||
fixedWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
|
||||
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
|
||||
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
|
||||
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
|
||||
};
|
||||
|
||||
/** Explicit width + height so every row matches; flex centers label optically. */
|
||||
const FIXED_LAYOUT_CLASS =
|
||||
'w-[8rem] min-w-[8rem] max-w-[8rem] shrink-0 h-7 px-2 py-0';
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
fixedWidth = true,
|
||||
}: BadgeProps) {
|
||||
const layoutClass = fixedWidth
|
||||
? `${FIXED_LAYOUT_CLASS} justify-center text-center`
|
||||
: 'min-h-[1.75rem] px-2.5 py-1 justify-center';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none whitespace-nowrap ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map organization connection / invitation row status to badge variant. */
|
||||
export function organizationConnectionStatusVariant(status: string): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return 'success';
|
||||
case 'PENDING':
|
||||
return 'warning';
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
63
frontend/src/components/ui/shared/Button.tsx
Normal file
63
frontend/src/components/ui/shared/Button.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
fullWidth = false,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
|
||||
|
||||
secondary:
|
||||
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
|
||||
|
||||
outline:
|
||||
'border border-border text-text-primary hover:bg-background-card/70 disabled:opacity-50',
|
||||
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:opacity-50',
|
||||
|
||||
ghost:
|
||||
'text-text-secondary hover:text-text-primary hover:bg-background-card/70 disabled:opacity-50',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
aria-busy={isLoading || undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
36
frontend/src/components/ui/shared/Card.tsx
Normal file
36
frontend/src/components/ui/shared/Card.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ElementType, ReactNode } from 'react';
|
||||
|
||||
type CardPadding = 'none' | 'sm' | 'md' | 'lg';
|
||||
|
||||
type CardProps<T extends ElementType = 'div'> = {
|
||||
children: ReactNode;
|
||||
padding?: CardPadding;
|
||||
as?: T;
|
||||
className?: string;
|
||||
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children' | 'className'>;
|
||||
|
||||
const paddingClassMap: Record<CardPadding, string> = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
};
|
||||
|
||||
export function Card<T extends ElementType = 'div'>({
|
||||
children,
|
||||
as,
|
||||
className = '',
|
||||
padding = 'md',
|
||||
...props
|
||||
}: CardProps<T>) {
|
||||
const Component = as ?? 'div';
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`rounded-[var(--radius-lg)] border border-card-border bg-card text-card-foreground ${paddingClassMap[padding]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/ui/shared/Checkbox.tsx
Normal file
70
frontend/src/components/ui/shared/Checkbox.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
type CheckboxProps = {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* App design-system checkbox: primary fill when checked, rounded, focus-visible ring.
|
||||
*/
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
id,
|
||||
className = '',
|
||||
}: CheckboxProps) {
|
||||
const genId = useId();
|
||||
const inputId = id ?? genId;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`
|
||||
inline-flex items-center gap-2.5 cursor-pointer select-none rounded-[var(--radius-sm)] -m-0.5 p-0.5
|
||||
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/45 has-[:focus-visible]:ring-offset-2
|
||||
has-[:focus-visible]:ring-offset-background-secondary
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span
|
||||
className={`
|
||||
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
|
||||
shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]
|
||||
${
|
||||
checked
|
||||
? 'border-primary bg-primary shadow-[0_0_0_1px_rgba(9,169,188,0.25)]'
|
||||
: 'border-border-strong bg-background-card/90 hover:border-border'
|
||||
}
|
||||
`}
|
||||
aria-hidden
|
||||
>
|
||||
<Check
|
||||
strokeWidth={3}
|
||||
className={`h-3.5 w-3.5 text-primary-contrast transition-all duration-200 ${
|
||||
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-text-secondary">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/shared/DialogCloseButton.tsx
Normal file
21
frontend/src/components/ui/shared/DialogCloseButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type DialogCloseButtonProps = {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`shrink-0 rounded-[var(--radius-sm)] p-1.5 text-text-muted hover:text-text-primary hover:bg-background-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/35 ${className}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5 icon-flat" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
68
frontend/src/components/ui/shared/Dropdown.tsx
Normal file
68
frontend/src/components/ui/shared/Dropdown.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
|
||||
({ label, error, className = '', id, children, ...props }, ref) => {
|
||||
const selectId = id || `dropdown-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`
|
||||
w-full appearance-none rounded-[var(--radius-md)] border
|
||||
${error ? 'border-red-500' : 'border-border'}
|
||||
bg-background-secondary/90 text-text-primary
|
||||
|
||||
pl-4 pr-14 py-2 text-sm
|
||||
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
|
||||
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
||||
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-text-muted"
|
||||
aria-hidden
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 icon-flat" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Dropdown.displayName = 'Dropdown';
|
||||
67
frontend/src/components/ui/shared/Input.tsx
Normal file
67
frontend/src/components/ui/shared/Input.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
// src/components/ui/Input.tsx
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, icon, className = '', id, ...props }, ref) => {
|
||||
const inputId =
|
||||
id || `input-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-text-muted pointer-events-none">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`
|
||||
w-full rounded-[var(--radius-md)] border
|
||||
${error ? 'border-red-500' : 'border-border'}
|
||||
bg-background-secondary/90 text-text-primary
|
||||
|
||||
${icon ? 'pl-10' : 'pl-4'} pr-4 py-2
|
||||
|
||||
placeholder:text-text-muted
|
||||
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
|
||||
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
||||
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
38
frontend/src/components/ui/shared/OrganizationCard.tsx
Normal file
38
frontend/src/components/ui/shared/OrganizationCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/components/ui/OrganizationCard.tsx
|
||||
import React from 'react';
|
||||
import { Building2, Beaker, ChevronRight } from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
|
||||
interface OrganizationCardProps {
|
||||
organization: Organization;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const OrganizationCard: React.FC<OrganizationCardProps> = ({
|
||||
organization,
|
||||
onSelect,
|
||||
}) => {
|
||||
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker;
|
||||
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(organization.id)}
|
||||
className="w-full surface-card p-6 hover:border-primary/60 transition-all text-left flex items-center gap-4 group"
|
||||
>
|
||||
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
|
||||
<Icon className="h-8 w-8 icon-flat" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{organization.name}</h3>
|
||||
<p className="text-sm text-text-secondary">{typeText}</p>
|
||||
{organization.plan && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Plan: {organization.plan.name} • {organization.plan.maxUsers} users
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 icon-flat text-text-muted group-hover:text-primary transition-colors" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
299
frontend/src/components/ui/shared/ScheduleDayPicker.tsx
Normal file
299
frontend/src/components/ui/shared/ScheduleDayPicker.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
addCalendarDays,
|
||||
compareLocalDayStart,
|
||||
startOfLocalDay,
|
||||
} from '@/lib/appointmentTime';
|
||||
|
||||
interface ScheduleDayPickerProps {
|
||||
value: Date;
|
||||
onChange: (day: Date) => void;
|
||||
/** Optional lower bound for day selection and previous-day navigation. */
|
||||
minDate?: Date;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
] as const;
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function buildLocalDay(year: number, month: number, day: number): Date {
|
||||
return new Date(year, month, day, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function clampToValidDay(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
min?: Date,
|
||||
): Date {
|
||||
const maxDay = daysInMonth(year, month);
|
||||
let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay));
|
||||
if (min) {
|
||||
const floor = startOfLocalDay(min);
|
||||
if (compareLocalDayStart(next, floor) < 0) {
|
||||
next = floor;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function yearRange(min?: Date, anchor?: Date): number[] {
|
||||
const now = new Date();
|
||||
const startYear = min ? min.getFullYear() : now.getFullYear() - 5;
|
||||
const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear());
|
||||
const years: number[] = [];
|
||||
for (let y = startYear; y <= endYear; y += 1) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
const selectClassName = `
|
||||
w-full appearance-none rounded-[var(--radius-sm)] border border-border
|
||||
bg-background-card/90 text-text-primary text-sm
|
||||
pl-2 pr-7 py-1.5
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`;
|
||||
|
||||
export function ScheduleDayPicker({
|
||||
value,
|
||||
onChange,
|
||||
minDate,
|
||||
label = 'Schedule date',
|
||||
}: ScheduleDayPickerProps) {
|
||||
const panelId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const normalizedValue = startOfLocalDay(value);
|
||||
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
|
||||
|
||||
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const previousDay = addCalendarDays(normalizedValue, -1);
|
||||
const canGoPrevious =
|
||||
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
|
||||
|
||||
const years = yearRange(normalizedMin, normalizedValue);
|
||||
const selectedYear = normalizedValue.getFullYear();
|
||||
const selectedMonth = normalizedValue.getMonth();
|
||||
const selectedDay = normalizedValue.getDate();
|
||||
const dayCount = daysInMonth(selectedYear, selectedMonth);
|
||||
|
||||
function applyParts(year: number, month: number, day: number, closePanel = false) {
|
||||
onChange(clampToValidDay(year, month, day, normalizedMin));
|
||||
if (closePanel) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviousDay() {
|
||||
if (!canGoPrevious) return;
|
||||
onChange(previousDay);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [panelOpen]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative w-full max-w-md">
|
||||
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
|
||||
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreviousDay}
|
||||
disabled={!canGoPrevious}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-40 disabled:pointer-events-none"
|
||||
aria-label="Previous day"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen((open) => !open)}
|
||||
aria-expanded={panelOpen}
|
||||
aria-controls={panelId}
|
||||
aria-haspopup="dialog"
|
||||
className="flex flex-1 min-w-0 items-center justify-center gap-1 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm font-medium text-text-primary tabular-nums hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
>
|
||||
<span className="truncate">{labelText}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-text-muted icon-flat transition-transform ${panelOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
aria-label="Next day"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{panelOpen && (
|
||||
<div
|
||||
id={panelId}
|
||||
role="dialog"
|
||||
aria-label="Choose schedule date"
|
||||
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${panelId}-year`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
Year
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={`${panelId}-year`}
|
||||
value={selectedYear}
|
||||
onChange={(e) =>
|
||||
applyParts(Number(e.target.value), selectedMonth, selectedDay)
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${panelId}-month`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
Month
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={`${panelId}-month`}
|
||||
value={selectedMonth}
|
||||
onChange={(e) =>
|
||||
applyParts(selectedYear, Number(e.target.value), selectedDay)
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{MONTH_LABELS.map((name, index) => {
|
||||
const disabled =
|
||||
normalizedMin &&
|
||||
selectedYear === normalizedMin.getFullYear() &&
|
||||
index < normalizedMin.getMonth();
|
||||
return (
|
||||
<option key={name} value={index} disabled={disabled}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${panelId}-day`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
Day
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={`${panelId}-day`}
|
||||
value={selectedDay}
|
||||
onChange={(e) =>
|
||||
applyParts(
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
Number(e.target.value),
|
||||
true,
|
||||
)
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => {
|
||||
const disabled =
|
||||
normalizedMin &&
|
||||
selectedYear === normalizedMin.getFullYear() &&
|
||||
selectedMonth === normalizedMin.getMonth() &&
|
||||
day < normalizedMin.getDate();
|
||||
return (
|
||||
<option key={day} value={day} disabled={disabled}>
|
||||
{day}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/ui/shared/SearchBar.tsx
Normal file
38
frontend/src/components/ui/shared/SearchBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Input } from './Input';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
onSubmit?: () => void;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
onSubmit,
|
||||
actions,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<div className="surface-card p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSubmit?.();
|
||||
}}
|
||||
icon={<Search className="h-4 w-4 icon-flat" />}
|
||||
/>
|
||||
</div>
|
||||
{actions && <div className="flex gap-2">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/ui/shared/Sidebar.tsx
Normal file
93
frontend/src/components/ui/shared/Sidebar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Calendar,
|
||||
UserCog,
|
||||
FlaskConical,
|
||||
FileText,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
|
||||
|
||||
const menu = [
|
||||
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
|
||||
{ name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
|
||||
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
|
||||
{ name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
|
||||
{ name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
|
||||
{ name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
|
||||
{ name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
|
||||
];
|
||||
|
||||
function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { currentOrganization } = useAuth();
|
||||
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
|
||||
|
||||
const visibleMenu = useMemo(
|
||||
() => {
|
||||
const withCounterpartTab = [
|
||||
menu[0],
|
||||
menu[1],
|
||||
{
|
||||
name: counterpartLabel,
|
||||
path: '/organizations',
|
||||
icon: FlaskConical,
|
||||
read: 'TAB_ORGANIZATIONS_READ' as const,
|
||||
},
|
||||
menu[2],
|
||||
menu[3],
|
||||
menu[4],
|
||||
menu[5],
|
||||
menu[6],
|
||||
];
|
||||
return withCounterpartTab.filter((item) => {
|
||||
if (item.path === '/appointments') {
|
||||
return canAccessAppointmentsSection(currentOrganization);
|
||||
}
|
||||
return canViewTab(currentOrganization, item.read);
|
||||
});
|
||||
},
|
||||
[counterpartLabel, currentOrganization],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
|
||||
<div className="h-[71px] px-4 flex items-center">
|
||||
<h1 className="text-lg font-medium tracking-tight">DyoLink</h1>
|
||||
</div>
|
||||
<div className="mx-4 border-b border-border/70" />
|
||||
|
||||
<nav className="flex flex-col gap-2 p-4">
|
||||
{visibleMenu.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.path;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.path}
|
||||
prefetch
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-sm)] border transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-soft border-primary/60 text-text-primary'
|
||||
: 'border-border/50 text-text-secondary hover:bg-background-card/70 hover:text-text-primary hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] icon-flat" />
|
||||
<span className="text-sm">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Sidebar);
|
||||
27
frontend/src/components/ui/shared/Table.tsx
Normal file
27
frontend/src/components/ui/shared/Table.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TableProps {
|
||||
headers: ReactNode;
|
||||
body: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function Table({ headers, body, footer }: TableProps) {
|
||||
return (
|
||||
<div className="surface-card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-background-secondary/70 border-b border-border">
|
||||
{headers}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{body}
|
||||
</tbody>
|
||||
</table>
|
||||
{footer && (
|
||||
<div className="px-6 py-3 border-t border-border/60 flex justify-between items-center bg-background-secondary/70">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/ui/shared/ThemeToggle.tsx
Normal file
47
frontend/src/components/ui/shared/ThemeToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { applyTheme, getStoredTheme, type ThemeMode } from '@/lib/theme';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMode(getStoredTheme());
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
const current = getStoredTheme();
|
||||
const next: ThemeMode = current === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
if (mode === null) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-9 w-9 shrink-0 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-[18px] w-[18px] icon-flat" />
|
||||
) : (
|
||||
<Moon className="h-[18px] w-[18px] icon-flat" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/ui/shared/Toast.tsx
Normal file
93
frontend/src/components/ui/shared/Toast.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
||||
|
||||
interface ToastProps {
|
||||
children: ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
|
||||
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
|
||||
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
|
||||
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
|
||||
};
|
||||
|
||||
export function Toast({ children, variant = 'default', className = '' }: ToastProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={`w-full rounded-[var(--radius-md)] border px-4 py-3 text-sm shadow-lg ${variantStyles[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ToastMessages = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
info?: string;
|
||||
default?: string;
|
||||
};
|
||||
|
||||
export type ToastStackProps = ToastMessages & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function hasToastMessages(messages: ToastMessages): boolean {
|
||||
return Boolean(messages.error || messages.success || messages.info || messages.default);
|
||||
}
|
||||
|
||||
/** Renders active toast messages with shared badge colors (success / warning / danger / default). */
|
||||
export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) {
|
||||
if (!hasToastMessages({ error, success, info, default: defaultMessage })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`.trim()} aria-live="polite">
|
||||
{error && <Toast variant="danger">{error}</Toast>}
|
||||
{info && <Toast variant="warning">{info}</Toast>}
|
||||
{success && <Toast variant="success">{success}</Toast>}
|
||||
{defaultMessage && <Toast variant="default">{defaultMessage}</Toast>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ToastViewportPosition = 'inline' | 'top' | 'bottom';
|
||||
|
||||
export type ToastViewportProps = ToastStackProps & {
|
||||
position?: ToastViewportPosition;
|
||||
};
|
||||
|
||||
const viewportPositionClass: Record<Exclude<ToastViewportPosition, 'inline'>, string> = {
|
||||
top: 'fixed top-4 left-0 right-0 z-[70] px-4 pointer-events-none',
|
||||
bottom: 'fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none',
|
||||
};
|
||||
|
||||
/**
|
||||
* Positions a ToastStack on the page. Use `inline` below a heading; `bottom` / `top` for overlays.
|
||||
*/
|
||||
export function ToastViewport({
|
||||
position = 'inline',
|
||||
className = '',
|
||||
...messages
|
||||
}: ToastViewportProps) {
|
||||
if (!hasToastMessages(messages)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stack = <ToastStack {...messages} className={className} />;
|
||||
|
||||
if (position === 'inline') {
|
||||
return stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={viewportPositionClass[position]}>
|
||||
<div className="pointer-events-auto w-full">{stack}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user