329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import { CSSProperties, FC, useRef } from "react";
|
|
import style from "./scheduleView.module.css";
|
|
import { useClipboardStore } from "@store/Clipboard.Store";
|
|
import { useAlert } from "@core/Store/Alert.Store";
|
|
import Avatar from "@components/Avatar/Avatar";
|
|
|
|
/**
|
|
* Given a background color and two foreground color options, returns the
|
|
* foreground color with the best contrast ratio to the background color.
|
|
*/
|
|
export const testContrast = (
|
|
bg: CSSProperties["color"],
|
|
fg1: CSSProperties["color"],
|
|
fg2: CSSProperties["color"],
|
|
skewReturnLighter: number = 0
|
|
): CSSProperties["color"] => {
|
|
const bgLuminance = clamp(getLuminance(bg) - skewReturnLighter, 0, 1);
|
|
const fg1Luminance = getLuminance(fg1);
|
|
const fg2Luminance = getLuminance(fg2);
|
|
|
|
const fg1Contrast = getContrast(bgLuminance, fg1Luminance);
|
|
const fg2Contrast = getContrast(bgLuminance, fg2Luminance);
|
|
|
|
return fg1Contrast > fg2Contrast ? fg1 : fg2;
|
|
};
|
|
|
|
/**
|
|
* Returns the contrast ratio between two luminances
|
|
*/
|
|
export const getContrast = (bgLuminance: number, fgLuminance: number): number => {
|
|
return Math.abs(bgLuminance - fgLuminance);
|
|
};
|
|
|
|
/**
|
|
* Returns the luminance value of a CSS color
|
|
*/
|
|
export const getLuminance = (color: CSSProperties["color"]): number => {
|
|
const rgb = cssColorToRGB(color);
|
|
const r = rgb[0] / 255;
|
|
const g = rgb[1] / 255;
|
|
const b = rgb[2] / 255;
|
|
|
|
const luminance = Math.sqrt(0.299 * r ** 2 + 0.587 * g ** 2 + 0.114 * b ** 2);
|
|
return luminance;
|
|
};
|
|
|
|
/**
|
|
* Converts a CSS color string to RGB values
|
|
*/
|
|
export const cssColorToRGB = (color: CSSProperties["color"]): number[] => {
|
|
const isBrowser = typeof window !== "undefined";
|
|
|
|
if (!color || !isBrowser) return [0, 0, 0];
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 1;
|
|
canvas.height = 1;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Could not get canvas context");
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(0, 0, 1, 1);
|
|
|
|
const data = ctx.getImageData(0, 0, 1, 1).data;
|
|
|
|
return [data[0], data[1], data[2]];
|
|
};
|
|
|
|
// https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/RGBToHSL.md
|
|
export const RGBToHSL = ([r, g, b]: number[]) => {
|
|
r /= 255;
|
|
g /= 255;
|
|
b /= 255;
|
|
const l = Math.max(r, g, b);
|
|
const s = l - Math.min(r, g, b);
|
|
const h = s ? (l === r ? (g - b) / s : l === g ? 2 + (b - r) / s : 4 + (r - g) / s) : 0;
|
|
return [
|
|
60 * h < 0 ? 60 * h + 360 : 60 * h,
|
|
100 * (s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0),
|
|
(100 * (2 * l - s)) / 2,
|
|
];
|
|
};
|
|
|
|
export const SUBDIVISIONS_PER_HOUR = 12;
|
|
|
|
export interface DaySchedule<CustomCalendarEvent extends CalendarEvent = CalendarEvent> {
|
|
name: string;
|
|
events: CustomCalendarEvent[];
|
|
}
|
|
|
|
export interface CalendarEvent {
|
|
startTime: number;
|
|
endTime: number;
|
|
title: string;
|
|
description?: string;
|
|
color?: CSSProperties["backgroundColor"];
|
|
}
|
|
|
|
// Rows/cols indexed starting from 1 to match CSS
|
|
export interface PositionedEvent<CustomCalendarEvent extends CalendarEvent> {
|
|
event: CustomCalendarEvent;
|
|
col: number;
|
|
endCol: number;
|
|
// Row numbers are relative to the first row of the event group
|
|
row: number;
|
|
endRow: number;
|
|
}
|
|
|
|
export interface PositionedEventGroup<CustomCalendarEvent extends CalendarEvent> {
|
|
totalCols: number;
|
|
groupStartRow: number;
|
|
groupEndRow: number;
|
|
positionedEvents: PositionedEvent<CustomCalendarEvent>[];
|
|
}
|
|
|
|
export type ScheduleThemeOverride = DeepPartial<ScheduleTheme>;
|
|
|
|
export type DeepPartial<T> = {
|
|
[P in keyof T]?: DeepPartial<T[P]>;
|
|
};
|
|
|
|
export interface ScheduleTheme<CustomCalendarEvent extends CalendarEvent = CalendarEvent> {
|
|
style?: ScheduleThemeStyle;
|
|
hourHeight: CSSProperties["height"];
|
|
minorGridlinesPerHour: number;
|
|
timeRangeFormatter: (startTime: number, endTime: number) => string;
|
|
timeFormatter: (time: number) => string;
|
|
defaultTileColor:
|
|
| CSSProperties["backgroundColor"]
|
|
| ((event: CustomCalendarEvent) => CSSProperties["backgroundColor"]);
|
|
customTileComponent?: FC<{ event: CustomCalendarEvent }>;
|
|
themeTileContent?: FC<{ event: CustomCalendarEvent }>;
|
|
}
|
|
|
|
export interface ScheduleThemeStyle<CustomCalendarEvent extends CalendarEvent = CalendarEvent> {
|
|
root?: CSSProperties;
|
|
dayLabels?: CSSProperties;
|
|
timeScaleLabels?: CSSProperties;
|
|
eventTiles?:
|
|
| CSSProperties
|
|
| ((
|
|
event: CustomCalendarEvent,
|
|
theme: ScheduleTheme<CustomCalendarEvent>
|
|
) => CSSProperties);
|
|
majorGridlinesBorder?: CSSProperties["borderStyle"];
|
|
minorGridlinesBorder?: CSSProperties["borderStyle"];
|
|
verticalGridlinesBorder?: CSSProperties["borderStyle"];
|
|
}
|
|
|
|
export const clamp = (value: number, min: number, max: number) =>
|
|
Math.min(Math.max(value, min), max);
|
|
|
|
export const numToHHMM = (num: number) => {
|
|
let hours = Math.floor(num) % 12;
|
|
if (hours === 0) {
|
|
hours = 12;
|
|
}
|
|
|
|
const minutes = Math.round((num % 1) * 60);
|
|
|
|
return `${hours}:${minutes < 10 ? "0" : ""}${minutes} ${num >= 12 && num < 24 ? "PM" : "AM"}`;
|
|
};
|
|
|
|
export const numToHH = (num: number) => {
|
|
let hours = Math.floor(num) % 12;
|
|
if (hours === 0) {
|
|
hours = 12;
|
|
}
|
|
|
|
return `${hours} ${num >= 12 && num < 24 ? "PM" : "AM"}`;
|
|
};
|
|
|
|
export const timeFormatter = (startTime: number) => `${numToHHMM(startTime)}`;
|
|
|
|
export const timeRangeFormatter = (startTime: number, endTime: number) =>
|
|
`${numToHHMM(startTime)} - ${numToHHMM(endTime)}`;
|
|
|
|
export const googleColors = {
|
|
greyGridline: "#a6a7ac",
|
|
greyTimeLabel: "#94979b",
|
|
greyDayLabel: "#666a6d",
|
|
blue: "#5186EC",
|
|
red: "#C3281C",
|
|
yellow: "#EEC04B",
|
|
purple: "#8332A4",
|
|
green: "#397D49",
|
|
indigo: "#4153AF",
|
|
pink: "#C63461",
|
|
lavender: "#AF9FD7",
|
|
orange: "#E35C33",
|
|
brown: "#74574A",
|
|
};
|
|
|
|
const calculateTextColor = (event: CalendarEvent, theme: ScheduleTheme): CSSProperties["color"] => {
|
|
const defaultColor =
|
|
typeof theme.defaultTileColor === "function"
|
|
? theme.defaultTileColor(event)
|
|
: theme.defaultTileColor;
|
|
|
|
return testContrast(event.color ?? defaultColor, "white", "black", 0.3);
|
|
};
|
|
|
|
const calculateBackgroundOpacity = (event: TXCalendarEvent): CSSProperties["opacity"] => {
|
|
return event.appointmentId ? 1 : 0.7;
|
|
};
|
|
|
|
export interface TXCalendarEvent extends CalendarEvent {
|
|
appointmentId?: string;
|
|
clientId?: string;
|
|
turnoId?: string;
|
|
employeeId?: string;
|
|
serviceId?: string;
|
|
avatar?: string;
|
|
repeatId?: string;
|
|
payment?: boolean;
|
|
}
|
|
|
|
const TurnosXpressTitle = (props: { event: TXCalendarEvent }) => {
|
|
let imageAvatar = "";
|
|
|
|
const alert = useAlert();
|
|
const setCopiedAppointment = useClipboardStore(state => state.setCopiedAppointment);
|
|
|
|
const pressTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
const isLongPress = useRef(false);
|
|
|
|
if (props.event.avatar) {
|
|
imageAvatar = props.event.avatar;
|
|
}
|
|
|
|
const handleStart = (e: React.TouchEvent | React.MouseEvent) => {
|
|
isLongPress.current = false;
|
|
const target = e.currentTarget;
|
|
|
|
target.parentElement?.classList.add(style.eventRowViewAnimated);
|
|
|
|
pressTimeout.current = setTimeout(() => {
|
|
isLongPress.current = true;
|
|
target.parentElement?.classList.remove(style.eventRowViewAnimated);
|
|
|
|
if (props.event.appointmentId) {
|
|
setCopiedAppointment(props.event);
|
|
alert.showSuccess("ID del turno copiado exitosamente.");
|
|
}
|
|
}, 500);
|
|
};
|
|
|
|
const handleCancel = (e: React.TouchEvent | React.MouseEvent) => {
|
|
if (pressTimeout.current) {
|
|
clearTimeout(pressTimeout.current);
|
|
pressTimeout.current = null;
|
|
}
|
|
e.currentTarget.parentElement?.classList.remove(style.eventRowViewAnimated);
|
|
};
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
if (isLongPress.current) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{ width: '100%', height: '100%' }}
|
|
onTouchStart={handleStart}
|
|
onTouchEnd={handleCancel}
|
|
onTouchCancel={handleCancel}
|
|
onTouchMove={handleCancel}
|
|
onMouseDown={handleStart}
|
|
onMouseUp={handleCancel}
|
|
onMouseLeave={handleCancel}
|
|
onClickCapture={handleClick}
|
|
>
|
|
<div className={style.turnosXpressTitleContent}>
|
|
<Avatar
|
|
src={imageAvatar}
|
|
alt={props.event.title}
|
|
size="super-small"
|
|
border="none"
|
|
style={{ minWidth: "32px" }}
|
|
/>
|
|
<h1>{props.event.title}</h1>
|
|
</div>
|
|
<div className={style.turnosXpressTimeContent}>
|
|
{timeRangeFormatter(props.event.startTime, props.event.endTime)}
|
|
{props.event.payment && (
|
|
<span style={{ marginLeft: "5px", marginTop: "5px" }}>💰</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const scViewTurnosExpress = (fractionTime: number = 60): ScheduleTheme<TXCalendarEvent> => {
|
|
const rowHeight = (100 * 60) / fractionTime;
|
|
|
|
return {
|
|
style: {
|
|
root: {
|
|
fontFamily: "Kanit, Roboto, Helvetica, Arial, sans-serif",
|
|
},
|
|
dayLabels: {
|
|
color: googleColors.greyDayLabel,
|
|
textTransform: "uppercase",
|
|
},
|
|
timeScaleLabels: {
|
|
color: googleColors.greyTimeLabel,
|
|
fontSize: "0.7rem",
|
|
},
|
|
majorGridlinesBorder: `1px solid #dad8d8`,
|
|
minorGridlinesBorder: `1px dotted ${googleColors.greyGridline}`,
|
|
verticalGridlinesBorder: `1x solid ${googleColors.greyGridline}`,
|
|
eventTiles: (event, theme) => ({
|
|
color: calculateTextColor(event, theme),
|
|
opacity: calculateBackgroundOpacity(event),
|
|
}),
|
|
},
|
|
hourHeight: `${rowHeight}px`,
|
|
minorGridlinesPerHour: 4,
|
|
timeRangeFormatter: timeRangeFormatter,
|
|
defaultTileColor: "var(--wine-darkest)",
|
|
timeFormatter: numToHH,
|
|
//customTileComponent: TurnosXpressTitle,
|
|
themeTileContent: TurnosXpressTitle,
|
|
};
|
|
};
|