668 lines
19 KiB
React
668 lines
19 KiB
React
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
PointerSensor,
|
|
TouchSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { BiCopy, BiDotsVertical, BiX } from "react-icons/bi";
|
|
import {
|
|
DengiButton,
|
|
DengiYesOrNoDialog,
|
|
LoadingSpinner,
|
|
} from "./trash-components";
|
|
import { motion, AnimatePresence } from "motion/react";
|
|
import {
|
|
BiCheck,
|
|
BiPlus,
|
|
BiRedo,
|
|
BiTime,
|
|
BiTrash,
|
|
BiUndo,
|
|
} from "react-icons/bi";
|
|
// import { ifstyle } from "../App";
|
|
import {
|
|
apiFormatDate,
|
|
convertApiToEditor,
|
|
convertEditorToApi,
|
|
formatDate,
|
|
} from "./dengi";
|
|
import {
|
|
EditorPrefillRequest,
|
|
EditorPublishRequest,
|
|
EditorRemoveRequest,
|
|
} from "./editor_req_mgr";
|
|
import { API_BASE_URL } from "../App";
|
|
|
|
// Configuration
|
|
const COLUMNS = [
|
|
[
|
|
{
|
|
key: "position",
|
|
label: "номер",
|
|
width: "w-9",
|
|
placeholder: "№",
|
|
useinput: true,
|
|
},
|
|
{
|
|
key: "subject",
|
|
label: "предмет",
|
|
width: "w-40",
|
|
placeholder: "предмет",
|
|
useinput: false,
|
|
},
|
|
{
|
|
key: "room",
|
|
label: "кабинет",
|
|
width: "w-14",
|
|
placeholder: "123",
|
|
useinput: false,
|
|
},
|
|
{
|
|
key: "time1",
|
|
label: "начало",
|
|
width: "w-16",
|
|
placeholder: "00:00",
|
|
useinput: true,
|
|
},
|
|
{
|
|
key: "time2",
|
|
label: "конец",
|
|
width: "w-16",
|
|
placeholder: "00:00",
|
|
useinput: true,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
key: "position",
|
|
label: "номер",
|
|
width: "w-9",
|
|
placeholder: "№",
|
|
useinput: true,
|
|
},
|
|
{
|
|
key: "subject",
|
|
label: "предмет",
|
|
width: "w-40",
|
|
placeholder: "предмет",
|
|
useinput: false,
|
|
},
|
|
{
|
|
key: "room",
|
|
label: "кабинет",
|
|
width: "w-14",
|
|
placeholder: "123",
|
|
useinput: false,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
key: "position",
|
|
label: "номер",
|
|
width: "w-9",
|
|
placeholder: "№",
|
|
useinput: true,
|
|
},
|
|
{
|
|
key: "time1",
|
|
label: "начало",
|
|
width: "w-16",
|
|
placeholder: "00:00",
|
|
useinput: true,
|
|
},
|
|
{
|
|
key: "time2",
|
|
label: "конец",
|
|
width: "w-16",
|
|
placeholder: "00:00",
|
|
useinput: true,
|
|
},
|
|
],
|
|
];
|
|
|
|
const INITIAL_DATA = [
|
|
// {
|
|
// id: "1",
|
|
// position: "1",
|
|
// subject: "Mathematics",
|
|
// time: "09:00",
|
|
// room: "A-1",
|
|
// },
|
|
// { id: "2", position: "2", subject: "Physics", time: "10:00", room: "B-2" },
|
|
// { id: "3", position: "3", subject: "Chemistry", time: "11:00", room: "A-3" },
|
|
// { id: "4", position: "4", subject: "Biology", time: "13:00", room: "C-1" },
|
|
];
|
|
|
|
function TableInput(props) {
|
|
return props.useinput ? <input {...props} /> : <textarea {...props} />;
|
|
}
|
|
|
|
function SortableRow({
|
|
item,
|
|
columns,
|
|
onUpdate,
|
|
onDelete,
|
|
onDuplicate,
|
|
setFocusEdit,
|
|
setHoverEdit,
|
|
pickmeMode,
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: item.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={`${
|
|
pickmeMode ? "bg-pink-500/35" : "bg-zinc-900"
|
|
} bg-grain rounded-lg mb-2`}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex">
|
|
{columns.map((col) => (
|
|
<TableInput
|
|
key={col.key}
|
|
useinput={col.useinput}
|
|
type="text"
|
|
value={item[col.key] || ""}
|
|
wrap="hard"
|
|
rows={1}
|
|
onChange={(e) => onUpdate(item.id, col.key, e.target.value)}
|
|
className={`px-1 py-3 text-center bg-transparent text-white border-r ${
|
|
pickmeMode ? "border-pink-400/50" : "border-zinc-700"
|
|
} last:border-r-0 last:rounded-tr-lg focus:outline-none focus:bg-gray-700/50 placeholder-gray-500 transition-colors hover:bg-gray-700/20 first:rounded-tl-lg max-h-min resize-none block min-w-0 grow ${
|
|
col.width
|
|
}`}
|
|
placeholder={col.placeholder}
|
|
onFocus={() => setFocusEdit(col.key)}
|
|
onBlur={() => setFocusEdit(null)}
|
|
onMouseOver={() => setHoverEdit(col.key)}
|
|
onMouseLeave={() => setHoverEdit(null)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className="w-[98%] mx-auto h-[1px] bg-zinc-500" />
|
|
<div className="flex justify-end w-full">
|
|
<button
|
|
onClick={() => onDuplicate(item.id)}
|
|
className="min-w-12 min-h-12 transition-colors bg-zinc-600/20 text-zinc-400 hover:bg-zinc-600/40 hover:text-zinc-300 flex items-center justify-center rounded-l-md"
|
|
>
|
|
<BiCopy size={20} />
|
|
</button>
|
|
<div
|
|
{...attributes}
|
|
{...listeners}
|
|
className="min-w-12 min-h-12 transition-colors hover:bg-zinc-200 flex items-center justify-center cursor-move touch-none select-none hover:text-black font-black text-white bg-zinc-700/60"
|
|
>
|
|
<BiDotsVertical size={24} />
|
|
</div>
|
|
<button
|
|
onClick={() => onDelete(item.id)}
|
|
className="min-w-12 min-h-12 transition-colors bg-red-600/20 text-red-400 hover:bg-red-600/40 hover:text-red-300 active:bg-red-600/60 flex items-center justify-center rounded-r-md"
|
|
>
|
|
<BiX size={24} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
async function getPrefilledRasp(tab, date, adminId) {
|
|
let a = await EditorPrefillRequest(
|
|
API_BASE_URL,
|
|
tab,
|
|
adminId,
|
|
apiFormatDate(date)
|
|
);
|
|
if (a[0] == 1 && a[1]) {
|
|
return convertApiToEditor(a[1]);
|
|
}
|
|
}
|
|
|
|
export function TableEditor({
|
|
initialData = INITIAL_DATA,
|
|
onChange = () => {},
|
|
selectedTab,
|
|
columns = COLUMNS[selectedTab],
|
|
setChangesWereMade,
|
|
adminId,
|
|
date,
|
|
dengiCash,
|
|
pickmeMode = false,
|
|
}) {
|
|
const [items, setItems] = useState(initialData);
|
|
const [undoItems, setUndoItems] = useState([]);
|
|
const [redoItems, setRedoItems] = useState([]);
|
|
const [isLoading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
let rejected = false;
|
|
//console.log("cleanup");
|
|
// cleanup on change
|
|
setUndoItems([]);
|
|
setRedoItems([]);
|
|
setLoading(true);
|
|
getPrefilledRasp(selectedTab, date, adminId).then((i) => {
|
|
if (rejected) return;
|
|
console.log(i || []);
|
|
setItems(i || []);
|
|
setLoading(false);
|
|
});
|
|
return () => {
|
|
rejected = true;
|
|
};
|
|
}, [
|
|
selectedTab,
|
|
formatDate(date) /* NOTE: we need to format to rely only on DD-MM-YY,
|
|
and forget about time at all*/,
|
|
]);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: {
|
|
delay: 200,
|
|
tolerance: 8,
|
|
},
|
|
})
|
|
);
|
|
|
|
const updateItems = (newItems) => {
|
|
setItems(newItems);
|
|
onChange?.(newItems);
|
|
};
|
|
|
|
useEffect(() => setChangesWereMade(undoItems.length > 0), [undoItems]);
|
|
const handleDragEnd = (event) => {
|
|
const { active, over } = event;
|
|
if (over && active.id !== over.id) {
|
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
|
const newIndex = items.findIndex((item) => item.id === over.id);
|
|
const newItems = arrayMove(items, oldIndex, newIndex);
|
|
|
|
// Swap positions: assign old positions to new order
|
|
const reorderedItems = newItems.map((item, idx) => ({
|
|
...item,
|
|
position: items[idx].position, // Use position from original array at this index
|
|
}));
|
|
|
|
setUndoItems([items, ...undoItems]);
|
|
updateItems(reorderedItems);
|
|
}
|
|
};
|
|
const updateTimeoutRef = useRef({});
|
|
const previousValueRef = useRef({});
|
|
|
|
const handleUpdate = (id, field, value) => {
|
|
const key = `${id}-${field}`;
|
|
if (!updateTimeoutRef.current[key]) {
|
|
const currentItem = items.find((item) => item.id === id);
|
|
previousValueRef.current[key] = currentItem[field];
|
|
}
|
|
if (updateTimeoutRef.current[key]) {
|
|
clearTimeout(updateTimeoutRef.current[key]);
|
|
}
|
|
const newItems = items.map((item) =>
|
|
item.id === id ? { ...item, [field]: value } : item
|
|
);
|
|
updateItems(newItems);
|
|
updateTimeoutRef.current[key] = setTimeout(() => {
|
|
// Create items array with the ORIGINAL value from before editing started
|
|
const itemsWithOriginalValue = items.map((item) =>
|
|
item.id === id
|
|
? { ...item, [field]: previousValueRef.current[key] }
|
|
: item
|
|
);
|
|
setUndoItems([itemsWithOriginalValue, ...undoItems]);
|
|
|
|
// Clean up
|
|
delete updateTimeoutRef.current[key];
|
|
delete previousValueRef.current[key];
|
|
}, 1000);
|
|
};
|
|
|
|
const handleUndo = () => {
|
|
if (isLoading) return;
|
|
setRedoItems([items, ...redoItems]);
|
|
updateItems(undoItems[0]);
|
|
setUndoItems(undoItems.slice(1));
|
|
};
|
|
const handleRedo = () => {
|
|
if (isLoading) return;
|
|
setUndoItems([items, ...undoItems]);
|
|
updateItems(redoItems[0]);
|
|
setRedoItems(redoItems.slice(1));
|
|
};
|
|
|
|
const handleDelete = (id) => {
|
|
if (isLoading) return;
|
|
const newItems = items.filter((item) => item.id !== id);
|
|
setUndoItems([items, ...undoItems]);
|
|
updateItems(newItems);
|
|
};
|
|
|
|
const handleDuplicate = (id) => {
|
|
const index = items.findIndex((item) => item.id === id);
|
|
if (index === -1) return;
|
|
|
|
const itemToDuplicate = items[index];
|
|
|
|
// Create new item with unique id
|
|
const newItem = {
|
|
...itemToDuplicate,
|
|
id: Date.now(), // or use uuid() or any unique id generator
|
|
};
|
|
|
|
// Insert the new item right after the duplicated item
|
|
const newItems = [
|
|
...items.slice(0, index + 1),
|
|
newItem,
|
|
...items.slice(index + 1),
|
|
];
|
|
|
|
// Reassign positions based on the original order
|
|
// The duplicate takes the next position, and everything shifts
|
|
const reorderedItems = newItems.map((item, idx) => ({
|
|
...item,
|
|
position: String(idx + 1), // or just (idx + 1) if position is a number
|
|
}));
|
|
|
|
setUndoItems([items, ...undoItems]);
|
|
updateItems(reorderedItems);
|
|
};
|
|
|
|
const handleAddRow = () => {
|
|
if (isLoading) return;
|
|
const newId = String(Date.now());
|
|
const sortedItems = [...items];
|
|
sortedItems.sort((a, b) => a.position - b.position);
|
|
const lastPosition =
|
|
items.length > 0
|
|
? String(
|
|
parseInt(sortedItems[sortedItems.length - 1].position || "0") + 1
|
|
)
|
|
: "1";
|
|
|
|
const newItem = { id: newId, position: lastPosition };
|
|
columns.forEach((col) => {
|
|
if (col.key !== "position") {
|
|
newItem[col.key] = "";
|
|
}
|
|
});
|
|
setUndoItems([items, ...undoItems]);
|
|
updateItems([...items, newItem]);
|
|
setTimeout(
|
|
() =>
|
|
window.scrollTo({
|
|
top: document.documentElement.scrollHeight - window.innerHeight,
|
|
left: window.scrollX, // preserve current X
|
|
behavior: "smooth",
|
|
}),
|
|
100
|
|
);
|
|
};
|
|
|
|
const [focusEdit, setFocusEdit] = useState(null);
|
|
const [hoverEdit, setHoverEdit] = useState(null);
|
|
|
|
const [deleteDiag, setDeleteDiag] = useState(false);
|
|
|
|
const handleClear = () => {
|
|
if (isLoading) return;
|
|
setUndoItems([items, ...undoItems]);
|
|
updateItems([]);
|
|
};
|
|
|
|
const handleChangesDefault = () => {
|
|
if (selectedTab != 0 || isLoading) return;
|
|
setLoading(true);
|
|
getPrefilledRasp(1, date, adminId).then((i) => {
|
|
setUndoItems([items, ...undoItems]);
|
|
setItems(i);
|
|
setLoading(false);
|
|
});
|
|
};
|
|
const handlePublish = () => {
|
|
if (isLoading) return;
|
|
setUndoItems([]);
|
|
setRedoItems([]);
|
|
console.log(convertEditorToApi(items));
|
|
dengiCash.fullClear(); // keep it clean
|
|
setLoading(true);
|
|
EditorPublishRequest(
|
|
API_BASE_URL,
|
|
selectedTab,
|
|
adminId,
|
|
apiFormatDate(date),
|
|
JSON.stringify(convertEditorToApi(items))
|
|
).then(() => {
|
|
setLoading(false);
|
|
});
|
|
};
|
|
const handleDeleteAll = () => {
|
|
// if (isLoading) return; // WARN!
|
|
setDeleteDiag(true);
|
|
};
|
|
|
|
let deleteDialogBusy = false; // NOTE: we don't need a state
|
|
return (
|
|
<>
|
|
<div>
|
|
<DengiYesOrNoDialog
|
|
content={{
|
|
message: "ВСЁ БУДЕТ УДАЛЕНО! продолжить?",
|
|
yesBtn: "да",
|
|
noBtn: "нет",
|
|
yesIcon: <BiTrash />,
|
|
noIcon: <BiX size={20} />,
|
|
bgDissmis: true,
|
|
yesBtnStyle: "bg-red-500! text-white! md:hover:bg-red-600!",
|
|
}}
|
|
onAnswer={(answ) => {
|
|
if (deleteDialogBusy) return;
|
|
if (!answ) setDeleteDiag(false);
|
|
else {
|
|
dengiCash.fullClear();
|
|
// scary
|
|
deleteDialogBusy = true;
|
|
EditorRemoveRequest(
|
|
API_BASE_URL,
|
|
selectedTab,
|
|
adminId,
|
|
apiFormatDate(date)
|
|
).then(() => {
|
|
deleteDialogBusy = false; // for 100% sure
|
|
setDeleteDiag(false);
|
|
location.hash = "/app";
|
|
});
|
|
}
|
|
}}
|
|
show={deleteDiag}
|
|
/>
|
|
</div>
|
|
<div className="w-full overflow-hidden my-1 flex flex-col gap-3">
|
|
<div className="flex gap-2 flex-wrap">
|
|
<DengiButton onClick={handleUndo} disabled={undoItems.length == 0}>
|
|
<BiUndo />
|
|
</DengiButton>
|
|
<DengiButton onClick={handleRedo} disabled={redoItems.length == 0}>
|
|
<BiRedo />
|
|
</DengiButton>
|
|
<AnimatePresence mode="popLayout">
|
|
{selectedTab == 0 && (
|
|
<motion.div exit={{ opacity: 0 }}>
|
|
<DengiButton
|
|
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
|
onClick={handleChangesDefault}
|
|
>
|
|
<BiTime />
|
|
убрать зам.
|
|
</DengiButton>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
<motion.div layout="position" className="bg-black">
|
|
<DengiButton
|
|
onClick={handleClear}
|
|
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
|
>
|
|
<BiTrash />
|
|
очистить
|
|
</DengiButton>
|
|
</motion.div>
|
|
</div>
|
|
{!isLoading && (
|
|
<div>
|
|
<div className="flex w-full justify-around items-center mb-2">
|
|
{columns.map((col, i) => (
|
|
<>
|
|
<div
|
|
key={col.key + i}
|
|
className={
|
|
`max-${col.width} transition-colors text-` +
|
|
((focusEdit == null && hoverEdit == null) ||
|
|
focusEdit == col.key ||
|
|
hoverEdit == col.key
|
|
? "white"
|
|
: "zinc-600")
|
|
}
|
|
// onMouseOver={() => setHoverEdit(col.key)}
|
|
// onMouseLeave={() => setHoverEdit(null)}
|
|
>
|
|
{col.label}
|
|
</div>
|
|
|
|
<span
|
|
key={col.key + i + "divider"}
|
|
className={`w-4 h-[1px] ${
|
|
pickmeMode ? "bg-pink-600" : "bg-zinc-600"
|
|
} -rotate-45 last:hidden`}
|
|
/>
|
|
</>
|
|
))}
|
|
</div>
|
|
<div className="max-w-3xl mx-auto">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={items.map((item) => item.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div>
|
|
<AnimatePresence mode={items.length > 0 ? "sync" : "wait"}>
|
|
{items.map((item) => (
|
|
<motion.div
|
|
initial={{ x: -15, filter: "blur(5px)", opacity: 0 }}
|
|
animate={{ x: 0, filter: "blur(0px)", opacity: 1 }}
|
|
exit={{ x: 15, filter: "blur(5px)", opacity: 0 }}
|
|
key={item.id}
|
|
>
|
|
<SortableRow
|
|
item={item}
|
|
columns={columns}
|
|
onUpdate={handleUpdate}
|
|
onDelete={handleDelete}
|
|
onDuplicate={handleDuplicate}
|
|
setFocusEdit={setFocusEdit}
|
|
setHoverEdit={setHoverEdit}
|
|
pickmeMode={pickmeMode}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
{items.length === 0 && (
|
|
<motion.div
|
|
initial={{
|
|
filter: "blur(5px)",
|
|
opacity: 0,
|
|
scale: 0.8,
|
|
}}
|
|
animate={{
|
|
filter: "blur(0px)",
|
|
opacity: 1,
|
|
scale: 1,
|
|
}}
|
|
className={`border-2 border-dashed ${
|
|
pickmeMode ? "border-pink-700" : "border-gray-700"
|
|
} rounded-lg p-12 text-center text-gray-500`}
|
|
>
|
|
пусто
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{/* <div className="mt-6 flex gap-3">
|
|
<button
|
|
onClick={handleAddRow}
|
|
className="flex-1 bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 active:bg-blue-800"
|
|
>
|
|
+ Add Row
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
className="px-6 py-3 rounded-lg border-2 border-gray-700 text-gray-300 font-semibold hover:bg-gray-800 active:bg-gray-700"
|
|
>
|
|
Clear All
|
|
</button>
|
|
</div> */}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{isLoading && (
|
|
<div className="w-full flex items-center justify-center py-3">
|
|
<LoadingSpinner size={35} />
|
|
</div>
|
|
)}
|
|
<motion.div layout="position" className="flex w-full justify-end gap-2">
|
|
<DengiButton
|
|
onClick={handleDeleteAll}
|
|
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
|
>
|
|
<BiTrash />
|
|
удалить всё
|
|
</DengiButton>
|
|
<DengiButton onClick={handleAddRow}>
|
|
<BiPlus />
|
|
</DengiButton>
|
|
<DengiButton onClick={handlePublish}>
|
|
<BiCheck />
|
|
сохранить
|
|
</DengiButton>
|
|
</motion.div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|