first commit
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user