commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { BrainIcon } from '../icons/Icons.tsx';
|
||||
|
||||
interface AiInsightCardProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AiInsightCard: React.FC<AiInsightCardProps> = ({ title, children, icon }) => {
|
||||
return (
|
||||
<div className="bg-purple-50 border-l-4 border-purple-400 p-4 rounded-r-lg shadow-sm">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="text-purple-600 mr-3 text-xl">{icon || <BrainIcon />}</div>
|
||||
<h3 className="text-lg font-bold text-purple-900">{title}</h3>
|
||||
</div>
|
||||
<div className="pl-9 text-sm text-purple-800/90 space-y-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiInsightCard;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
date: Date;
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CalendarViewProps {
|
||||
events: CalendarEvent[];
|
||||
onDateSelect: (date: Date) => void;
|
||||
selectedDate: Date | null;
|
||||
}
|
||||
|
||||
const EventPill: React.FC<{ text: string; color: string }> = ({ text, color }) => (
|
||||
<div className={`text-white text-[10px] px-1.5 py-0.5 rounded-full truncate ${color}`}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selectedDate }) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
|
||||
const changeMonth = (amount: number) => {
|
||||
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + amount, 1));
|
||||
};
|
||||
|
||||
const calendarGrid = useMemo(() => {
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const grid = [];
|
||||
let dayCounter = 1;
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
// 6 weeks for full display
|
||||
const week = [];
|
||||
for (let j = 0; j < 7; j++) {
|
||||
if ((i === 0 && j < firstDayOfMonth) || dayCounter > daysInMonth) {
|
||||
week.push(null);
|
||||
} else {
|
||||
const date = new Date(year, month, dayCounter);
|
||||
week.push(date);
|
||||
dayCounter++;
|
||||
}
|
||||
}
|
||||
grid.push(week);
|
||||
if (dayCounter > daysInMonth) break;
|
||||
}
|
||||
return grid;
|
||||
}, [currentMonth]);
|
||||
|
||||
const isSameDay = (d1: Date, d2: Date) =>
|
||||
d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-4 px-2">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
className="text-gray-600 hover:text-red-600 p-2 rounded-full w-10 h-10 flex items-center justify-center hover:bg-gray-100"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left"></i>
|
||||
</button>
|
||||
<h3 className="text-lg font-bold text-gray-800">
|
||||
{currentMonth.toLocaleString('id-ID', { month: 'long', year: 'numeric' })}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => changeMonth(1)}
|
||||
className="text-gray-600 hover:text-red-600 p-2 rounded-full w-10 h-10 flex items-center justify-center hover:bg-gray-100"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-gray-500 mb-2">
|
||||
{['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'].map((day) => (
|
||||
<div key={day}>{day}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendarGrid.flat().map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={`empty-${index}`} className="h-24 bg-gray-50 rounded-md"></div>;
|
||||
}
|
||||
const isToday = isSameDay(date, new Date());
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const dayEvents = events.filter((e) => isSameDay(e.date, date));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date.toString()}
|
||||
onClick={() => onDateSelect(date)}
|
||||
className={`h-24 p-1.5 border rounded-md flex flex-col cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-red-100 border-red-300'
|
||||
: 'bg-white hover:bg-gray-100 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`font-semibold text-xs flex items-center justify-center w-6 h-6 rounded-full ${
|
||||
isToday ? 'bg-red-600 text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
<div className="mt-1 space-y-1 overflow-hidden">
|
||||
{dayEvents.slice(0, 2).map((event) => (
|
||||
<EventPill key={event.id} text={event.text} color={event.color} />
|
||||
))}
|
||||
{dayEvents.length > 2 && (
|
||||
<div className="text-[10px] text-gray-500 font-semibold text-center">
|
||||
+{dayEvents.length - 2} lagi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarView;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FilterDropdownOption {
|
||||
value: string | number | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FilterDropdownProps {
|
||||
label: string;
|
||||
value: string | number | null;
|
||||
options: FilterDropdownOption[];
|
||||
onChange: (value: string | number | null) => void;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const FilterDropdown: React.FC<FilterDropdownProps> = ({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
icon,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const val = e.target.value;
|
||||
if (val === '') {
|
||||
onChange(null);
|
||||
} else if (!isNaN(Number(val))) {
|
||||
onChange(Number(val));
|
||||
} else {
|
||||
onChange(val);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{icon && <i className={`${icon} mr-2`}></i>}
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={value === null ? '' : value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
{options.map((opt, idx) => (
|
||||
<option key={idx} value={opt.value === null ? '' : opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterDropdown;
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Location, Coop, Floor } from '../../types';
|
||||
|
||||
interface LocationSelectorProps {
|
||||
locations: Location[];
|
||||
coops: Coop[];
|
||||
floors: Floor[];
|
||||
initialFloorId: string | null;
|
||||
onFloorChange: (floorId: string | null) => void;
|
||||
}
|
||||
|
||||
const LocationSelector: React.FC<LocationSelectorProps> = ({
|
||||
locations,
|
||||
coops,
|
||||
floors,
|
||||
initialFloorId,
|
||||
onFloorChange,
|
||||
}) => {
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<string>('');
|
||||
const [selectedCoopId, setSelectedCoopId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFloorId) {
|
||||
const floor = floors.find((f) => f.id === initialFloorId);
|
||||
const coop = floor ? coops.find((c) => c.id === floor.coopId) : undefined;
|
||||
const location = coop ? locations.find((l) => l.id === coop.locationId) : undefined;
|
||||
if (location) setSelectedLocationId(location.id);
|
||||
if (coop) setSelectedCoopId(coop.id);
|
||||
} else {
|
||||
setSelectedLocationId(locations[0]?.id || '');
|
||||
}
|
||||
}, [initialFloorId, locations, coops, floors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFloorId) {
|
||||
const firstCoop = coops.find((c) => c.locationId === selectedLocationId);
|
||||
setSelectedCoopId(firstCoop?.id || '');
|
||||
}
|
||||
}, [selectedLocationId, coops, initialFloorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFloorId) {
|
||||
const firstFloor = floors.find((f) => f.coopId === selectedCoopId);
|
||||
onFloorChange(firstFloor?.id || null);
|
||||
}
|
||||
// We only want this to run when the coop changes, not when the parent's floor changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCoopId, floors, initialFloorId]);
|
||||
|
||||
const availableCoops = useMemo(
|
||||
() => coops.filter((c) => c.locationId === selectedLocationId),
|
||||
[coops, selectedLocationId]
|
||||
);
|
||||
const availableFloors = useMemo(
|
||||
() => floors.filter((f) => f.coopId === selectedCoopId),
|
||||
[floors, selectedCoopId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Lokasi
|
||||
</label>
|
||||
<select
|
||||
id="location"
|
||||
value={selectedLocationId}
|
||||
onChange={(e) => setSelectedLocationId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white"
|
||||
>
|
||||
{locations.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="coop" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kandang
|
||||
</label>
|
||||
<select
|
||||
id="coop"
|
||||
value={selectedCoopId}
|
||||
onChange={(e) => setSelectedCoopId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white"
|
||||
disabled={!selectedLocationId}
|
||||
>
|
||||
{availableCoops.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="floor" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Lantai
|
||||
</label>
|
||||
<select
|
||||
id="floor"
|
||||
value={initialFloorId || ''}
|
||||
onChange={(e) => onFloorChange(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white"
|
||||
disabled={!selectedCoopId}
|
||||
>
|
||||
<option value="">-- Pilih Lantai --</option>
|
||||
{availableFloors.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationSelector;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { useMarkdown } from '../../hooks/useMarkdown.ts';
|
||||
|
||||
interface MarkdownWithTocProps {
|
||||
fileUrl: string;
|
||||
}
|
||||
|
||||
const MarkdownWithToc: React.FC<MarkdownWithTocProps> = ({ fileUrl }) => {
|
||||
const { markdownHtml, toc, isLoading, error } = useMarkdown(fileUrl);
|
||||
|
||||
const renderLayout = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center col-span-full py-20">
|
||||
<i className="fa-solid fa-spinner fa-spin text-4xl text-gray-400"></i>
|
||||
<p className="mt-2 text-gray-600">Loading Document...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center text-red-500 col-span-full py-20">
|
||||
<i className="fa-solid fa-exclamation-triangle text-4xl"></i>
|
||||
<p className="mt-2 font-semibold">Could not load document</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<aside className="w-full lg:w-1/4 mb-8 lg:mb-0">
|
||||
<div className="sticky top-24 self-start bg-white p-4 rounded-lg shadow-md border border-gray-200">
|
||||
<h3 className="font-bold text-lg mb-3">Daftar Isi</h3>
|
||||
{toc.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{toc.map((item) => (
|
||||
<li key={item.id} style={{ marginLeft: `${(item.level - 1) * 1}rem` }}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
className="text-gray-600 hover:text-red-600 text-sm hover:underline transition-colors"
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Tidak ada daftar isi.</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="w-full lg:w-3/4 bg-white p-6 sm:p-8 md:p-12 rounded-lg shadow-lg">
|
||||
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-8">{renderLayout()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownWithToc;
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user