bugfix: organization sidebar icon updated like organization switch feature.
This commit is contained in:
173
frontend/src/components/appointments/appointmentOverlapLayout.ts
Normal file
173
frontend/src/components/appointments/appointmentOverlapLayout.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { AppointmentRecord } from '@/types/appointment';
|
||||
|
||||
export type AppointmentTimedInterval = {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type AppointmentLaneLayout = {
|
||||
lane: number;
|
||||
/** Max concurrent overlaps in this appointment's cluster (column count). */
|
||||
laneCount: number;
|
||||
};
|
||||
|
||||
function intervalsOverlap(a: AppointmentTimedInterval, b: AppointmentTimedInterval): boolean {
|
||||
return a.start < b.end && b.start < a.end;
|
||||
}
|
||||
|
||||
export function toTimedInterval(apt: AppointmentRecord): AppointmentTimedInterval {
|
||||
return {
|
||||
id: apt.id,
|
||||
start: new Date(apt.startAt).getTime(),
|
||||
end: new Date(apt.endAt).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Connected overlap component containing `appointmentId`. */
|
||||
export function findOverlapCluster(
|
||||
appointmentId: string,
|
||||
appointments: AppointmentRecord[],
|
||||
): AppointmentRecord[] {
|
||||
const byId = new Map(appointments.map((a) => [a.id, a]));
|
||||
if (!byId.has(appointmentId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timed = appointments.map(toTimedInterval);
|
||||
const clusterIds = new Set<string>([appointmentId]);
|
||||
let changed = true;
|
||||
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const interval of timed) {
|
||||
if (clusterIds.has(interval.id)) {
|
||||
continue;
|
||||
}
|
||||
for (const memberId of clusterIds) {
|
||||
const member = timed.find((t) => t.id === memberId);
|
||||
if (member && intervalsOverlap(interval, member)) {
|
||||
clusterIds.add(interval.id);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return appointments.filter((a) => clusterIds.has(a.id));
|
||||
}
|
||||
|
||||
function maxConcurrentCount(intervals: AppointmentTimedInterval[]): number {
|
||||
if (intervals.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
type Point = { time: number; delta: number };
|
||||
const points: Point[] = [];
|
||||
for (const interval of intervals) {
|
||||
points.push({ time: interval.start, delta: 1 });
|
||||
points.push({ time: interval.end, delta: -1 });
|
||||
}
|
||||
points.sort((a, b) => a.time - b.time || a.delta - b.delta);
|
||||
|
||||
let current = 0;
|
||||
let max = 0;
|
||||
for (const point of points) {
|
||||
current += point.delta;
|
||||
max = Math.max(max, current);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function assignGreedyLanes(intervals: AppointmentTimedInterval[]): Map<string, number> {
|
||||
const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
const laneEndTimes: number[] = [];
|
||||
const laneById = new Map<string, number>();
|
||||
|
||||
for (const interval of sorted) {
|
||||
let lane = laneEndTimes.findIndex((end) => end <= interval.start);
|
||||
if (lane === -1) {
|
||||
lane = laneEndTimes.length;
|
||||
laneEndTimes.push(interval.end);
|
||||
} else {
|
||||
laneEndTimes[lane] = interval.end;
|
||||
}
|
||||
laneById.set(interval.id, lane);
|
||||
}
|
||||
|
||||
return laneById;
|
||||
}
|
||||
|
||||
function buildClusters(intervals: AppointmentTimedInterval[]): AppointmentTimedInterval[][] {
|
||||
const visited = new Set<string>();
|
||||
const clusters: AppointmentTimedInterval[][] = [];
|
||||
|
||||
for (const seed of intervals) {
|
||||
if (visited.has(seed.id)) {
|
||||
continue;
|
||||
}
|
||||
const cluster: AppointmentTimedInterval[] = [];
|
||||
const queue = [seed];
|
||||
visited.add(seed.id);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.pop()!;
|
||||
cluster.push(current);
|
||||
for (const other of intervals) {
|
||||
if (!visited.has(other.id) && intervalsOverlap(current, other)) {
|
||||
visited.add(other.id);
|
||||
queue.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
clusters.push(cluster);
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns side-by-side lanes per provider column (Google Calendar style).
|
||||
*/
|
||||
export function computeAppointmentLaneLayouts(
|
||||
appointments: AppointmentRecord[],
|
||||
): Map<string, AppointmentLaneLayout> {
|
||||
const timed = appointments.map(toTimedInterval);
|
||||
if (timed.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const layouts = new Map<string, AppointmentLaneLayout>();
|
||||
const clusters = buildClusters(timed);
|
||||
|
||||
for (const cluster of clusters) {
|
||||
const laneCount = Math.max(1, maxConcurrentCount(cluster));
|
||||
const greedyLanes = assignGreedyLanes(cluster);
|
||||
const usedLaneIndices = [...new Set(cluster.map((c) => greedyLanes.get(c.id) ?? 0))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const remap = new Map(usedLaneIndices.map((lane, index) => [lane, index]));
|
||||
|
||||
for (const interval of cluster) {
|
||||
const rawLane = greedyLanes.get(interval.id) ?? 0;
|
||||
layouts.set(interval.id, {
|
||||
lane: remap.get(rawLane) ?? 0,
|
||||
laneCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return layouts;
|
||||
}
|
||||
|
||||
export function lanePositionStyles(lane: number, laneCount: number): {
|
||||
left: string;
|
||||
width: string;
|
||||
} {
|
||||
const gapPct = 1;
|
||||
const widthPct = (100 - gapPct * (laneCount + 1)) / laneCount;
|
||||
return {
|
||||
left: `calc(${gapPct}% + ${lane} * (${widthPct}% + ${gapPct}%))`,
|
||||
width: `${widthPct}%`,
|
||||
};
|
||||
}
|
||||
63
frontend/src/components/appointments/appointmentTime.ts
Normal file
63
frontend/src/components/appointments/appointmentTime.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Normalize to local midnight; invalid input falls back to today. */
|
||||
export function startOfLocalDay(d: Date): Date {
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
const t = new Date();
|
||||
return new Date(t.getFullYear(), t.getMonth(), t.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** Local calendar bounds for a date (browser timezone). */
|
||||
export function getLocalDayIsoRange(day: Date): { from: string; to: string } {
|
||||
const start = startOfLocalDay(day);
|
||||
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1, 0, 0, 0, 0);
|
||||
return { from: start.toISOString(), to: end.toISOString() };
|
||||
}
|
||||
|
||||
export function toDateInputValue(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function parseDateInput(value: string): Date {
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
return new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
export function combineLocalDateAndTime(day: Date, timeHHmm: string): Date {
|
||||
const [h, min] = timeHHmm.split(':').map(Number);
|
||||
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, min, 0, 0);
|
||||
}
|
||||
|
||||
export function formatTimeForInput(d: Date): string {
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
export function isSameLocalCalendarDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function formatHourLabel(hour: number): string {
|
||||
const d = new Date(2000, 0, 1, hour, 0, 0, 0);
|
||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true });
|
||||
}
|
||||
|
||||
/** Local midnight + delta calendar days. */
|
||||
export function addCalendarDays(day: Date, delta: number): Date {
|
||||
return new Date(day.getFullYear(), day.getMonth(), day.getDate() + delta, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** Compare two calendar days at local midnight (ordering by date only). */
|
||||
export function compareLocalDayStart(a: Date, b: Date): number {
|
||||
const ta = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
|
||||
const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
|
||||
return ta - tb;
|
||||
}
|
||||
12
frontend/src/components/shared/formatApiError.ts
Normal file
12
frontend/src/components/shared/formatApiError.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function formatApiErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
const m = (err as { message: unknown }).message;
|
||||
if (Array.isArray(m)) {
|
||||
return m.filter(Boolean).join(', ');
|
||||
}
|
||||
if (typeof m === 'string' && m.trim()) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
16
frontend/src/components/shared/organizationTypeIcon.ts
Normal file
16
frontend/src/components/shared/organizationTypeIcon.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Building2, Beaker, type LucideIcon } from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
|
||||
/** Clinic → Building2, Lab → Beaker (switch-organization cards). */
|
||||
export function organizationTypeIcon(type: Organization['type']): LucideIcon {
|
||||
return type === 'CLINIC' ? Building2 : Beaker;
|
||||
}
|
||||
|
||||
/** Organizations tab lists counterpart orgs (labs for clinics, clinics for labs). */
|
||||
export function counterpartOrganizationType(
|
||||
currentType: Organization['type'] | undefined,
|
||||
): Organization['type'] {
|
||||
if (currentType === 'CLINIC') return 'LAB';
|
||||
if (currentType === 'LAB') return 'CLINIC';
|
||||
return 'CLINIC';
|
||||
}
|
||||
25
frontend/src/components/shared/treatmentSelection.ts
Normal file
25
frontend/src/components/shared/treatmentSelection.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTime';
|
||||
import type { TreatmentAppointment } from '@/types/treatment';
|
||||
|
||||
/**
|
||||
* For the selected calendar day: if it is today, pick the appointment whose time range contains now;
|
||||
* otherwise pick the first appointment of that day. Returns null when there are no appointments.
|
||||
*/
|
||||
export function pickAutoAppointment(
|
||||
appointments: TreatmentAppointment[],
|
||||
selectedDay: Date,
|
||||
): string | null {
|
||||
if (appointments.length === 0) return null;
|
||||
|
||||
const now = new Date();
|
||||
if (isSameLocalCalendarDay(selectedDay, now)) {
|
||||
const t = now.getTime();
|
||||
for (const a of appointments) {
|
||||
const s = new Date(a.startAt).getTime();
|
||||
const e = new Date(a.endAt).getTime();
|
||||
if (t >= s && t <= e) return a.id;
|
||||
}
|
||||
}
|
||||
|
||||
return appointments[0].id;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
compareLocalDayStart,
|
||||
formatTimeForInput,
|
||||
isSameLocalCalendarDay,
|
||||
} from '@/lib/appointmentTime';
|
||||
} from '@/components/appointments/appointmentTime';
|
||||
|
||||
interface AppointmentBookingModalProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||
import { formatHourLabel } from '@/lib/appointmentTime';
|
||||
import { formatHourLabel } from '@/components/appointments/appointmentTime';
|
||||
import {
|
||||
computeAppointmentLaneLayouts,
|
||||
findOverlapCluster,
|
||||
lanePositionStyles,
|
||||
} from '@/lib/appointmentOverlapLayout';
|
||||
} from '@/components/appointments/appointmentOverlapLayout';
|
||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
|
||||
import { Building2, Beaker, Mail } from 'lucide-react';
|
||||
import { Building2, Mail } from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
|
||||
@@ -26,8 +28,10 @@ export function OrganizationSelectorContent() {
|
||||
const [organizationEmail, setOrganizationEmail] = useState('');
|
||||
const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC');
|
||||
|
||||
const getIcon = (type: string) =>
|
||||
type === 'CLINIC' ? <Building2 className="h-8 w-8 icon-flat" /> : <Beaker className="h-8 w-8 icon-flat" />;
|
||||
const renderOrgTypeIcon = (type: Organization['type']) => {
|
||||
const Icon = organizationTypeIcon(type);
|
||||
return <Icon className="h-8 w-8 icon-flat" />;
|
||||
};
|
||||
|
||||
const handleCreateOrganization = async () => {
|
||||
try {
|
||||
@@ -158,7 +162,7 @@ export function OrganizationSelectorContent() {
|
||||
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
|
||||
>
|
||||
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
|
||||
{getIcon(org.type)}
|
||||
{renderOrgTypeIcon(org.type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// src/components/ui/OrganizationCard.tsx
|
||||
import React from 'react';
|
||||
import { Building2, Beaker, ChevronRight } from 'lucide-react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon';
|
||||
|
||||
interface OrganizationCardProps {
|
||||
organization: Organization;
|
||||
@@ -12,7 +13,7 @@ export const OrganizationCard: React.FC<OrganizationCardProps> = ({
|
||||
organization,
|
||||
onSelect,
|
||||
}) => {
|
||||
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker;
|
||||
const Icon = organizationTypeIcon(organization.type);
|
||||
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { addCalendarDays, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
|
||||
interface ScheduleDayPickerProps {
|
||||
value: Date;
|
||||
|
||||
@@ -14,9 +14,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
|
||||
import {
|
||||
counterpartOrganizationType,
|
||||
organizationTypeIcon,
|
||||
} from '@/components/shared/organizationTypeIcon';
|
||||
|
||||
const menu = [
|
||||
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
|
||||
{ name: 'Dashboard', 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 },
|
||||
@@ -29,6 +33,9 @@ function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { currentOrganization } = useAuth();
|
||||
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
|
||||
const organizationsTabIcon = organizationTypeIcon(
|
||||
counterpartOrganizationType(currentOrganization?.type),
|
||||
);
|
||||
|
||||
const visibleMenu = useMemo(
|
||||
() => {
|
||||
@@ -38,7 +45,7 @@ function Sidebar() {
|
||||
{
|
||||
name: counterpartLabel,
|
||||
path: '/organizations',
|
||||
icon: FlaskConical,
|
||||
icon: organizationsTabIcon,
|
||||
read: 'TAB_ORGANIZATIONS_READ' as const,
|
||||
},
|
||||
menu[2],
|
||||
@@ -54,7 +61,7 @@ function Sidebar() {
|
||||
return canViewTab(currentOrganization, item.read);
|
||||
});
|
||||
},
|
||||
[counterpartLabel, currentOrganization],
|
||||
[counterpartLabel, organizationsTabIcon, currentOrganization],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CalendarDays } from 'lucide-react';
|
||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||
import { startOfLocalDay } from '@/lib/appointmentTime';
|
||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
import type { TreatmentAppointment } from '@/types/treatment';
|
||||
|
||||
interface AppointmentsStripProps {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/shared/Button';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { Toast } from '@/components/ui/shared/Toast';
|
||||
import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
import {
|
||||
fetchLinkedOrganizations,
|
||||
fetchMyAppointmentsForDay,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
saveTreatmentDraft,
|
||||
sendTreatmentRecord,
|
||||
} from '@/lib/mocks/treatmentMockApi';
|
||||
import { pickAutoAppointment } from '@/lib/treatmentSelection';
|
||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||
import { canEditTreatment } from '@/components/shared/permissions';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import type {
|
||||
|
||||
Reference in New Issue
Block a user