Add exceptional hour modals

This commit is contained in:
2025-10-19 21:17:28 +01:00
parent 5efc893269
commit 17ebaeba94
4 changed files with 667 additions and 156 deletions
+197 -59
View File
@@ -3,6 +3,7 @@ package scheduling
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv"
"time" "time"
"crussell/db" "crussell/db"
@@ -21,19 +22,14 @@ type ExceptionalGroup struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Hours []ExceptionalHours `json:"hours,omitempty"` // opening hours per day, 7 (day) entries per week Hours []ExceptionalHours `json:"hours,omitempty"`
WeekStarts []string `json:"weekStarts,omitempty"`
} }
type ExceptionalApplication struct { // --- List Groups with Hours and Applications ---
ID int `json:"id"`
GroupID int `json:"groupId"`
WeekStart string `json:"weekStart"` // week beginning monday the Xth
}
// --- Exceptional Groups & Hours ---
func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) { func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query(r.Context(), ` rows, err := db.DB.Query(r.Context(), `
SELECT id,name,description SELECT id, name, description
FROM exceptional_working_hours_groups FROM exceptional_working_hours_groups
ORDER BY id DESC ORDER BY id DESC
`) `)
@@ -50,125 +46,267 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to scan group", http.StatusInternalServerError) http.Error(w, "failed to scan group", http.StatusInternalServerError)
return return
} }
// load 7-day hours
hoursRows, _ := db.DB.Query(r.Context(), ` // Load 7-day hours
hoursRows, err := db.DB.Query(r.Context(), `
SELECT id, weekday, start_time::text, end_time::text, is_open SELECT id, weekday, start_time::text, end_time::text, is_open
FROM exceptional_working_hours FROM exceptional_working_hours
WHERE group_id=$1 ORDER BY weekday WHERE group_id=$1 ORDER BY weekday
`, g.ID) `, g.ID)
if err != nil {
http.Error(w, "failed to fetch group hours", http.StatusInternalServerError)
return
}
for hoursRows.Next() { for hoursRows.Next() {
var h ExceptionalHours var h ExceptionalHours
if err := hoursRows.Scan(&h.ID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err == nil { if err := hoursRows.Scan(&h.ID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil {
h.GroupID = g.ID hoursRows.Close()
g.Hours = append(g.Hours, h) http.Error(w, "failed to scan hours", http.StatusInternalServerError)
return
} }
h.GroupID = g.ID
g.Hours = append(g.Hours, h)
} }
hoursRows.Close() hoursRows.Close()
if err := hoursRows.Err(); err != nil {
http.Error(w, "error iterating hours", http.StatusInternalServerError)
return
}
// Load applied week starts
weekRows, err := db.DB.Query(r.Context(), `
SELECT week_start
FROM exceptional_group_applications
WHERE group_id=$1 ORDER BY week_start
`, g.ID)
if err != nil {
http.Error(w, "failed to fetch applications", http.StatusInternalServerError)
return
}
for weekRows.Next() {
var weekStart time.Time
if err := weekRows.Scan(&weekStart); err != nil {
weekRows.Close()
http.Error(w, "failed to scan week_start", http.StatusInternalServerError)
return
}
g.WeekStarts = append(g.WeekStarts, weekStart.Format("2006-01-02"))
}
weekRows.Close()
if err := weekRows.Err(); err != nil {
http.Error(w, "error iterating applications", http.StatusInternalServerError)
return
}
groups = append(groups, g) groups = append(groups, g)
} }
if err := rows.Err(); err != nil {
http.Error(w, "error iterating groups", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(groups) json.NewEncoder(w).Encode(groups)
} }
// --- Create Group with Hours and Applications (bulk) ---
func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) { func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
var g ExceptionalGroup var g ExceptionalGroup
if err := json.NewDecoder(r.Body).Decode(&g); err != nil { if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest) http.Error(w, "invalid payload", http.StatusBadRequest)
return return
} }
if len(g.Hours) != 7 { if len(g.Hours) != 7 {
http.Error(w, "must provide 7 weekday entries", http.StatusBadRequest) http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
return return
} }
// Validate weekdays and parse week_starts
weekdaysSeen := make(map[int]bool)
for _, h := range g.Hours {
if h.Weekday < 0 || h.Weekday > 6 {
http.Error(w, "weekday must be 0-6", http.StatusBadRequest)
return
}
if weekdaysSeen[h.Weekday] {
http.Error(w, "duplicate weekday entries", http.StatusBadRequest)
return
}
weekdaysSeen[h.Weekday] = true
}
var parsedWeeks []time.Time
for _, ws := range g.WeekStarts {
weekStart, err := time.Parse("2006-01-02", ws)
if err != nil {
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
if weekStart.Weekday() != time.Monday {
http.Error(w, "week_start must be a Monday", http.StatusBadRequest)
return
}
parsedWeeks = append(parsedWeeks, weekStart)
}
tx, err := db.DB.Begin(r.Context()) tx, err := db.DB.Begin(r.Context())
if err != nil { if err != nil {
http.Error(w, "failed to start tx", http.StatusInternalServerError) http.Error(w, "failed to start transaction", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
// Create group
err = tx.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
INSERT INTO exceptional_working_hours_groups (name, description) INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ($1,$2) RETURNING id VALUES ($1, $2) RETURNING id
`, g.Name, g.Description).Scan(&g.ID) `, g.Name, g.Description).Scan(&g.ID)
if err != nil { if err != nil {
http.Error(w, "failed to create group", http.StatusInternalServerError) http.Error(w, "failed to create group", http.StatusInternalServerError)
return return
} }
for _, h := range g.Hours { // Insert hours and collect their IDs
_, err := tx.Exec(r.Context(), ` inputHours := g.Hours
g.Hours = []ExceptionalHours{} // Clear and rebuild with IDs
for _, h := range inputHours {
var id int
err := tx.QueryRow(r.Context(), `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1,$2,$3,$4,$5) VALUES ($1, $2, $3, $4, $5)
`, g.ID, h.Weekday, h.StartTime, h.EndTime, h.IsOpen) RETURNING id
`, g.ID, h.Weekday, h.StartTime, h.EndTime, h.IsOpen).Scan(&id)
if err != nil { if err != nil {
http.Error(w, "failed to insert group hours", http.StatusInternalServerError) http.Error(w, "failed to insert hours", http.StatusInternalServerError)
return
}
h.ID = id
h.GroupID = g.ID
g.Hours = append(g.Hours, h)
}
// Insert applications
for _, weekStart := range parsedWeeks {
_, err := tx.Exec(r.Context(), `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2)
`, g.ID, weekStart)
if err != nil {
http.Error(w, "failed to insert application", http.StatusInternalServerError)
return return
} }
} }
if err := tx.Commit(r.Context()); err != nil { if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "failed to commit", http.StatusInternalServerError) http.Error(w, "failed to commit transaction", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(g) json.NewEncoder(w).Encode(g)
} }
// --- Exceptional Applications (assign a group to a week) --- // --- Delete Group (cascades to hours and applications) ---
func ListExceptionalApplications(w http.ResponseWriter, r *http.Request) { func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query(r.Context(), ` // Extract group ID from URL path or query params
SELECT id, group_id, week_start // Assuming you have a router that provides this, e.g. chi, mux, etc.
FROM exceptional_group_applications // For this example, we'll use query param: DELETE /exceptional-groups?id=123
ORDER BY week_start DESC idStr := r.URL.Query().Get("id")
`) if idStr == "" {
if err != nil { http.Error(w, "missing id parameter", http.StatusBadRequest)
http.Error(w, "failed to fetch applications", http.StatusInternalServerError)
return return
} }
defer rows.Close()
var list []ExceptionalApplication id, err := strconv.Atoi(idStr)
for rows.Next() { if err != nil {
var a ExceptionalApplication http.Error(w, "invalid id parameter", http.StatusBadRequest)
var weekStart time.Time return
if err := rows.Scan(&a.ID, &a.GroupID, &weekStart); err != nil {
http.Error(w, "failed to scan application", http.StatusInternalServerError)
return
}
a.WeekStart = weekStart.Format("2006-01-02")
list = append(list, a)
} }
w.Header().Set("Content-Type", "application/json") result, err := db.DB.Exec(r.Context(), `
json.NewEncoder(w).Encode(list) DELETE FROM exceptional_working_hours_groups WHERE id=$1
`, id)
if err != nil {
http.Error(w, "failed to delete group", http.StatusInternalServerError)
return
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "group not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
} }
func CreateExceptionalApplication(w http.ResponseWriter, r *http.Request) { // --- Update Applied Weeks (replaces all applications for a group) ---
var a ExceptionalApplication func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&a); err != nil { var req struct {
GroupID int `json:"groupId"`
WeekStarts []string `json:"weekStarts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest) http.Error(w, "invalid payload", http.StatusBadRequest)
return return
} }
weekStart, err := time.Parse("2006-01-02", a.WeekStart)
// Validate and parse weeks
var parsedWeeks []time.Time
for _, ws := range req.WeekStarts {
weekStart, err := time.Parse("2006-01-02", ws)
if err != nil {
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
if weekStart.Weekday() != time.Monday {
http.Error(w, "week_start must be a Monday", http.StatusBadRequest)
return
}
parsedWeeks = append(parsedWeeks, weekStart)
}
tx, err := db.DB.Begin(r.Context())
if err != nil { if err != nil {
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest) http.Error(w, "failed to start transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Delete existing applications for this group
_, err = tx.Exec(r.Context(), `
DELETE FROM exceptional_group_applications WHERE group_id=$1
`, req.GroupID)
if err != nil {
http.Error(w, "failed to delete existing applications", http.StatusInternalServerError)
return return
} }
err = db.DB.QueryRow(r.Context(), ` // Insert new applications
INSERT INTO exceptional_group_applications (group_id, week_start) for _, weekStart := range parsedWeeks {
VALUES ($1,$2) _, err := tx.Exec(r.Context(), `
RETURNING id INSERT INTO exceptional_group_applications (group_id, week_start)
`, a.GroupID, weekStart).Scan(&a.ID) VALUES ($1, $2)
if err != nil { `, req.GroupID, weekStart)
http.Error(w, "failed to create application", http.StatusInternalServerError) if err != nil {
http.Error(w, "failed to insert application", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "failed to commit transaction", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNoContent)
json.NewEncoder(w).Encode(a)
} }
+4 -3
View File
@@ -77,11 +77,11 @@ func main() {
r.Post("/register", authHandlers.RegisterHandler) r.Post("/register", authHandlers.RegisterHandler)
r.Post("/login", authHandlers.LoginHandler) r.Post("/login", authHandlers.LoginHandler)
// --- Scheduling public GET routes --- // --- Scheduling Routes ---
r.Route("/scheduling", func(r chi.Router) { r.Route("/scheduling", func(r chi.Router) {
// Public GET routes
r.Get("/default-hours", scheduling.GetDefaultHours) r.Get("/default-hours", scheduling.GetDefaultHours)
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups) r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
r.Get("/exceptional-applications", scheduling.ListExceptionalApplications)
r.Get("/working-hours", scheduling.GetWorkingHours) r.Get("/working-hours", scheduling.GetWorkingHours)
r.Get("/available-hours", scheduling.GetAvailableHours) r.Get("/available-hours", scheduling.GetAvailableHours)
@@ -92,7 +92,8 @@ func main() {
r.Put("/default-hours", scheduling.UpdateDefaultHours) r.Put("/default-hours", scheduling.UpdateDefaultHours)
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup) r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
r.Post("/exceptional-applications", scheduling.CreateExceptionalApplication) r.Delete("/exceptional-groups", scheduling.DeleteExceptionalGroup)
r.Put("/exceptional-applications", scheduling.UpdateExceptionalApplications)
}) })
}) })
+458 -92
View File
@@ -178,43 +178,83 @@
id?: number; id?: number;
name: string; name: string;
description: string; description: string;
week_starts: string[]; weekStarts: string[];
rows: WorkingHourRow[]; hours: WorkingHourRow[];
}; };
// DEMO DATA: Exception Groups // Replace the demo data with empty array and add loading state
let exceptionGroups = $state<ExceptionGroup[]>([ let exceptionGroups = $state<ExceptionGroup[]>([]);
{ let exceptionGroupsLoading = $state(true);
id: 1,
name: 'Christmas Week', // Add state for the exception modal
description: 'Closed from Mon-Wed, open reduced hours Thu/Fri', let exceptionDraft = $state<ExceptionGroup>({
week_starts: ['2025-12-22'], name: '',
rows: [ description: '',
{ weekday: 0, start_time: '09:00', end_time: '17:00', is_open: false }, weekStarts: [],
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: false }, hours: [
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: false }, { weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 3, start_time: '10:00', end_time: '15:00', is_open: true }, { weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 4, start_time: '10:00', end_time: '15:00', is_open: true }, { weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: false }, { weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '09:00', end_time: '17:00', is_open: false } { weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
] { weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
}, { weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
{ ]
id: 2, });
name: 'Summer Holiday',
description: 'Closed on Mondays/Tuesdays only', let weekRangeFrom = $state('');
week_starts: ['2026-07-06', '2026-07-13', '2026-07-20'], let weekRangeTo = $state('');
rows: [
{ weekday: 0, start_time: '09:00', end_time: '17:00', is_open: false }, async function fetchExceptionGroups() {
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: false }, if (pageState !== 'authorized') return;
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true }, exceptionGroupsLoading = true;
{ weekday: 4, start_time: '09:00', end_time: '17:00', is_open: true }, try {
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: false }, const response = await fetch('/api/scheduling/exceptional-groups', {
{ weekday: 6, start_time: '09:00', end_time: '17:00', is_open: false } method: 'GET',
] headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
if (data === null || data.length === 0) {
return;
}
exceptionGroups = data.map((group: any) => ({
id: group.id,
name: group.name,
description: group.description,
weekStarts: group.weekStarts || [],
hours:
group.hours?.map((h: any) => ({
id: h.id,
weekday: h.weekday,
start_time: formatTime(h.startTime),
end_time: formatTime(h.endTime),
is_open: h.isOpen
})) || []
}));
} else {
console.error('Failed to fetch exception groups:', response.status);
toast.error('Failed to load exception groups');
}
} catch (err) {
console.error('Error fetching exception groups:', err);
toast.error('Network error loading exception groups');
} finally {
exceptionGroupsLoading = false;
} }
]); }
// Fetch on mount
$effect(() => {
if (pageState === 'authorized') {
fetchExceptionGroups();
}
});
let loadingHours = $state(false); let loadingHours = $state(false);
let savingHours = $state(false); let savingHours = $state(false);
@@ -283,24 +323,133 @@
} }
async function saveExceptionGroup() { async function saveExceptionGroup() {
// TODO // Validate
} if (!exceptionDraft.name.trim()) {
toast.error('Please enter a group name');
return;
}
if (exceptionDraft.weekStarts.length === 0) {
toast.error('Please add at least one week');
return;
}
savingHours = true;
const loadingToast = toast.loading('Creating exception group...');
async function confirmDeleteExceptionGroup() {
try { try {
await new Promise((r) => setTimeout(r, 500)); // Map to API format
exceptionGroups = exceptionGroups.filter((g) => g.id !== exceptionToDelete); const payload = {
showDeleteExceptionAlert = false; name: exceptionDraft.name,
exceptionToDelete = undefined; description: exceptionDraft.description,
weekStarts: exceptionDraft.weekStarts,
hours: exceptionDraft.hours.map((h) => ({
weekday: h.weekday,
startTime: h.start_time,
endTime: h.end_time,
isOpen: h.is_open
}))
};
const response = await fetch('/api/scheduling/exceptional-groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success('Exception group created successfully!', { id: loadingToast });
showExceptionModal = false;
resetExceptionForm();
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
}
} catch (err) { } catch (err) {
console.error(err); console.error('Error creating exception group:', err);
toast.error('Network error creating exception group', { id: loadingToast });
} finally {
savingHours = false;
} }
} }
async function confirmDeleteExceptionGroup() {
if (exceptionToDelete === undefined) return;
const loadingToast = toast.loading('Deleting exception group...');
try {
const response = await fetch(`/api/scheduling/exceptional-groups?id=${exceptionToDelete}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok || response.status === 204) {
toast.success('Exception group deleted successfully!', { id: loadingToast });
showDeleteExceptionAlert = false;
exceptionToDelete = undefined;
// Refresh the exception groups list
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error deleting exception group:', err);
toast.error('Network error deleting exception group', { id: loadingToast });
}
}
function openViewExceptionModal(exception: ExceptionGroup) {
viewingException = exception;
showViewExceptionModal = true;
}
function resetExceptionForm() {
exceptionDraft = {
name: '',
description: '',
weekStarts: [],
hours: [
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
]
};
weekRangeFrom = '';
weekRangeTo = '';
}
function createNewException() { function createNewException() {
resetExceptionForm();
showExceptionModal = true; showExceptionModal = true;
} }
function addWeekRange() {
if (!weekRangeFrom || !weekRangeTo) {
toast.error('Please select both start and end dates');
return;
}
addWeeksToException(weekRangeFrom, weekRangeTo, exceptionDraft.weekStarts);
weekRangeFrom = '';
weekRangeTo = '';
}
function removeWeek(index: number) {
exceptionDraft.weekStarts = exceptionDraft.weekStarts.filter((_, i) => i !== index);
}
// =============== Users & Bookings =============== // =============== Users & Bookings ===============
type User = { type User = {
id: string; id: string;
@@ -464,8 +613,12 @@
const to = new Date(toISO + 'T00:00:00'); const to = new Date(toISO + 'T00:00:00');
const first = new Date(from); const first = new Date(from);
const day = first.getDay(); const day = first.getDay();
const mondayOffset = (day + 6) % 7; const daysToMonday = day === 0 ? -6 : 1 - day;
first.setDate(first.getDate() - mondayOffset);
// Set to the Monday of the current week
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) { for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new Date(d))); dest.push(isoDateOf(new Date(d)));
} }
@@ -819,6 +972,8 @@
let showSaveDefaultHoursAlert = $state(false); let showSaveDefaultHoursAlert = $state(false);
let showDeleteExceptionAlert = $state(false); let showDeleteExceptionAlert = $state(false);
let exceptionToDelete = $state<number | undefined>(undefined); let exceptionToDelete = $state<number | undefined>(undefined);
let showViewExceptionModal = $state(false);
let viewingException = $state<ExceptionGroup | null>(null);
</script> </script>
{#if pageState === 'loading'} {#if pageState === 'loading'}
@@ -1130,52 +1285,52 @@
<Button variant="default" onclick={createNewException}>New schedule</Button> <Button variant="default" onclick={createNewException}>New schedule</Button>
</div> </div>
<div class="grid grid-cols-1 gap-4 space-y-3 md:grid-cols-2"> {#if exceptionGroupsLoading}
{#if exceptionGroups.length === 0} <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<p class="text-sm text-gray-500">No exception groups found.</p> {#each Array(2) as _, i}
{/if} <Skeleton class="h-32 w-full" />
{/each}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#if exceptionGroups.length === 0}
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
{/if}
{#each exceptionGroups as g} {#each exceptionGroups as g}
<div class="relative h-full rounded border p-3 pb-12"> <div class="relative h-full rounded border p-3 pb-12">
<!-- add bottom padding to avoid overlap --> <div class="flex h-full flex-col gap-3">
<div class="flex h-full flex-col gap-3"> <div class="flex-1">
<div class="flex-1"> <div class="font-semibold">{g.name}</div>
<div class="font-semibold">{g.name}</div> <div class="text-sm text-gray-600">{g.description}</div>
<div class="text-sm text-gray-600">{g.description}</div> <div class="mt-1 text-xs text-gray-500">
<div class="mt-1 text-xs text-gray-500"> Applies to weeks: {g.weekStarts?.slice(0, 5).join(', ')}
Applies to weeks: {g.week_starts?.slice(0, 5).join(', ')} {#if (g.weekStarts?.length ?? 0) > 5}
{#if (g.week_starts?.length ?? 0) > 5} (+{(g.weekStarts?.length ?? 0) - 5} more)
(+{(g.week_starts?.length ?? 0) - 5} more) {/if}
{/if} </div>
</div>
<div class="absolute bottom-3 right-3 flex gap-2">
<Button variant="default" size="sm" onclick={() => openViewExceptionModal(g)}>
View
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
Delete
</Button>
</div> </div>
</div> </div>
<!-- Absolute positioned button -->
<div class="absolute bottom-3 right-3">
<Button
variant="default"
class="w-20"
onclick={() => {
alert('TODO - view group');
}}
>
View
</Button>
<Button
variant="destructive"
class="w-20"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
Delete
</Button>
</div>
</div> </div>
</div> {/each}
{/each} </div>
</div> {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -1530,21 +1685,149 @@
<!-- Exception Group Modal --> <!-- Exception Group Modal -->
<Modal.Root bind:open={showExceptionModal}> <Modal.Root bind:open={showExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto p-4 md:max-w-lg"> <Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header class="mb-4 p-0"> <Modal.Header>
<Modal.Title class="text-lg font-semibold">New Exception Group</Modal.Title> <Modal.Title class="text-lg font-semibold">Create Exception Schedule</Modal.Title>
<Modal.Description>
Define custom working hours for holidays, closures, or special events.
</Modal.Description>
</Modal.Header> </Modal.Header>
<Modal.Footer class="flex items-center justify-end gap-2 p-0 pt-4">
<div class="space-y-6 px-4 pb-4">
<!-- Basic Info -->
<div class="space-y-4">
<div class="space-y-2">
<label for="exception-name" class="text-sm font-medium">Schedule Name *</label>
<Input
id="exception-name"
type="text"
placeholder="e.g., Christmas Week, Summer Holiday"
bind:value={exceptionDraft.name}
/>
</div>
<div class="space-y-2">
<label for="exception-description" class="text-sm font-medium">Description</label>
<Input
id="exception-description"
type="text"
placeholder="Brief description of the service, will be shown to customers"
bind:value={exceptionDraft.description}
/>
</div>
</div>
<Separator />
<!-- Week Selection -->
<div class="space-y-4">
<div>
<h3 class="mb-2 text-sm font-medium">Apply to Weeks *</h3>
<p class="mb-3 text-xs text-gray-500">
Select a date range to add all Mondays within that range
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-2">
<label for="week-from" class="text-xs text-gray-600">From Date</label>
<Input id="week-from" type="date" bind:value={weekRangeFrom} />
</div>
<div class="space-y-2">
<label for="week-to" class="text-xs text-gray-600">To Date</label>
<Input id="week-to" type="date" bind:value={weekRangeTo} />
</div>
</div>
<Button variant="outline" size="sm" onclick={addWeekRange} class="mt-3">
Add Week Range
</Button>
</div>
{#if exceptionDraft.weekStarts.length > 0}
<div class="space-y-2">
<div class="text-xs text-gray-600">
Selected weeks ({exceptionDraft.weekStarts.length}):
</div>
<div class="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{#each exceptionDraft.weekStarts as week, index}
<div class="flex items-center justify-between text-sm">
<span>Week starting: {week}</span>
<button
class="text-xs text-red-500 hover:text-red-700"
onclick={() => removeWeek(index)}
>
Remove
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours for these Weeks *</h3>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto text-sm">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Open</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each exceptionDraft.hours as row}
<tr class="border-t">
<td class="py-2">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<input
type="checkbox"
bind:checked={row.is_open}
class="h-4 w-4 rounded border-gray-300 bg-gray-100"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.start_time}
disabled={!row.is_open}
class="w-24 text-sm"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.end_time}
disabled={!row.is_open}
class="w-24 text-sm"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button <Button
variant="outline" variant="outline"
onclick={() => { onclick={() => {
showExceptionModal = false; showExceptionModal = false;
resetExceptionForm();
}} }}
disabled={savingHours}
> >
Cancel Cancel
</Button> </Button>
<Button onclick={saveExceptionGroup} disabled={savingHours}> <Button onclick={saveExceptionGroup} disabled={savingHours}>
{savingHours ? 'Saving…' : 'Save'} {savingHours ? 'Creating…' : 'Create Schedule'}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal.Content> </Modal.Content>
@@ -1573,6 +1856,89 @@
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
<!-- View Exception Modal -->
{#if viewingException}
<Modal.Root bind:open={showViewExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">{viewingException.name}</Modal.Title>
<Modal.Description>
{viewingException.description || 'Holiday schedule details'}
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Applied Weeks -->
<div class="space-y-2">
<h3 class="text-sm font-medium">Applied to Weeks</h3>
<div class="max-h-48 space-y-1 overflow-y-auto rounded border bg-gray-50 p-3">
{#if viewingException.weekStarts && viewingException.weekStarts.length > 0}
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
{#each viewingException.weekStarts as week}
<div class="text-sm">
Week of {new Date(week).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-gray-500">No weeks specified</p>
{/if}
</div>
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours</h3>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Status</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each viewingException.hours as row}
<tr class="border-t">
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<span
class="text-sm font-medium {row.is_open
? 'text-emerald-600'
: 'text-red-600'}"
>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="py-2 text-sm">
{row.is_open ? row.start_time : '—'}
</td>
<td class="py-2 text-sm">
{row.is_open ? row.end_time : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showViewExceptionModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
<!-- User Modal --> <!-- User Modal -->
{#if selectedUser} {#if selectedUser}
<Modal.Root bind:open={showUserModal}> <Modal.Root bind:open={showUserModal}>
@@ -1669,7 +2035,7 @@
<Input <Input
id="service-description" id="service-description"
type="text" type="text"
placeholder="Brief description of the service" placeholder="Brief description of the service, will be shown to customers"
bind:value={newService.description} bind:value={newService.description}
class="w-full" class="w-full"
/> />
+8 -2
View File
@@ -302,7 +302,7 @@
} }
// Check if we have working hours data // Check if we have working hours data
if (!workingHours) return false; if (!workingHours) return true; // Changed from false to true when data not loaded
const dateStr = date.toString(); const dateStr = date.toString();
const dayHours = workingHours[dateStr]; const dayHours = workingHours[dateStr];
@@ -472,7 +472,13 @@
const dayWorkingHours = workingHours[dateStr]; const dayWorkingHours = workingHours[dateStr];
const dayAvailableHours = availableHours[dateStr]; const dayAvailableHours = availableHours[dateStr];
if (!dayWorkingHours || !dayWorkingHours.isOpen || !dayAvailableHours) { // Add check for slots existence
if (
!dayWorkingHours ||
!dayWorkingHours.isOpen ||
!dayAvailableHours ||
!dayAvailableHours.slots
) {
return []; return [];
} }