feat: Implement mobile dropdown navigation and enhance weekly schedule component
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github.com/microsoft/playwright-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
},
|
||||
"github.com/21st-dev/magic-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@smithery/cli@latest",
|
||||
"run",
|
||||
"@21st-dev/magic-mcp"
|
||||
]
|
||||
},
|
||||
"github.com/GLips/Figma-Context-MCP": {
|
||||
"command": "cmd",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"figma-developer-mcp",
|
||||
"--figma-api-key=YOUR_FIGMA_API_KEY",
|
||||
"--stdio"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
▲ Next.js 16.1.7 (Turbopack)
|
||||
- Local: http://localhost:3000
|
||||
- Network: http://192.168.1.3:3000
|
||||
|
||||
✓ Starting...
|
||||
✓ Ready in 1344ms
|
||||
GET /feed 200 in 707ms (compile: 589ms, render: 118ms)
|
||||
GET / 200 in 261ms (compile: 40ms, render: 221ms)
|
||||
GET /imgs/products/placeholder.jpg 404 in 91ms (compile: 59ms, render: 32ms)
|
||||
⨯ The requested resource isn't a valid image for /imgs/products/placeholder.jpg received null
|
||||
GET /login 200 in 41ms (compile: 31ms, render: 10ms)
|
||||
GET / 200 in 9ms (compile: 1591µs, render: 7ms)
|
||||
GET /imgs/products/placeholder.jpg 404 in 34ms (compile: 4ms, render: 30ms)
|
||||
⨯ The requested resource isn't a valid image for /imgs/products/placeholder.jpg received null
|
||||
GET /manager 200 in 43ms (compile: 33ms, render: 10ms)
|
||||
GET / 200 in 13ms (compile: 1706µs, render: 11ms)
|
||||
GET /imgs/products/placeholder.jpg 404 in 35ms (compile: 6ms, render: 29ms)
|
||||
⨯ The requested resource isn't a valid image for /imgs/products/placeholder.jpg received null
|
||||
GET /login 200 in 11ms (compile: 1707µs, render: 9ms)
|
||||
GET / 200 in 11ms (compile: 2ms, render: 8ms)
|
||||
GET /imgs/products/placeholder.jpg 404 in 38ms (compile: 6ms, render: 31ms)
|
||||
⨯ The requested resource isn't a valid image for /imgs/products/placeholder.jpg received null
|
||||
GET /staff 404 in 10ms (compile: 3ms, render: 7ms)
|
||||
GET /staff 404 in 20ms (compile: 4ms, render: 16ms)
|
||||
GET /staff 404 in 21ms (compile: 4ms, render: 17ms)
|
||||
GET / 200 in 106ms (compile: 25ms, render: 81ms)
|
||||
GET /imgs/products/placeholder.jpg 404 in 22ms (compile: 5ms, render: 17ms)
|
||||
⨯ The requested resource isn't a valid image for /imgs/products/placeholder.jpg received null
|
||||
GET /staff/schedule 200 in 804ms (compile: 784ms, render: 19ms)
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useManager } from "@/lib/manager-context";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ManagerPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { activeTab, setActiveTab, products, combos, categories } =
|
||||
useManager();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -152,29 +154,69 @@ export default function ManagerPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex items-center gap-1 lg:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-xs font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-background text-(--color-text-secondary) hover:text-(--color-primary)"
|
||||
}`}
|
||||
>
|
||||
<i className={tab.icon}></i>
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
|
||||
{/* Mobile dropdown navigation */}
|
||||
<div className="relative lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen((prev) => !prev)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border-light) bg-white px-3 py-2 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
<span className="hidden sm:inline">Tài chính</span>
|
||||
</Link>
|
||||
<i className="fa-solid fa-bars"></i>
|
||||
Tác vụ
|
||||
<i
|
||||
className={`fa-solid fa-chevron-${mobileNavOpen ? "up" : "down"} text-[10px]`}
|
||||
></i>
|
||||
</button>
|
||||
|
||||
{mobileNavOpen && (
|
||||
<div className="absolute right-0 top-11 z-50 w-56 rounded-xl border border-(--color-border-light) bg-white p-2 shadow-lg">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setMobileNavOpen(false);
|
||||
}}
|
||||
className={`mb-1 flex w-full cursor-pointer items-center gap-2 rounded-lg border-none px-3 py-2 text-left text-xs font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? "bg-(--color-primary) text-white"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<i className={`${tab.icon} w-4 text-center`}></i>
|
||||
<span className="flex-1">{tab.label}</span>
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] ${
|
||||
activeTab === tab.id
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-(--color-border-light) text-(--color-text-muted)"
|
||||
}`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<Link
|
||||
href="/manager/analytics"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
className="mb-1 flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line w-4 text-center"></i>
|
||||
<span className="flex-1">Tài chính</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/staff/schedule"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
|
||||
>
|
||||
<i className="fa-solid fa-calendar-days w-4 text-center"></i>
|
||||
<span className="flex-1">Ca làm</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop actions */}
|
||||
|
||||
@@ -326,7 +326,15 @@ export default function StaffSchedulePage() {
|
||||
|
||||
{/* Mobile view */}
|
||||
<div className="md:hidden">
|
||||
<MobileShiftView onShiftClick={handleShiftClick} />
|
||||
{view === "week" ? (
|
||||
<WeeklySchedule
|
||||
onShiftClick={handleShiftClick}
|
||||
onCreateShift={isManager ? handleCreateShift : undefined}
|
||||
mobileCalendarHeader
|
||||
/>
|
||||
) : (
|
||||
<MobileShiftView onShiftClick={handleShiftClick} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for manager */}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ShiftSlot } from "@/lib/types";
|
||||
export interface WeeklyScheduleProps {
|
||||
onShiftClick: (shift: ShiftSlot) => void;
|
||||
onCreateShift?: (date: string) => void;
|
||||
mobileCalendarHeader?: boolean;
|
||||
}
|
||||
|
||||
export interface MonthlyCalendarProps {
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
import ShiftCard from "@/components/molecules/cards/ShiftCard";
|
||||
import { DEPARTMENTS } from "@/lib/constants";
|
||||
import { useShift } from "@/lib/shift-context";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
||||
|
||||
const MONTH_NAMES_EN = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function formatDateShort(d: Date): string {
|
||||
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`;
|
||||
@@ -28,10 +35,140 @@ function isToday(d: Date): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export default function WeeklySchedule({ onShiftClick, onCreateShift }: WeeklyScheduleProps) {
|
||||
const { getWeekDates, getShiftsForDate } = useShift();
|
||||
export default function WeeklySchedule({
|
||||
onShiftClick,
|
||||
onCreateShift,
|
||||
mobileCalendarHeader = false,
|
||||
}: WeeklyScheduleProps) {
|
||||
const { currentDate, getWeekDates, getShiftsForDate, goToNextWeek, goToPrevWeek } = useShift();
|
||||
const weekDates = getWeekDates();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(weekDates[0] ?? currentDate));
|
||||
|
||||
const statusDotsByDate = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
weekDates.forEach((date) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const dayShifts = getShiftsForDate(dateStr);
|
||||
const dots: string[] = [];
|
||||
if (dayShifts.some((s) => s.status === "available")) dots.push("bg-sky-300");
|
||||
if (dayShifts.some((s) => s.status === "registered")) dots.push("bg-blue-600");
|
||||
if (dayShifts.some((s) => s.status === "approved_leave")) dots.push("bg-purple-400");
|
||||
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-rose-400");
|
||||
map[dateStr] = dots.slice(0, 3);
|
||||
});
|
||||
return map;
|
||||
}, [weekDates, getShiftsForDate]);
|
||||
|
||||
const selectedDateObj = useMemo(() => {
|
||||
const inWeek = weekDates.find((d) => formatDateISO(d) === selectedDate);
|
||||
return inWeek ?? weekDates[0] ?? currentDate;
|
||||
}, [selectedDate, weekDates, currentDate]);
|
||||
|
||||
const selectedDateStr = formatDateISO(selectedDateObj);
|
||||
|
||||
const renderMobileDayView = mobileCalendarHeader && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
title="Tuần trước"
|
||||
type="button"
|
||||
onClick={goToPrevWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<h3 className="text-base font-bold text-(--color-primary-dark)">
|
||||
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
title="Tuần sau"
|
||||
type="button"
|
||||
onClick={goToNextWeek}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{weekDates.map((date, i) => {
|
||||
const dateStr = formatDateISO(date);
|
||||
const active = dateStr === selectedDateStr;
|
||||
const dots = statusDotsByDate[dateStr] ?? [];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(dateStr)}
|
||||
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
|
||||
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}>
|
||||
{DAY_LABELS_EN[i]}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
active || isToday(date)
|
||||
? "bg-indigo-500 text-white"
|
||||
: i >= 5
|
||||
? "text-pink-500"
|
||||
: "text-sky-500"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
<div className="flex min-h-2 items-center gap-0.5">
|
||||
{dots.map((dot, idx) => (
|
||||
<span key={idx} className={`h-1.5 w-1.5 rounded-full ${dot}`} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{DEPARTMENTS.map((dept) => {
|
||||
const deptShifts = getShiftsForDate(selectedDateStr).filter((s) => s.department === dept.id);
|
||||
if (deptShifts.length === 0 && !onCreateShift) return null;
|
||||
return (
|
||||
<div key={dept.id} className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i>
|
||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||
{dept.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{deptShifts.map((shift) => (
|
||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
||||
))}
|
||||
{onCreateShift && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreateShift(selectedDateStr)}
|
||||
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
||||
>
|
||||
<i className="fa-solid fa-plus mr-1"></i>
|
||||
Thêm ca
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (mobileCalendarHeader) {
|
||||
return renderMobileDayView ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-225 border-collapse">
|
||||
|
||||
Reference in New Issue
Block a user