feat: Implement mobile dropdown navigation and enhance weekly schedule component

This commit is contained in:
Thanh Quy - wolf
2026-04-10 22:37:52 +07:00
parent 95d9435694
commit 4ddce518ac
7 changed files with 243 additions and 54 deletions
+30
View File
@@ -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"
]
}
}
}
-29
View File
@@ -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)
+64 -22
View File
@@ -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 */}
+9 -1
View File
@@ -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">