Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ed4889310 |
@@ -95,7 +95,6 @@ jobs:
|
|||||||
password: ${{ secrets.ACCESS_TOKEN }}
|
password: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: Prepare Docker Metadata
|
- name: Prepare Docker Metadata
|
||||||
if: gitea.ref == 'refs/heads/main'
|
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
# Lowercase Repository
|
# Lowercase Repository
|
||||||
@@ -108,11 +107,10 @@ jobs:
|
|||||||
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Docker Image
|
- name: Build and Push Docker Image
|
||||||
if: gitea.ref == 'refs/heads/main'
|
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: ${{ github.ref_name == 'main' }}
|
||||||
tags: |
|
tags: |
|
||||||
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
||||||
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { SearchBar } from "@/components/molecules/search-bar";
|
|
||||||
import { ShopGrid } from "@/components/organisms/shop-grid";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export default function FeedPage() {
|
|
||||||
const [searchName, setSearchName] = useState("");
|
|
||||||
const [searchAddress, setSearchAddress] = useState("");
|
|
||||||
|
|
||||||
const hasFilters = searchName || searchAddress;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
|
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 md:px-6 lg:px-8">
|
|
||||||
{/* Page title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-foreground text-2xl font-bold md:text-3xl">
|
|
||||||
Explore Shops
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
|
||||||
Find and choose your favorite shop
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shop grid */}
|
|
||||||
<div className="mb-10">
|
|
||||||
<ShopGrid searchName={searchName} searchAddress={searchAddress} />
|
|
||||||
{hasFilters && (
|
|
||||||
<div className="mt-4 flex justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSearchName("");
|
|
||||||
setSearchAddress("");
|
|
||||||
}}
|
|
||||||
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter / Search bar — sticky bottom */}
|
|
||||||
<div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5">
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
|
|
||||||
<i className="fa-solid fa-filter text-(--color-primary)"></i>
|
|
||||||
<span>Filter shops</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name search */}
|
|
||||||
<SearchBar
|
|
||||||
value={searchName}
|
|
||||||
onChange={setSearchName}
|
|
||||||
onClear={() => setSearchName("")}
|
|
||||||
placeholder="Search by shop name..."
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Address search — different icon so not using SearchBar atom */}
|
|
||||||
<div className="relative min-w-0 flex-1">
|
|
||||||
<i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchAddress}
|
|
||||||
onChange={(e) => setSearchAddress(e.target.value)}
|
|
||||||
placeholder="Search by address..."
|
|
||||||
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
{searchAddress && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchAddress("")}
|
|
||||||
aria-label="Clear address search"
|
|
||||||
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-xmark text-sm"></i>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { FeedLayout } from "@/components/templates/feed-layout";
|
|
||||||
|
|
||||||
export default function RootFeedLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <FeedLayout>{children}</FeedLayout>;
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import ShiftDetailModal from "@/components/organisms/shift-schedule/ShiftDetailM
|
|||||||
import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule";
|
import WeeklySchedule from "@/components/organisms/shift-schedule/WeeklySchedule";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { useShift } from "@/lib/shift-context";
|
import { useShift } from "@/lib/shift-context";
|
||||||
import type { ShiftSlot } from "@/lib/types";
|
import type { ShiftEntity } from "@/lib/types";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -52,24 +52,24 @@ export default function StaffSchedulePage() {
|
|||||||
getWeeklyBudget,
|
getWeeklyBudget,
|
||||||
} = useShift();
|
} = useShift();
|
||||||
|
|
||||||
const [selectedShift, setSelectedShift] = useState<ShiftSlot | null>(null);
|
const [selectedShift, setSelectedShift] = useState<ShiftEntity | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createDate, setCreateDate] = useState<string | undefined>();
|
const [createDate, setCreateDate] = useState<Date | undefined>();
|
||||||
|
|
||||||
const isManager = user?.role === "manager";
|
const isManager = user?.role === "manager";
|
||||||
|
|
||||||
const handleShiftClick = (shift: ShiftSlot) => {
|
const handleShiftClick = (shift: ShiftEntity) => {
|
||||||
setSelectedShift(shift);
|
setSelectedShift(shift);
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateShift = (date: string) => {
|
const handleCreateShift = (date: Date) => {
|
||||||
setCreateDate(date);
|
setCreateDate(date);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateSelect = (date: string) => {
|
const handleDateSelect = (date: Date) => {
|
||||||
// In month view on desktop, clicking a date could open create modal for managers
|
// In month view on desktop, clicking a date could open create modal for managers
|
||||||
if (isManager) {
|
if (isManager) {
|
||||||
setCreateDate(date);
|
setCreateDate(date);
|
||||||
|
|||||||
@@ -1,35 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ShiftSlot } from "@/lib/types";
|
import type { ShiftEntity } from "@/lib/types";
|
||||||
|
|
||||||
import type { ShiftCardProps } from "./ShiftCard.types";
|
import type { ShiftCardProps } from "./ShiftCard.types";
|
||||||
|
|
||||||
const STATUS_STYLES: Record<
|
|
||||||
ShiftSlot["status"],
|
|
||||||
{ bg: string; text: string; label: string }
|
|
||||||
> = {
|
|
||||||
available: {
|
|
||||||
bg: "bg-blue-50 border-blue-200",
|
|
||||||
text: "text-blue-700",
|
|
||||||
label: "Còn trống",
|
|
||||||
},
|
|
||||||
registered: {
|
|
||||||
bg: "bg-blue-100 border-blue-400",
|
|
||||||
text: "text-blue-900",
|
|
||||||
label: "Đã đăng ký",
|
|
||||||
},
|
|
||||||
approved_leave: {
|
|
||||||
bg: "bg-purple-50 border-purple-300",
|
|
||||||
text: "text-purple-700",
|
|
||||||
label: "Nghỉ phép",
|
|
||||||
},
|
|
||||||
absent: {
|
|
||||||
bg: "bg-red-50 border-red-300",
|
|
||||||
text: "text-red-700",
|
|
||||||
label: "Vắng mặt",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatWage(wage: number): string {
|
function formatWage(wage: number): string {
|
||||||
if (wage >= 1000) {
|
if (wage >= 1000) {
|
||||||
return `${(wage / 1000).toFixed(0)}k`;
|
return `${(wage / 1000).toFixed(0)}k`;
|
||||||
@@ -42,20 +16,20 @@ export default function ShiftCard({
|
|||||||
compact = false,
|
compact = false,
|
||||||
onClick,
|
onClick,
|
||||||
}: ShiftCardProps) {
|
}: ShiftCardProps) {
|
||||||
const style = STATUS_STYLES[shift.status];
|
console.log(shift);
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onClick?.(shift)}
|
onClick={() => onClick?.(shift)}
|
||||||
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm ${style.bg} ${style.text}`}
|
className={`w-full cursor-pointer rounded-lg border px-2 py-1.5 text-left text-xs transition-shadow hover:shadow-sm `}
|
||||||
>
|
>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{shift.startTime} – {shift.endTime}
|
{shift.startTime} – {shift.endTime}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 opacity-75">
|
<p className="mt-0.5 opacity-75">
|
||||||
{shift.durationHours}h · {formatWage(shift.wage)}
|
{}h · {formatWage(shift.wage)}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -65,7 +39,7 @@ export default function ShiftCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onClick?.(shift)}
|
onClick={() => onClick?.(shift)}
|
||||||
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md ${style.bg} ${style.text}`}
|
className={`w-full cursor-pointer rounded-xl border p-3 text-left transition-shadow hover:shadow-md `}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -73,25 +47,26 @@ export default function ShiftCard({
|
|||||||
{shift.startTime} – {shift.endTime}
|
{shift.startTime} – {shift.endTime}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs opacity-75">
|
<p className="mt-1 text-xs opacity-75">
|
||||||
{shift.durationHours}h · {formatWage(shift.wage)} VND
|
{}h · {formatWage(shift.wage)} VND
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||||
shift.status === "available"
|
// shift.status === "available"
|
||||||
? "bg-blue-200 text-blue-800"
|
// ? "bg-blue-200 text-blue-800"
|
||||||
: shift.status === "registered"
|
// : shift.status === "registered"
|
||||||
? "bg-blue-300 text-blue-900"
|
// ? "bg-blue-300 text-blue-900"
|
||||||
: shift.status === "approved_leave"
|
// : shift.status === "approved_leave"
|
||||||
? "bg-purple-200 text-purple-800"
|
// ? "bg-purple-200 text-purple-800"
|
||||||
: "bg-red-200 text-red-800"
|
// : "bg-red-200 text-red-800"
|
||||||
|
""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{style.label}
|
{/* {style.label} */}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shift.registeredStaff.length > 0 && (
|
{(shift.registeredStaff && shift.registeredStaff.length > 0) && (
|
||||||
<div className="mt-2 border-t border-current/10 pt-2">
|
<div className="mt-2 border-t border-current/10 pt-2">
|
||||||
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
|
<p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
|
||||||
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
|
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
|
||||||
@@ -102,18 +77,18 @@ export default function ShiftCard({
|
|||||||
key={s.id}
|
key={s.id}
|
||||||
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
|
className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-medium"
|
||||||
>
|
>
|
||||||
{s.name}
|
{s.staffId}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shift.status === "available" && shift.registeredStaff.length === 0 && (
|
{/* {shift.status === "available" && shift.registeredStaff.length === 0 && (
|
||||||
<p className="mt-2 text-[10px] italic opacity-50">
|
<p className="mt-2 text-[10px] italic opacity-50">
|
||||||
{shift.maxStaff} vị trí còn trống
|
{shift.maxStaff} vị trí còn trống
|
||||||
</p>
|
</p>
|
||||||
)}
|
)} */}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ShiftSlot } from "@/lib/types";
|
import type { ShiftEntity } from "@/lib/types";
|
||||||
|
|
||||||
export interface ShiftCardProps {
|
export interface ShiftCardProps {
|
||||||
shift: ShiftSlot;
|
shift: ShiftEntity;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
onClick?: (shift: ShiftSlot) => void;
|
onClick?: (shift: ShiftEntity) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export { ReviewModal } from "./modals";
|
|||||||
export type { ReviewModalProps, ConfirmModalProps } from "./modals";
|
export type { ReviewModalProps, ConfirmModalProps } from "./modals";
|
||||||
|
|
||||||
// Shop Grid
|
// Shop Grid
|
||||||
export { ShopGrid } from "./shop-grid";
|
// export { ShopGrid } from "./shop-grid";
|
||||||
export type { ShopGridProps } from "./shop-grid";
|
export type { ShopGridProps } from "./shop-grid";
|
||||||
|
|
||||||
// Manager
|
// Manager
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ export default function MobileShiftView({
|
|||||||
onShiftClick,
|
onShiftClick,
|
||||||
}: MobileShiftViewProps) {
|
}: MobileShiftViewProps) {
|
||||||
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date(2026, 3, 10));
|
||||||
formatDateISO(new Date(2026, 3, 10)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
@@ -67,21 +65,20 @@ export default function MobileShiftView({
|
|||||||
}, [currentDate]);
|
}, [currentDate]);
|
||||||
|
|
||||||
const getDotColors = (date: Date): string[] => {
|
const getDotColors = (date: Date): string[] => {
|
||||||
const dateStr = formatDateISO(date);
|
const dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
|
||||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
|
||||||
const dots: string[] = [];
|
const dots: string[] = [];
|
||||||
if (dayShifts.some((s) => s.status === "available"))
|
// if (dayShifts.some((s) => s.status === "available"))
|
||||||
dots.push("bg-amber-400");
|
// dots.push("bg-amber-400");
|
||||||
if (dayShifts.some((s) => s.status === "registered"))
|
// if (dayShifts.some((s) => s.status === "registered"))
|
||||||
dots.push("bg-green-500");
|
// dots.push("bg-green-500");
|
||||||
if (dayShifts.some((s) => s.status === "approved_leave"))
|
// if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||||
dots.push("bg-purple-400");
|
// dots.push("bg-purple-400");
|
||||||
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
|
// if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
|
||||||
return dots;
|
return dots;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedShifts = useMemo(() => {
|
const selectedShifts = useMemo(() => {
|
||||||
return shifts.filter((s) => s.date === selectedDate);
|
return shifts.filter((s) => s.date.getDate() === selectedDate.getDate());
|
||||||
}, [shifts, selectedDate]);
|
}, [shifts, selectedDate]);
|
||||||
|
|
||||||
const selectedDateObj = new Date(selectedDate + "T00:00:00");
|
const selectedDateObj = new Date(selectedDate + "T00:00:00");
|
||||||
@@ -133,16 +130,15 @@ export default function MobileShiftView({
|
|||||||
return <div key={`empty-${i}`} className="p-1" />;
|
return <div key={`empty-${i}`} className="p-1" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateStr = formatDateISO(date);
|
|
||||||
const today = isToday(date);
|
const today = isToday(date);
|
||||||
const selected = dateStr === selectedDate;
|
const selected = date === selectedDate;
|
||||||
const dots = getDotColors(date);
|
const dots = getDotColors(date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setSelectedDate(dateStr)}
|
onClick={() => setSelectedDate(date)}
|
||||||
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
|
className={`flex cursor-pointer flex-col items-center border-none bg-transparent p-1 transition ${
|
||||||
selected ? "rounded-lg bg-(--color-primary)/10" : ""
|
selected ? "rounded-lg bg-(--color-primary)/10" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -218,9 +214,7 @@ export default function MobileShiftView({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{DEPARTMENTS.map((dept) => {
|
{DEPARTMENTS.map((dept) => {
|
||||||
const deptShifts = selectedShifts.filter(
|
const deptShifts = selectedShifts;
|
||||||
(s) => s.department === dept.id,
|
|
||||||
);
|
|
||||||
if (deptShifts.length === 0) return null;
|
if (deptShifts.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={dept.id}>
|
<div key={dept.id}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
|
|||||||
|
|
||||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
|
||||||
function formatDateISO(d: Date): string {
|
export function formatDateISO(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
const day = d.getDate().toString().padStart(2, "0");
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
@@ -60,15 +60,14 @@ export default function MonthlyCalendar({
|
|||||||
}, [currentDate]);
|
}, [currentDate]);
|
||||||
|
|
||||||
const getShiftSummary = (date: Date) => {
|
const getShiftSummary = (date: Date) => {
|
||||||
const dateStr = formatDateISO(date);
|
const dayShifts = shifts.filter((s) => s.date.getDate() === date.getDate());
|
||||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
// const available = dayShifts.filter((s) => s.status === "available").length;
|
||||||
const available = dayShifts.filter((s) => s.status === "available").length;
|
// const registered = dayShifts.filter(
|
||||||
const registered = dayShifts.filter(
|
// (s) => s.status === "registered",
|
||||||
(s) => s.status === "registered",
|
// ).length;
|
||||||
).length;
|
// const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
|
||||||
const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
|
// const absent = dayShifts.filter((s) => s.status === "absent").length;
|
||||||
const absent = dayShifts.filter((s) => s.status === "absent").length;
|
return { total: dayShifts.length };
|
||||||
return { total: dayShifts.length, available, registered, leave, absent };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,7 +103,7 @@ export default function MonthlyCalendar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onDateSelect?.(formatDateISO(date))}
|
onClick={() => onDateSelect?.(date)}
|
||||||
className={`min-h-25 cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${
|
className={`min-h-25 cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${
|
||||||
today ? "bg-(--color-primary)/5" : ""
|
today ? "bg-(--color-primary)/5" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -119,7 +118,7 @@ export default function MonthlyCalendar({
|
|||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{summary.total > 0 && (
|
{/* {summary.total > 0 && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{summary.available > 0 && (
|
{summary.available > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -154,7 +153,7 @@ export default function MonthlyCalendar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DEPARTMENTS } from "@/lib/constants";
|
|||||||
import { useShift } from "@/lib/shift-context";
|
import { useShift } from "@/lib/shift-context";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { formatDateISO } from "./MonthlyCalendar";
|
||||||
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
export default function ShiftCreateModal({
|
export default function ShiftCreateModal({
|
||||||
@@ -13,7 +14,7 @@ export default function ShiftCreateModal({
|
|||||||
}: ShiftCreateModalProps) {
|
}: ShiftCreateModalProps) {
|
||||||
const { createShift } = useShift();
|
const { createShift } = useShift();
|
||||||
|
|
||||||
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
const [date, setDate] = useState<Date>(new Date(defaultDate ?? "2026-04-10"));
|
||||||
const [startTime, setStartTime] = useState("08:00");
|
const [startTime, setStartTime] = useState("08:00");
|
||||||
const [endTime, setEndTime] = useState("12:00");
|
const [endTime, setEndTime] = useState("12:00");
|
||||||
const [department, setDepartment] = useState("bar");
|
const [department, setDepartment] = useState("bar");
|
||||||
@@ -23,7 +24,7 @@ export default function ShiftCreateModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setDate(defaultDate ?? "2026-04-10");
|
setDate(new Date(defaultDate ?? "2026-04-10"));
|
||||||
}
|
}
|
||||||
}, [defaultDate, isOpen]);
|
}, [defaultDate, isOpen]);
|
||||||
|
|
||||||
@@ -49,18 +50,12 @@ export default function ShiftCreateModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationHours = (endMinutes - startMinutes) / 60;
|
|
||||||
|
|
||||||
createShift({
|
createShift({
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
durationHours,
|
|
||||||
wage,
|
wage,
|
||||||
department,
|
|
||||||
maxStaff,
|
maxStaff,
|
||||||
registeredStaff: [],
|
|
||||||
status: "available",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
@@ -103,8 +98,8 @@ export default function ShiftCreateModal({
|
|||||||
<input
|
<input
|
||||||
title="Date"
|
title="Date"
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={formatDateISO(date)}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(new Date(e.target.value))}
|
||||||
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
|
className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ export default function ShiftDetailModal({
|
|||||||
|
|
||||||
if (!isOpen || !shift) return null;
|
if (!isOpen || !shift) return null;
|
||||||
|
|
||||||
const dept = DEPARTMENTS.find((d) => d.id === shift.department);
|
// const dept = DEPARTMENTS.find((d) => d.id === shift.department);
|
||||||
const isManager = user?.role === "manager";
|
const isManager = user?.role === "manager";
|
||||||
const isRegistered = user
|
const isRegistered = user
|
||||||
? shift.registeredStaff.some((s) => s.id === user.id)
|
? shift.registeredStaff!.some((s) => s.id === user.id)
|
||||||
: false;
|
: false;
|
||||||
const isFull = shift.registeredStaff.length >= shift.maxStaff;
|
const isFull = shift.registeredStaff!.length >= shift.maxStaff;
|
||||||
|
|
||||||
const handleRegister = () => {
|
const handleRegister = () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -47,7 +47,7 @@ export default function ShiftDetailModal({
|
|||||||
setTimeout(onClose, 1200);
|
setTimeout(onClose, 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManagerUnregister = (staffId: number) => {
|
const handleManagerUnregister = (staffId: string) => {
|
||||||
unregisterShift(shift.id, staffId);
|
unregisterShift(shift.id, staffId);
|
||||||
setSuccess("Đã xóa nhân viên khỏi ca.");
|
setSuccess("Đã xóa nhân viên khỏi ca.");
|
||||||
};
|
};
|
||||||
@@ -81,12 +81,12 @@ export default function ShiftDetailModal({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
|
{/* {dept && <i className={`${dept.icon} text-(--color-primary)`}></i>} */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-foreground text-base font-bold">
|
<h2 className="text-foreground text-base font-bold">
|
||||||
Chi tiết ca làm
|
Chi tiết ca làm
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
|
{/* <p className="text-xs text-(--color-text-muted)">{dept?.name}</p> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -102,13 +102,13 @@ export default function ShiftDetailModal({
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="space-y-4 px-5 py-4">
|
||||||
{/* Status badge */}
|
{/* Status badge */}
|
||||||
<div className="flex items-center gap-2">
|
{/* <div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
|
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
|
||||||
>
|
>
|
||||||
{statusLabel[shift.status]}
|
{statusLabel[shift.status]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* Shift info grid */}
|
{/* Shift info grid */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -141,7 +141,7 @@ export default function ShiftDetailModal({
|
|||||||
Thời lượng
|
Thời lượng
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground mt-1 text-sm font-bold">
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{shift.durationHours} giờ
|
{""} giờ
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
@@ -157,16 +157,16 @@ export default function ShiftDetailModal({
|
|||||||
{/* Registered staff */}
|
{/* Registered staff */}
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
|
||||||
Nhân viên đã đăng ký ({shift.registeredStaff.length}/
|
Nhân viên đã đăng ký ({shift.registeredStaff!.length}/
|
||||||
{shift.maxStaff})
|
{shift.maxStaff})
|
||||||
</p>
|
</p>
|
||||||
{shift.registeredStaff.length === 0 ? (
|
{(shift.registeredStaff && shift.registeredStaff.length === 0) ? (
|
||||||
<p className="text-xs text-(--color-text-muted) italic">
|
<p className="text-xs text-(--color-text-muted) italic">
|
||||||
Chưa có ai đăng ký
|
Chưa có ai đăng ký
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{shift.registeredStaff.map((staff) => (
|
{shift.registeredStaff!.map((staff) => (
|
||||||
<div
|
<div
|
||||||
key={staff.id}
|
key={staff.id}
|
||||||
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
|
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2"
|
||||||
@@ -176,7 +176,7 @@ export default function ShiftDetailModal({
|
|||||||
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
|
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-foreground text-sm font-medium">
|
<span className="text-foreground text-sm font-medium">
|
||||||
{staff.name}
|
{staff.staffId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isManager && (
|
{isManager && (
|
||||||
@@ -212,7 +212,7 @@ export default function ShiftDetailModal({
|
|||||||
|
|
||||||
{/* Footer actions */}
|
{/* Footer actions */}
|
||||||
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
|
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
|
||||||
{!isRegistered &&
|
{/* {!isRegistered &&
|
||||||
!isFull &&
|
!isFull &&
|
||||||
shift.status !== "approved_leave" &&
|
shift.status !== "approved_leave" &&
|
||||||
shift.status !== "absent" && (
|
shift.status !== "absent" && (
|
||||||
@@ -224,7 +224,7 @@ export default function ShiftDetailModal({
|
|||||||
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||||
Đăng ký ca
|
Đăng ký ca
|
||||||
</button>
|
</button>
|
||||||
)}
|
)} */}
|
||||||
{isRegistered && (
|
{isRegistered && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import type { ShiftSlot } from "@/lib/types";
|
import type { ShiftEntity } from "@/lib/types";
|
||||||
|
|
||||||
export interface WeeklyScheduleProps {
|
export interface WeeklyScheduleProps {
|
||||||
onShiftClick: (shift: ShiftSlot) => void;
|
onShiftClick: (shift: ShiftEntity) => void;
|
||||||
onCreateShift?: (date: string) => void;
|
onCreateShift?: (date: Date) => void;
|
||||||
mobileCalendarHeader?: boolean;
|
mobileCalendarHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonthlyCalendarProps {
|
export interface MonthlyCalendarProps {
|
||||||
onShiftClick: (shift: ShiftSlot) => void;
|
onShiftClick: (shift: ShiftEntity) => void;
|
||||||
onDateSelect?: (date: string) => void;
|
onDateSelect?: (date: Date) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MobileShiftViewProps {
|
export interface MobileShiftViewProps {
|
||||||
onShiftClick: (shift: ShiftSlot) => void;
|
onShiftClick: (shift: ShiftEntity) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftDetailModalProps {
|
export interface ShiftDetailModalProps {
|
||||||
shift: ShiftSlot | null;
|
shift: ShiftEntity | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -24,5 +24,5 @@ export interface ShiftDetailModalProps {
|
|||||||
export interface ShiftCreateModalProps {
|
export interface ShiftCreateModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
defaultDate?: string;
|
defaultDate?: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function formatDateISO(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isToday(d: Date): boolean {
|
function isToday(d: Date): boolean {
|
||||||
const today = new Date(2026, 3, 10);
|
const today = new Date(Date.now());
|
||||||
return (
|
return (
|
||||||
d.getDate() === today.getDate() &&
|
d.getDate() === today.getDate() &&
|
||||||
d.getMonth() === today.getMonth() &&
|
d.getMonth() === today.getMonth() &&
|
||||||
@@ -59,36 +59,32 @@ export default function WeeklySchedule({
|
|||||||
} = useShift();
|
} = useShift();
|
||||||
const weekDates = getWeekDates();
|
const weekDates = getWeekDates();
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(
|
const [selectedDate, setSelectedDate] = useState<Date>(
|
||||||
formatDateISO(weekDates[0] ?? currentDate),
|
weekDates[0] ?? currentDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusDotsByDate = useMemo(() => {
|
const statusDotsByDate = useMemo(() => {
|
||||||
const map: Record<string, string[]> = {};
|
const map: Record<string, string[]> = {};
|
||||||
weekDates.forEach((date) => {
|
weekDates.forEach((date) => {
|
||||||
const dateStr = formatDateISO(date);
|
|
||||||
const dayShifts = getShiftsForDate(dateStr);
|
|
||||||
const dots: string[] = [];
|
const dots: string[] = [];
|
||||||
if (dayShifts.some((s) => s.status === "available"))
|
// if (dayShifts.some((s) => s.status === "available"))
|
||||||
dots.push("bg-sky-300");
|
// dots.push("bg-sky-300");
|
||||||
if (dayShifts.some((s) => s.status === "registered"))
|
// if (dayShifts.some((s) => s.status === "registered"))
|
||||||
dots.push("bg-blue-600");
|
// dots.push("bg-blue-600");
|
||||||
if (dayShifts.some((s) => s.status === "approved_leave"))
|
// if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||||
dots.push("bg-purple-400");
|
// dots.push("bg-purple-400");
|
||||||
if (dayShifts.some((s) => s.status === "absent"))
|
// if (dayShifts.some((s) => s.status === "absent"))
|
||||||
dots.push("bg-rose-400");
|
// dots.push("bg-rose-400");
|
||||||
map[dateStr] = dots.slice(0, 3);
|
map[date.toISOString()] = dots.slice(0, 3);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [weekDates, getShiftsForDate]);
|
}, [weekDates, getShiftsForDate]);
|
||||||
|
|
||||||
const selectedDateObj = useMemo(() => {
|
const selectedDateStr = useMemo(() => {
|
||||||
const inWeek = weekDates.find((d) => formatDateISO(d) === selectedDate);
|
const inWeek = weekDates.find((d) => d === selectedDate);
|
||||||
return inWeek ?? weekDates[0] ?? currentDate;
|
return inWeek ?? weekDates[0] ?? currentDate;
|
||||||
}, [selectedDate, weekDates, currentDate]);
|
}, [selectedDate, weekDates, currentDate]);
|
||||||
|
|
||||||
const selectedDateStr = formatDateISO(selectedDateObj);
|
|
||||||
|
|
||||||
const renderMobileDayView = mobileCalendarHeader && (
|
const renderMobileDayView = mobileCalendarHeader && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||||
@@ -116,14 +112,13 @@ export default function WeeklySchedule({
|
|||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{weekDates.map((date, i) => {
|
{weekDates.map((date, i) => {
|
||||||
const dateStr = formatDateISO(date);
|
const active = date === selectedDateStr;
|
||||||
const active = dateStr === selectedDateStr;
|
const dots = statusDotsByDate[date.toISOString()] ?? [];
|
||||||
const dots = statusDotsByDate[dateStr] ?? [];
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedDate(dateStr)}
|
onClick={() => setSelectedDate(date)}
|
||||||
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
|
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
|
||||||
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
@@ -135,14 +130,14 @@ export default function WeeklySchedule({
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
|
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
|
||||||
active || isToday(date)
|
active || isToday(new Date(date))
|
||||||
? "bg-indigo-500 text-white"
|
? "bg-indigo-500 text-white"
|
||||||
: i >= 5
|
: i >= 5
|
||||||
? "text-pink-500"
|
? "text-pink-500"
|
||||||
: "text-sky-500"
|
: "text-sky-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{date.getDate()}
|
{new Date(date).getDate()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex min-h-2 items-center gap-0.5">
|
<div className="flex min-h-2 items-center gap-0.5">
|
||||||
{dots.map((dot, idx) => (
|
{dots.map((dot, idx) => (
|
||||||
@@ -160,9 +155,10 @@ export default function WeeklySchedule({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{DEPARTMENTS.map((dept) => {
|
{DEPARTMENTS.map((dept) => {
|
||||||
const deptShifts = getShiftsForDate(selectedDateStr).filter(
|
const deptShifts = getShiftsForDate(new Date(selectedDateStr));
|
||||||
(s) => s.department === dept.id,
|
// .filter(
|
||||||
);
|
// (s) => s.department === dept.id,
|
||||||
|
// );
|
||||||
if (deptShifts.length === 0 && !onCreateShift) return null;
|
if (deptShifts.length === 0 && !onCreateShift) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -219,14 +215,14 @@ export default function WeeklySchedule({
|
|||||||
<th
|
<th
|
||||||
key={i}
|
key={i}
|
||||||
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
|
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
|
||||||
isToday(date)
|
isToday(new Date(date))
|
||||||
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
|
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
|
||||||
: "bg-gray-50 font-semibold text-(--color-text-muted)"
|
: "bg-gray-50 font-semibold text-(--color-text-muted)"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block uppercase">{DAY_LABELS[i]}</span>
|
<span className="block uppercase">{DAY_LABELS[i]}</span>
|
||||||
<span className="mt-0.5 block text-[11px] font-normal">
|
<span className="mt-0.5 block text-[11px] font-normal">
|
||||||
{formatDateShort(date)}
|
{date.toISOString()}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -246,15 +242,16 @@ export default function WeeklySchedule({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{weekDates.map((date, i) => {
|
{weekDates.map((date, i) => {
|
||||||
const dateStr = formatDateISO(date);
|
const dateStr = date;
|
||||||
const shifts = getShiftsForDate(dateStr).filter(
|
const shifts = getShiftsForDate(new Date(date));
|
||||||
(s) => s.department === dept.id,
|
// .filter(
|
||||||
);
|
// (s) => s.department === dept.id,
|
||||||
|
// );
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={i}
|
key={i}
|
||||||
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
|
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
|
||||||
isToday(date) ? "bg-(--color-primary)/5" : ""
|
isToday(new Date(date)) ? "bg-(--color-primary)/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-20 flex-col gap-1">
|
<div className="flex min-h-20 flex-col gap-1">
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ShopCard } from "@/components/molecules/cards";
|
// import { ShopCard } from "@/components/molecules/cards";
|
||||||
import { MOCK_SHOPS } from "@/lib/constants";
|
// import { MOCK_SHOPS } from "@/lib/constants";
|
||||||
|
|
||||||
import type { ShopGridProps } from "./ShopGrid.types";
|
// import type { ShopGridProps } from "./ShopGrid.types";
|
||||||
|
|
||||||
export default function ShopGrid({
|
// export default function ShopGrid({
|
||||||
searchName = "",
|
// searchName = "",
|
||||||
searchAddress = "",
|
// searchAddress = "",
|
||||||
}: ShopGridProps) {
|
// }: ShopGridProps) {
|
||||||
const filtered = MOCK_SHOPS.filter((shop) => {
|
// const filtered = MOCK_SHOPS.filter((shop) => {
|
||||||
const matchesName =
|
// const matchesName =
|
||||||
searchName.trim() === "" ||
|
// searchName.trim() === "" ||
|
||||||
shop.name.toLowerCase().includes(searchName.toLowerCase());
|
// shop.name.toLowerCase().includes(searchName.toLowerCase());
|
||||||
const matchesAddress =
|
// const matchesAddress =
|
||||||
searchAddress.trim() === "" ||
|
// searchAddress.trim() === "" ||
|
||||||
shop.address.toLowerCase().includes(searchAddress.toLowerCase());
|
// shop.address.toLowerCase().includes(searchAddress.toLowerCase());
|
||||||
return matchesName && matchesAddress;
|
// return matchesName && matchesAddress;
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
// if (filtered.length === 0) {
|
||||||
return (
|
// return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
// <div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
||||||
<i className="fa-solid fa-store text-5xl opacity-30"></i>
|
// <i className="fa-solid fa-store text-5xl opacity-30"></i>
|
||||||
<p className="text-base font-medium">
|
// <p className="text-base font-medium">
|
||||||
No shops found matching your search
|
// No shops found matching your search
|
||||||
</p>
|
// </p>
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
// <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{filtered.map((shop) => (
|
// {filtered.map((shop) => (
|
||||||
<ShopCard
|
// <ShopCard
|
||||||
key={shop.id}
|
// key={shop.id}
|
||||||
id={shop.id}
|
// id={shop.id}
|
||||||
name={shop.name}
|
// name={shop.name}
|
||||||
address={shop.address}
|
// address={shop.address}
|
||||||
image={shop.image}
|
// image={shop.image}
|
||||||
/>
|
// />
|
||||||
))}
|
// ))}
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as ShopGrid } from "./ShopGrid";
|
// export { default as ShopGrid } from "./ShopGrid";
|
||||||
export type { ShopGridProps } from "./ShopGrid.types";
|
export type { ShopGridProps } from "./ShopGrid.types";
|
||||||
|
|||||||
+18
-64
@@ -1,13 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
Department,
|
Department,
|
||||||
MenuItemEntity,
|
|
||||||
ProductSalesStats,
|
|
||||||
RevenueDataPoint,
|
RevenueDataPoint,
|
||||||
ShiftSlot,
|
ShiftEntity,
|
||||||
Shop,
|
|
||||||
ShopInfo,
|
ShopInfo,
|
||||||
SocialLinks,
|
SocialLinks,
|
||||||
User,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// ===== SHOP INFORMATION =====
|
// ===== SHOP INFORMATION =====
|
||||||
@@ -33,45 +29,6 @@ export const SOCIAL_LINKS: SocialLinks = {
|
|||||||
website: "/",
|
website: "/",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== MOCK SHOPS (for Feed page) =====
|
|
||||||
export const MOCK_SHOPS: Shop[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "The Coffee House",
|
|
||||||
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
|
|
||||||
image:
|
|
||||||
"https://images.unsplash.com/photo-1554118811-1e0d58224f24?w=600&h=400&fit=crop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Highlands Coffee",
|
|
||||||
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
|
|
||||||
image:
|
|
||||||
"https://images.unsplash.com/photo-1559305616-3f99cd43e353?w=600&h=400&fit=crop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Phúc Long Heritage",
|
|
||||||
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
|
|
||||||
image:
|
|
||||||
"https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=600&h=400&fit=crop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "Katinat Saigon Kafe",
|
|
||||||
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
|
|
||||||
image:
|
|
||||||
"https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600&h=400&fit=crop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: "Trung Nguyên E-Coffee",
|
|
||||||
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
|
|
||||||
image:
|
|
||||||
"https://images.unsplash.com/photo-1453614512568-c4024d13c247?w=600&h=400&fit=crop",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ===== MOCK FINANCIAL DATA =====
|
// ===== MOCK FINANCIAL DATA =====
|
||||||
|
|
||||||
// Daily revenue for the last 30 days (current month)
|
// Daily revenue for the last 30 days (current month)
|
||||||
@@ -153,9 +110,9 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
|
|||||||
* Generate mock shift slots for the weeks around today (April 2026).
|
* Generate mock shift slots for the weeks around today (April 2026).
|
||||||
* Covers Mon 6 Apr – Sun 26 Apr 2026.
|
* Covers Mon 6 Apr – Sun 26 Apr 2026.
|
||||||
*/
|
*/
|
||||||
function generateMockShifts(): ShiftSlot[] {
|
function generateMockShifts(): ShiftEntity[] {
|
||||||
const shifts: ShiftSlot[] = [];
|
const shifts: ShiftEntity[] = [];
|
||||||
const departments = ["bar", "kitchen", "cashier", "janitor"];
|
const departments = ["waiter"];
|
||||||
const timeSlots = [
|
const timeSlots = [
|
||||||
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 },
|
{ start: "07:00", end: "11:00", hours: 4, wage: 120000 },
|
||||||
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 },
|
{ start: "11:00", end: "15:00", hours: 4, wage: 120000 },
|
||||||
@@ -189,7 +146,7 @@ function generateMockShifts(): ShiftSlot[] {
|
|||||||
|
|
||||||
// Determine registration status
|
// Determine registration status
|
||||||
const registered: { id: number; name: string }[] = [];
|
const registered: { id: number; name: string }[] = [];
|
||||||
let status: ShiftSlot["status"] = "available";
|
// let status: ShiftEntity["status"] = "available";
|
||||||
|
|
||||||
// Past shifts (before April 10) are mostly registered
|
// Past shifts (before April 10) are mostly registered
|
||||||
if (day < 10) {
|
if (day < 10) {
|
||||||
@@ -198,37 +155,37 @@ function generateMockShifts(): ShiftSlot[] {
|
|||||||
if (shiftCounter % 7 === 0) {
|
if (shiftCounter % 7 === 0) {
|
||||||
registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
|
registered.push(staffPool[(staffIdx + 1) % staffPool.length]);
|
||||||
}
|
}
|
||||||
status = "registered";
|
// status = "registered";
|
||||||
// Some past shifts have leave/absent
|
// Some past shifts have leave/absent
|
||||||
if (shiftCounter % 11 === 0) status = "approved_leave";
|
// if (shiftCounter % 11 === 0) status = "approved_leave";
|
||||||
if (shiftCounter % 13 === 0) status = "absent";
|
// if (shiftCounter % 13 === 0) status = "absent";
|
||||||
}
|
}
|
||||||
// Current week (April 6-12): mix of registered and available
|
// Current week (April 6-12): mix of registered and available
|
||||||
else if (day >= 10 && day <= 12) {
|
else if (day >= 10 && day <= 12) {
|
||||||
if (shiftCounter % 3 === 0) {
|
if (shiftCounter % 3 === 0) {
|
||||||
registered.push(staffPool[shiftCounter % staffPool.length]);
|
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||||
status = "registered";
|
// status = "registered";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Future shifts: mostly available, some registered
|
// Future shifts: mostly available, some registered
|
||||||
else {
|
else {
|
||||||
if (shiftCounter % 5 === 0) {
|
if (shiftCounter % 5 === 0) {
|
||||||
registered.push(staffPool[shiftCounter % staffPool.length]);
|
registered.push(staffPool[shiftCounter % staffPool.length]);
|
||||||
status = "registered";
|
// status = "registered";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shifts.push({
|
shifts.push({
|
||||||
id: "shiftId",
|
id: "shiftId",
|
||||||
date: dateStr,
|
date: new Date(dateStr),
|
||||||
startTime: slot.start,
|
startTime: slot.start,
|
||||||
endTime: slot.end,
|
endTime: slot.end,
|
||||||
durationHours: slot.hours,
|
// durationHours: slot.hours,
|
||||||
wage: slot.wage,
|
wage: slot.wage,
|
||||||
department: dept,
|
// department: dept,
|
||||||
maxStaff: dept === "bar" ? 3 : 2,
|
maxStaff: dept === "bar" ? 3 : 2,
|
||||||
registeredStaff: registered,
|
// registeredStaff: registered,
|
||||||
status,
|
// status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,11 +194,8 @@ function generateMockShifts(): ShiftSlot[] {
|
|||||||
return shifts;
|
return shifts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
|
export const MOCK_SHIFT_SLOTS: ShiftEntity[] = generateMockShifts();
|
||||||
|
|
||||||
export const DEPARTMENTS: Department[] = [
|
export const DEPARTMENTS: Department[] = [
|
||||||
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" },
|
{ id: "waiter", name: "Bar Staff", icon: "fa-brands fa-jenkins" },
|
||||||
{ id: "kitchen", name: "Kitchen", icon: "fa-solid fa-kitchen-set" },
|
];
|
||||||
{ id: "cashier", name: "Cashier", icon: "fa-solid fa-cash-register" },
|
|
||||||
{ id: "janitor", name: "Janitor", icon: "fa-solid fa-broom" },
|
|
||||||
];
|
|
||||||
|
|||||||
+108
-50
@@ -1,15 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { gql } from "@apollo/client";
|
||||||
|
import { useMutation, useQuery } from "@apollo/client/react";
|
||||||
import {
|
import {
|
||||||
ReactNode,
|
ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { MOCK_SHIFT_SLOTS } from "./constants";
|
import { eateryClient } from "./apollo-clients";
|
||||||
import type { ShiftSlot, ShiftStatus } from "./types";
|
import {
|
||||||
|
type ShiftEntity,
|
||||||
|
type allShiftsQuery,
|
||||||
|
createShiftMutation,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -17,7 +24,7 @@ export type ScheduleView = "week" | "month";
|
|||||||
|
|
||||||
interface ShiftContextType {
|
interface ShiftContextType {
|
||||||
// Data
|
// Data
|
||||||
shifts: ShiftSlot[];
|
shifts: ShiftEntity[];
|
||||||
view: ScheduleView;
|
view: ScheduleView;
|
||||||
setView: (view: ScheduleView) => void;
|
setView: (view: ScheduleView) => void;
|
||||||
|
|
||||||
@@ -32,23 +39,23 @@ interface ShiftContextType {
|
|||||||
// Shift actions
|
// Shift actions
|
||||||
registerShift: (
|
registerShift: (
|
||||||
shiftId: string,
|
shiftId: string,
|
||||||
staffId: number,
|
staffId: string,
|
||||||
staffName: string,
|
staffName: string,
|
||||||
) => { success: boolean; error?: string };
|
) => { success: boolean; error?: string };
|
||||||
unregisterShift: (shiftId: string, staffId: number) => void;
|
unregisterShift: (shiftId: string, staffId: string) => void;
|
||||||
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
createShift: (shift: Omit<ShiftEntity, "id">) => void;
|
||||||
updateShift: (shift: ShiftSlot) => void;
|
updateShift: (shift: ShiftEntity) => void;
|
||||||
deleteShift: (shiftId: string) => void;
|
deleteShift: (shiftId: string) => void;
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
getShiftsForDate: (date: string) => ShiftSlot[];
|
getShiftsForDate: (date: Date) => ShiftEntity[];
|
||||||
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
getShiftsForWeek: (weekStart: Date) => ShiftEntity[];
|
||||||
getWeekDates: () => Date[];
|
getWeekDates: () => Date[];
|
||||||
hasConflict: (
|
hasConflict: (
|
||||||
date: string,
|
date: Date,
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
staffId: number,
|
staffId: string,
|
||||||
excludeShiftId?: string,
|
excludeShiftId?: string,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
getWeeklyBudget: () => number;
|
getWeeklyBudget: () => number;
|
||||||
@@ -94,13 +101,60 @@ function timesOverlap(
|
|||||||
return s1 < e2 && s2 < e1;
|
return s1 < e2 && s2 < e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ___ GraphQL __________________________________________________________________
|
||||||
|
|
||||||
|
const GET_ALL_SHIFTS = gql`
|
||||||
|
query allShifts {
|
||||||
|
allShifts {
|
||||||
|
id
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
maxStaff
|
||||||
|
wage
|
||||||
|
registeredStaff {
|
||||||
|
staffId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CREATE_SHIFT = gql`
|
||||||
|
mutation createShift($shiftInput: CreateShiftInput!) {
|
||||||
|
createShift(shiftInput: $shiftInput) {
|
||||||
|
id
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
maxStaff
|
||||||
|
wage
|
||||||
|
registeredStaff {
|
||||||
|
staffId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ShiftProvider({ children }: { children: ReactNode }) {
|
export function ShiftProvider({ children }: { children: ReactNode }) {
|
||||||
const [shifts, setShifts] = useState<ShiftSlot[]>(MOCK_SHIFT_SLOTS);
|
const [shifts, setShifts] = useState<ShiftEntity[]>([]);
|
||||||
const [view, setView] = useState<ScheduleView>("week");
|
const [view, setView] = useState<ScheduleView>("week");
|
||||||
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
|
const [currentDate, setCurrentDate] = useState<Date>(new Date(2026, 3, 10)); // April 10, 2026
|
||||||
|
|
||||||
|
const { data } = useQuery<allShiftsQuery>(GET_ALL_SHIFTS, {
|
||||||
|
client: eateryClient,
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [mutateCreateShift] = useMutation<createShiftMutation>(CREATE_SHIFT, {
|
||||||
|
client: eateryClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) setShifts(data.allShifts);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
// ── Navigation ──────────────────────────────────────────────────────────
|
// ── Navigation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const goToNextWeek = useCallback(() => {
|
const goToNextWeek = useCallback(() => {
|
||||||
@@ -151,18 +205,20 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [currentDate]);
|
}, [currentDate]);
|
||||||
|
|
||||||
const getShiftsForDate = useCallback(
|
const getShiftsForDate = useCallback(
|
||||||
(date: string): ShiftSlot[] => {
|
(date: Date): ShiftEntity[] => {
|
||||||
return shifts.filter((s) => s.date === date);
|
return shifts.filter((s) => {
|
||||||
|
return new Date(s.date).getDate() === date.getDate();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[shifts],
|
[shifts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getShiftsForWeek = useCallback(
|
const getShiftsForWeek = useCallback(
|
||||||
(weekStart: Date): ShiftSlot[] => {
|
(weekStart: Date): ShiftEntity[] => {
|
||||||
const dates = Array.from({ length: 7 }, (_, i) => {
|
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||||
const d = new Date(weekStart);
|
const d = new Date(weekStart);
|
||||||
d.setDate(weekStart.getDate() + i);
|
d.setDate(weekStart.getDate() + i);
|
||||||
return formatDate(d);
|
return d;
|
||||||
});
|
});
|
||||||
return shifts.filter((s) => dates.includes(s.date));
|
return shifts.filter((s) => dates.includes(s.date));
|
||||||
},
|
},
|
||||||
@@ -171,17 +227,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const hasConflict = useCallback(
|
const hasConflict = useCallback(
|
||||||
(
|
(
|
||||||
date: string,
|
date: Date,
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
staffId: number,
|
staffId: string,
|
||||||
excludeShiftId?: string,
|
excludeShiftId?: string,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return shifts.some(
|
return shifts.some(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.date === date &&
|
s.date === date &&
|
||||||
s.id !== excludeShiftId &&
|
s.id !== excludeShiftId &&
|
||||||
s.registeredStaff.some((rs) => rs.id === staffId) &&
|
s.registeredStaff!.some((rs) => rs.id === staffId) &&
|
||||||
timesOverlap(startTime, endTime, s.startTime, s.endTime),
|
timesOverlap(startTime, endTime, s.startTime, s.endTime),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -189,10 +245,12 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getWeeklyBudget = useCallback((): number => {
|
const getWeeklyBudget = useCallback((): number => {
|
||||||
const weekDates = getWeekDates().map(formatDate);
|
const weekDates = getWeekDates();
|
||||||
return shifts
|
return shifts
|
||||||
.filter((s) => weekDates.includes(s.date) && s.registeredStaff.length > 0)
|
.filter(
|
||||||
.reduce((sum, s) => sum + s.wage * s.registeredStaff.length, 0);
|
(s) => weekDates.includes(s.date) && s.registeredStaff!.length > 0,
|
||||||
|
)
|
||||||
|
.reduce((sum, s) => sum + s.wage * s.registeredStaff!.length, 0);
|
||||||
}, [shifts, getWeekDates]);
|
}, [shifts, getWeekDates]);
|
||||||
|
|
||||||
// ── Shift actions ───────────────────────────────────────────────────────
|
// ── Shift actions ───────────────────────────────────────────────────────
|
||||||
@@ -200,17 +258,17 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
const registerShift = useCallback(
|
const registerShift = useCallback(
|
||||||
(
|
(
|
||||||
shiftId: string,
|
shiftId: string,
|
||||||
staffId: number,
|
staffId: string,
|
||||||
staffName: string,
|
staffName: string,
|
||||||
): { success: boolean; error?: string } => {
|
): { success: boolean; error?: string } => {
|
||||||
const shift = shifts.find((s) => s.id === shiftId);
|
const shift = shifts.find((s) => s.id === shiftId);
|
||||||
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
|
if (!shift) return { success: false, error: "Ca làm không tồn tại." };
|
||||||
|
|
||||||
if (shift.registeredStaff.length >= shift.maxStaff) {
|
if (shift.registeredStaff!.length >= shift.maxStaff) {
|
||||||
return { success: false, error: "Ca làm đã đủ người." };
|
return { success: false, error: "Ca làm đã đủ người." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shift.registeredStaff.some((rs) => rs.id === staffId)) {
|
if (shift.registeredStaff!.some((rs) => rs.id === staffId)) {
|
||||||
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
|
return { success: false, error: "Bạn đã đăng ký ca này rồi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,50 +287,50 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setShifts((prev) =>
|
// setShifts((prev) =>
|
||||||
prev.map((s) =>
|
// prev.map((s) =>
|
||||||
s.id === shiftId
|
// s.id === shiftId
|
||||||
? {
|
// ? {
|
||||||
...s,
|
// ...s,
|
||||||
registeredStaff: [
|
// registeredStaff: [
|
||||||
...s.registeredStaff,
|
// ...s.registeredStaff,
|
||||||
{ id: staffId, name: staffName },
|
// { staffId, name: staffName },
|
||||||
],
|
// ],
|
||||||
status: "registered" as ShiftStatus,
|
// status: "registered" as ShiftStatus,
|
||||||
}
|
// }
|
||||||
: s,
|
// : s,
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
[shifts, hasConflict],
|
[shifts, hasConflict],
|
||||||
);
|
);
|
||||||
|
|
||||||
const unregisterShift = useCallback((shiftId: string, staffId: number) => {
|
const unregisterShift = useCallback((shiftId: string, staffId: string) => {
|
||||||
setShifts((prev) =>
|
setShifts((prev) =>
|
||||||
prev.map((s) => {
|
prev.map((s) => {
|
||||||
if (s.id !== shiftId) return s;
|
if (s.id !== shiftId) return s;
|
||||||
const updated = s.registeredStaff.filter((rs) => rs.id !== staffId);
|
const updated = s.registeredStaff!.filter((rs) => rs.id !== staffId);
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
registeredStaff: updated,
|
registeredStaff: updated,
|
||||||
status:
|
|
||||||
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createShift = useCallback((shift: Omit<ShiftSlot, "id">) => {
|
const createShift = useCallback(async (shift: Omit<ShiftEntity, "id">) => {
|
||||||
const newShift: ShiftSlot = {
|
const { data } = await mutateCreateShift({
|
||||||
...shift,
|
variables: {
|
||||||
id: `shift_${Date.now()}`,
|
shiftInput: shift,
|
||||||
};
|
},
|
||||||
setShifts((prev) => [...prev, newShift]);
|
});
|
||||||
|
|
||||||
|
if (data) setShifts((prev) => [...prev, data.createShift]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateShift = useCallback((shift: ShiftSlot) => {
|
const updateShift = useCallback((shift: ShiftEntity) => {
|
||||||
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
setShifts((prev) => prev.map((s) => (s.id === shift.id ? shift : s)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
+24
-16
@@ -2,7 +2,7 @@
|
|||||||
export type UserRole = "manager" | "staff" | "customer";
|
export type UserRole = "manager" | "staff" | "customer";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
@@ -88,28 +88,26 @@ export type ShiftStatus =
|
|||||||
| "approved_leave"
|
| "approved_leave"
|
||||||
| "absent";
|
| "absent";
|
||||||
|
|
||||||
export interface RegisteredStaff {
|
export interface ShiftRegistrationEntity {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
staffId: string;
|
||||||
|
shift: ShiftEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftSlot {
|
export interface ShiftEntity {
|
||||||
id: string;
|
id: string;
|
||||||
date: string; // ISO date string "YYYY-MM-DD"
|
date: Date;
|
||||||
startTime: string; // "HH:mm"
|
startTime: string;
|
||||||
endTime: string; // "HH:mm"
|
endTime: string;
|
||||||
durationHours: number;
|
wage: number;
|
||||||
wage: number; // VND per shift
|
|
||||||
department: string;
|
|
||||||
maxStaff: number;
|
maxStaff: number;
|
||||||
registeredStaff: RegisteredStaff[];
|
registeredStaff?: ShiftRegistrationEntity[];
|
||||||
status: ShiftStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Department {
|
export interface Department {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string; // FontAwesome class
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuItemEntity {
|
export interface MenuItemEntity {
|
||||||
@@ -122,8 +120,6 @@ export interface MenuItemEntity {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftEntity {}
|
|
||||||
|
|
||||||
export interface EateryEntity {
|
export interface EateryEntity {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -172,3 +168,15 @@ export interface createCartMutation {
|
|||||||
export interface addMenuItemMutation {
|
export interface addMenuItemMutation {
|
||||||
addItem: CartEntity;
|
addItem: CartEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface allRegistrationsQuery {
|
||||||
|
allRegistrations: ShiftRegistrationEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface allShiftsQuery {
|
||||||
|
allShifts: ShiftEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface createShiftMutation {
|
||||||
|
createShift: ShiftEntity;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user