Enhance formatting and readability of the Register Shift feature documentation by adjusting line breaks and indentation for better clarity. Update descriptions for roles, functionalities, and UI elements to ensure consistency and improve user understanding.
This commit is contained in:
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -22,15 +22,10 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Calculate and Export Target IP
|
- uses: gerlero/apt-install@v1
|
||||||
run: |
|
with:
|
||||||
# 1. Lấy IP hiện tại và tính toán IP đích (đổi octet cuối thành .1)
|
packages: docker.io zip
|
||||||
RUNNER_IP=$(hostname -I | awk '{print $1}')
|
|
||||||
CALCULATED_IP=$(echo $RUNNER_IP | cut -d'.' -f1-3).1
|
|
||||||
|
|
||||||
# 2. Đưa vào biến môi trường toàn cục của Job
|
|
||||||
echo "TARGET_IP=${CALCULATED_IP}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Setup SSH Key
|
- name: Setup SSH Key
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
@@ -38,33 +33,17 @@ jobs:
|
|||||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
|
||||||
ssh-keyscan -p 222 -t ed25519 $TARGET_IP >> ~/.ssh/known_hosts
|
ssh-keyscan -t ed25519 vps.demonkernel.io.vn >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: latest
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v3
|
- uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
run_install: false
|
run_install: false
|
||||||
|
cache: true
|
||||||
- name: Get pnpm store directory
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: actions/cache@v3
|
|
||||||
name: Setup pnpm cache
|
|
||||||
with:
|
|
||||||
path: ${{ env.STORE_PATH }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Update dependencies
|
- name: Update dependencies
|
||||||
run: pnpm update
|
run: pnpm update
|
||||||
@@ -80,7 +59,7 @@ jobs:
|
|||||||
GITEA_USER: ${{ github.actor }}
|
GITEA_USER: ${{ github.actor }}
|
||||||
GITEA_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
run: pnpm release
|
run: pnpm release
|
||||||
|
|
||||||
- name: Set Version Output
|
- name: Set Version Output
|
||||||
id: set_output
|
id: set_output
|
||||||
run: |
|
run: |
|
||||||
@@ -92,7 +71,7 @@ jobs:
|
|||||||
if: gitea.ref == 'refs/heads/main'
|
if: gitea.ref == 'refs/heads/main'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.demonkernel.io.vn
|
registry: vps.demonkernel.io.vn
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.ACCESS_TOKEN }}
|
password: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
||||||
@@ -102,10 +81,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# Lowercase Repository
|
# Lowercase Repository
|
||||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
RAW_VERSION="${{ steps.set_output.outputs.version }}"
|
RAW_VERSION="${{ steps.set_output.outputs.version }}"
|
||||||
VERSION_LOWER=$(echo "$RAW_VERSION" | tr '[:upper:]' '[:lower:]')
|
VERSION_LOWER=$(echo "$RAW_VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
echo "repo=$REPO_LOWER" >> $GITHUB_OUTPUT
|
echo "repo=$REPO_LOWER" >> $GITHUB_OUTPUT
|
||||||
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
echo "version=$VERSION_LOWER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -116,19 +95,18 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
|
||||||
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
- name: Commit & Push Changes
|
- name: Commit & Push Changes
|
||||||
if: gitea.ref == 'refs/heads/main'
|
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
if [ -n "$(git status --porcelain)" ]&& [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
git config --global user.name "gitea-actions"
|
git config --global user.name "gitea-actions"
|
||||||
git config --global user.email "actions-user@noreply.git.demonkernel.io.vn"
|
git config --global user.email "actions-user@noreply.git.demonkernel.io.vn"
|
||||||
|
|
||||||
REPO_NAME="${{ gitea.repository }}"
|
REPO_NAME="${{ gitea.repository }}"
|
||||||
|
|
||||||
git remote set-url origin ssh://git@$TARGET_IP:222/${REPO_NAME}.git
|
git remote set-url origin ssh://git@vps.demonkernel.io.vn/${REPO_NAME}.git
|
||||||
|
|
||||||
git rm -r --cached .
|
git rm -r --cached .
|
||||||
git add .
|
git add .
|
||||||
@@ -136,4 +114,3 @@ jobs:
|
|||||||
|
|
||||||
git push
|
git push
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4",
|
"Tháng 1",
|
||||||
"Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8",
|
"Tháng 2",
|
||||||
"Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12",
|
"Tháng 3",
|
||||||
|
"Tháng 4",
|
||||||
|
"Tháng 5",
|
||||||
|
"Tháng 6",
|
||||||
|
"Tháng 7",
|
||||||
|
"Tháng 8",
|
||||||
|
"Tháng 9",
|
||||||
|
"Tháng 10",
|
||||||
|
"Tháng 11",
|
||||||
|
"Tháng 12",
|
||||||
];
|
];
|
||||||
|
|
||||||
function getMonday(d: Date): Date {
|
function getMonday(d: Date): Date {
|
||||||
@@ -172,7 +181,9 @@ export default function StaffSchedulePage() {
|
|||||||
<div className="border-t border-(--color-border-light) p-3">
|
<div className="border-t border-(--color-border-light) p-3">
|
||||||
<div className="flex items-center gap-3 rounded-xl p-3">
|
<div className="flex items-center gap-3 rounded-xl p-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
|
||||||
<i className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}></i>
|
<i
|
||||||
|
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-foreground truncate text-sm font-semibold">
|
<p className="text-foreground truncate text-sm font-semibold">
|
||||||
@@ -212,7 +223,9 @@ export default function StaffSchedulePage() {
|
|||||||
Đăng ký ca làm
|
Đăng ký ca làm
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
{view === "week" ? weekLabel : `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
|
{view === "week"
|
||||||
|
? weekLabel
|
||||||
|
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import type { ShiftSlot } 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 }> = {
|
const STATUS_STYLES: Record<
|
||||||
|
ShiftSlot["status"],
|
||||||
|
{ bg: string; text: string; label: string }
|
||||||
|
> = {
|
||||||
available: {
|
available: {
|
||||||
bg: "bg-blue-50 border-blue-200",
|
bg: "bg-blue-50 border-blue-200",
|
||||||
text: "text-blue-700",
|
text: "text-blue-700",
|
||||||
@@ -34,7 +37,11 @@ function formatWage(wage: number): string {
|
|||||||
return wage.toLocaleString("vi-VN");
|
return wage.toLocaleString("vi-VN");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ShiftCard({ shift, compact = false, onClick }: ShiftCardProps) {
|
export default function ShiftCard({
|
||||||
|
shift,
|
||||||
|
compact = false,
|
||||||
|
onClick,
|
||||||
|
}: ShiftCardProps) {
|
||||||
const style = STATUS_STYLES[shift.status];
|
const style = STATUS_STYLES[shift.status];
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
@@ -86,7 +93,7 @@ export default function ShiftCard({ shift, compact = false, onClick }: ShiftCard
|
|||||||
|
|
||||||
{shift.registeredStaff.length > 0 && (
|
{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 uppercase tracking-wide 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})
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -26,14 +26,27 @@ function isToday(d: Date): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4",
|
"Tháng 1",
|
||||||
"Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8",
|
"Tháng 2",
|
||||||
"Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12",
|
"Tháng 3",
|
||||||
|
"Tháng 4",
|
||||||
|
"Tháng 5",
|
||||||
|
"Tháng 6",
|
||||||
|
"Tháng 7",
|
||||||
|
"Tháng 8",
|
||||||
|
"Tháng 9",
|
||||||
|
"Tháng 10",
|
||||||
|
"Tháng 11",
|
||||||
|
"Tháng 12",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps) {
|
export default function MobileShiftView({
|
||||||
|
onShiftClick,
|
||||||
|
}: MobileShiftViewProps) {
|
||||||
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(new Date(2026, 3, 10)));
|
const [selectedDate, setSelectedDate] = useState<string>(
|
||||||
|
formatDateISO(new Date(2026, 3, 10)),
|
||||||
|
);
|
||||||
|
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
@@ -57,9 +70,12 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
const dateStr = formatDateISO(date);
|
const dateStr = formatDateISO(date);
|
||||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
||||||
const dots: string[] = [];
|
const dots: string[] = [];
|
||||||
if (dayShifts.some((s) => s.status === "available")) dots.push("bg-amber-400");
|
if (dayShifts.some((s) => s.status === "available"))
|
||||||
if (dayShifts.some((s) => s.status === "registered")) dots.push("bg-green-500");
|
dots.push("bg-amber-400");
|
||||||
if (dayShifts.some((s) => s.status === "approved_leave")) dots.push("bg-purple-400");
|
if (dayShifts.some((s) => s.status === "registered"))
|
||||||
|
dots.push("bg-green-500");
|
||||||
|
if (dayShifts.some((s) => s.status === "approved_leave"))
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
@@ -85,7 +101,7 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
>
|
>
|
||||||
<i className="fa-solid fa-chevron-left text-xs"></i>
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
<h3 className="text-sm font-bold text-foreground">
|
<h3 className="text-foreground text-sm font-bold">
|
||||||
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
@@ -101,7 +117,10 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
<div className="mb-1 grid grid-cols-7">
|
<div className="mb-1 grid grid-cols-7">
|
||||||
{DAY_HEADERS.map((day) => (
|
{DAY_HEADERS.map((day) => (
|
||||||
<div key={day} className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
<div
|
||||||
|
key={day}
|
||||||
|
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"
|
||||||
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -133,7 +152,7 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
today
|
today
|
||||||
? "bg-(--color-primary) font-bold text-white"
|
? "bg-(--color-primary) font-bold text-white"
|
||||||
: selected
|
: selected
|
||||||
? "font-bold text-foreground"
|
? "text-foreground font-bold"
|
||||||
: "text-foreground"
|
: "text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -153,7 +172,9 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
<div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2">
|
<div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
<span className="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||||
<span className="text-[10px] text-(--color-text-muted)">Còn trống</span>
|
<span className="text-[10px] text-(--color-text-muted)">
|
||||||
|
Còn trống
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||||
@@ -161,7 +182,9 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
|
||||||
<span className="text-[10px] text-(--color-text-muted)">Nghỉ phép</span>
|
<span className="text-[10px] text-(--color-text-muted)">
|
||||||
|
Nghỉ phép
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
<span className="h-2 w-2 rounded-full bg-red-400"></span>
|
||||||
@@ -172,8 +195,9 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
|
|
||||||
{/* Selected day shifts */}
|
{/* Selected day shifts */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-bold text-foreground">
|
<h3 className="text-foreground mb-3 text-sm font-bold">
|
||||||
{dayOfWeek}, {selectedDateObj.getDate()}/{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
{dayOfWeek}, {selectedDateObj.getDate()}/
|
||||||
|
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
|
||||||
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
|
||||||
({selectedShifts.length} ca)
|
({selectedShifts.length} ca)
|
||||||
</span>
|
</span>
|
||||||
@@ -182,24 +206,34 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
|
|||||||
{selectedShifts.length === 0 ? (
|
{selectedShifts.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
|
||||||
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
|
||||||
<p className="text-sm text-(--color-text-muted)">Không có ca làm trong ngày này</p>
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
|
Không có ca làm trong ngày này
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{DEPARTMENTS.map((dept) => {
|
{DEPARTMENTS.map((dept) => {
|
||||||
const deptShifts = selectedShifts.filter((s) => s.department === dept.id);
|
const deptShifts = selectedShifts.filter(
|
||||||
|
(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}>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i>
|
<i
|
||||||
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{deptShifts.map((shift) => (
|
{deptShifts.map((shift) => (
|
||||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
<ShiftCard
|
||||||
|
key={shift.id}
|
||||||
|
shift={shift}
|
||||||
|
onClick={onShiftClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ function isToday(d: Date): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyCalendarProps) {
|
export default function MonthlyCalendar({
|
||||||
|
onShiftClick,
|
||||||
|
onDateSelect,
|
||||||
|
}: MonthlyCalendarProps) {
|
||||||
const { currentDate, shifts } = useShift();
|
const { currentDate, shifts } = useShift();
|
||||||
|
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
@@ -60,7 +63,9 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
|
|||||||
const dateStr = formatDateISO(date);
|
const dateStr = formatDateISO(date);
|
||||||
const dayShifts = shifts.filter((s) => s.date === dateStr);
|
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((s) => s.status === "registered").length;
|
const registered = dayShifts.filter(
|
||||||
|
(s) => s.status === "registered",
|
||||||
|
).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, available, registered, leave, absent };
|
return { total: dayShifts.length, available, registered, leave, absent };
|
||||||
@@ -87,7 +92,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`empty-${i}`}
|
key={`empty-${i}`}
|
||||||
className="min-h-25 border-b border-r border-(--color-border-light) bg-gray-50/30"
|
className="min-h-25 border-r border-b border-(--color-border-light) bg-gray-50/30"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,7 +105,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
|
|||||||
type="button"
|
type="button"
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onDateSelect?.(formatDateISO(date))}
|
onClick={() => onDateSelect?.(formatDateISO(date))}
|
||||||
className={`min-h-25 cursor-pointer border-b border-r 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" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -108,7 +113,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
|
|||||||
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
|
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
|
||||||
today
|
today
|
||||||
? "bg-(--color-primary) font-bold text-white"
|
? "bg-(--color-primary) font-bold text-white"
|
||||||
: "font-medium text-foreground"
|
: "text-foreground font-medium"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: ShiftCreateModalProps) {
|
export default function ShiftCreateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
defaultDate,
|
||||||
|
}: ShiftCreateModalProps) {
|
||||||
const { createShift } = useShift();
|
const { createShift } = useShift();
|
||||||
|
|
||||||
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
const [date, setDate] = useState(defaultDate ?? "2026-04-10");
|
||||||
@@ -72,7 +76,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
{/* 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>
|
<div>
|
||||||
<h2 className="text-base font-bold text-foreground">
|
<h2 className="text-foreground text-base font-bold">
|
||||||
Tạo ca làm mới
|
Tạo ca làm mới
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-(--color-text-muted)">
|
<p className="text-xs text-(--color-text-muted)">
|
||||||
@@ -101,7 +105,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition 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>
|
||||||
|
|
||||||
@@ -116,7 +120,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
type="time"
|
type="time"
|
||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition 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>
|
||||||
<div>
|
<div>
|
||||||
@@ -128,7 +132,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
type="time"
|
type="time"
|
||||||
value={endTime}
|
value={endTime}
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +146,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
title="Department"
|
title="Department"
|
||||||
value={department}
|
value={department}
|
||||||
onChange={(e) => setDepartment(e.target.value)}
|
onChange={(e) => setDepartment(e.target.value)}
|
||||||
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition 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)"
|
||||||
>
|
>
|
||||||
{DEPARTMENTS.map((dept) => (
|
{DEPARTMENTS.map((dept) => (
|
||||||
<option key={dept.id} value={dept.id}>
|
<option key={dept.id} value={dept.id}>
|
||||||
@@ -165,7 +169,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
max={10}
|
max={10}
|
||||||
value={maxStaff}
|
value={maxStaff}
|
||||||
onChange={(e) => setMaxStaff(Number(e.target.value))}
|
onChange={(e) => setMaxStaff(Number(e.target.value))}
|
||||||
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition 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>
|
||||||
<div>
|
<div>
|
||||||
@@ -179,7 +183,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
|
|||||||
step={10000}
|
step={10000}
|
||||||
value={wage}
|
value={wage}
|
||||||
onChange={(e) => setWage(Number(e.target.value))}
|
onChange={(e) => setWage(Number(e.target.value))}
|
||||||
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
|
import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetailModalProps) {
|
export default function ShiftDetailModal({
|
||||||
|
shift,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: ShiftDetailModalProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { registerShift, unregisterShift, deleteShift } = useShift();
|
const { registerShift, unregisterShift, deleteShift } = useShift();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -17,7 +21,9 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
|
|
||||||
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 ? shift.registeredStaff.some((s) => s.id === user.id) : false;
|
const isRegistered = user
|
||||||
|
? shift.registeredStaff.some((s) => s.id === user.id)
|
||||||
|
: false;
|
||||||
const isFull = shift.registeredStaff.length >= shift.maxStaff;
|
const isFull = shift.registeredStaff.length >= shift.maxStaff;
|
||||||
|
|
||||||
const handleRegister = () => {
|
const handleRegister = () => {
|
||||||
@@ -68,10 +74,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
className="absolute inset-0 bg-black/40"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
|
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
|
||||||
@@ -80,7 +83,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
<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-base font-bold text-foreground">
|
<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>
|
||||||
@@ -100,7 +103,9 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
<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 className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}>
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
|
||||||
|
>
|
||||||
{statusLabel[shift.status]}
|
{statusLabel[shift.status]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,30 +113,41 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
{/* Shift info grid */}
|
{/* Shift info grid */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Ngày</p>
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
<p className="mt-1 text-sm font-bold text-foreground">
|
Ngày
|
||||||
{new Date(shift.date + "T00:00:00").toLocaleDateString("vi-VN", {
|
</p>
|
||||||
weekday: "long",
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
day: "2-digit",
|
{new Date(shift.date + "T00:00:00").toLocaleDateString(
|
||||||
month: "2-digit",
|
"vi-VN",
|
||||||
year: "numeric",
|
{
|
||||||
})}
|
weekday: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Giờ làm</p>
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
<p className="mt-1 text-sm font-bold text-foreground">
|
Giờ làm
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{shift.startTime} – {shift.endTime}
|
{shift.startTime} – {shift.endTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Thời lượng</p>
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
<p className="mt-1 text-sm font-bold text-foreground">
|
Thời lượng
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground mt-1 text-sm font-bold">
|
||||||
{shift.durationHours} giờ
|
{shift.durationHours} giờ
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-gray-50 p-3">
|
<div className="rounded-xl bg-gray-50 p-3">
|
||||||
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Lương ca</p>
|
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
|
||||||
|
Lương ca
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-sm font-bold text-(--color-primary)">
|
<p className="mt-1 text-sm font-bold text-(--color-primary)">
|
||||||
{shift.wage.toLocaleString("vi-VN")} VND
|
{shift.wage.toLocaleString("vi-VN")} VND
|
||||||
</p>
|
</p>
|
||||||
@@ -141,10 +157,13 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
{/* 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}/{shift.maxStaff})
|
Nhân viên đã đăng ký ({shift.registeredStaff.length}/
|
||||||
|
{shift.maxStaff})
|
||||||
</p>
|
</p>
|
||||||
{shift.registeredStaff.length === 0 ? (
|
{shift.registeredStaff.length === 0 ? (
|
||||||
<p className="text-xs italic text-(--color-text-muted)">Chưa có ai đăng ký</p>
|
<p className="text-xs text-(--color-text-muted) italic">
|
||||||
|
Chưa có ai đăng ký
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{shift.registeredStaff.map((staff) => (
|
{shift.registeredStaff.map((staff) => (
|
||||||
@@ -156,7 +175,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-(--color-primary)/10">
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-(--color-primary)/10">
|
||||||
<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-sm font-medium text-foreground">
|
<span className="text-foreground text-sm font-medium">
|
||||||
{staff.name}
|
{staff.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,16 +212,19 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
|
|||||||
|
|
||||||
{/* 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 && !isFull && shift.status !== "approved_leave" && shift.status !== "absent" && (
|
{!isRegistered &&
|
||||||
<button
|
!isFull &&
|
||||||
type="button"
|
shift.status !== "approved_leave" &&
|
||||||
onClick={handleRegister}
|
shift.status !== "absent" && (
|
||||||
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
<button
|
||||||
>
|
type="button"
|
||||||
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
onClick={handleRegister}
|
||||||
Đăng ký ca
|
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||||
</button>
|
>
|
||||||
)}
|
<i className="fa-solid fa-calendar-plus mr-2"></i>
|
||||||
|
Đăng ký ca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isRegistered && (
|
{isRegistered && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ import { useMemo, useState } from "react";
|
|||||||
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
||||||
|
|
||||||
const MONTH_NAMES_EN = [
|
const MONTH_NAMES_EN = [
|
||||||
"January", "February", "March", "April", "May", "June",
|
"January",
|
||||||
"July", "August", "September", "October", "November", "December",
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
];
|
];
|
||||||
|
|
||||||
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"];
|
||||||
@@ -40,10 +50,18 @@ export default function WeeklySchedule({
|
|||||||
onCreateShift,
|
onCreateShift,
|
||||||
mobileCalendarHeader = false,
|
mobileCalendarHeader = false,
|
||||||
}: WeeklyScheduleProps) {
|
}: WeeklyScheduleProps) {
|
||||||
const { currentDate, getWeekDates, getShiftsForDate, goToNextWeek, goToPrevWeek } = useShift();
|
const {
|
||||||
|
currentDate,
|
||||||
|
getWeekDates,
|
||||||
|
getShiftsForDate,
|
||||||
|
goToNextWeek,
|
||||||
|
goToPrevWeek,
|
||||||
|
} = useShift();
|
||||||
const weekDates = getWeekDates();
|
const weekDates = getWeekDates();
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(weekDates[0] ?? currentDate));
|
const [selectedDate, setSelectedDate] = useState<string>(
|
||||||
|
formatDateISO(weekDates[0] ?? currentDate),
|
||||||
|
);
|
||||||
|
|
||||||
const statusDotsByDate = useMemo(() => {
|
const statusDotsByDate = useMemo(() => {
|
||||||
const map: Record<string, string[]> = {};
|
const map: Record<string, string[]> = {};
|
||||||
@@ -51,10 +69,14 @@ export default function WeeklySchedule({
|
|||||||
const dateStr = formatDateISO(date);
|
const dateStr = formatDateISO(date);
|
||||||
const dayShifts = getShiftsForDate(dateStr);
|
const dayShifts = getShiftsForDate(dateStr);
|
||||||
const dots: string[] = [];
|
const dots: string[] = [];
|
||||||
if (dayShifts.some((s) => s.status === "available")) dots.push("bg-sky-300");
|
if (dayShifts.some((s) => s.status === "available"))
|
||||||
if (dayShifts.some((s) => s.status === "registered")) dots.push("bg-blue-600");
|
dots.push("bg-sky-300");
|
||||||
if (dayShifts.some((s) => s.status === "approved_leave")) dots.push("bg-purple-400");
|
if (dayShifts.some((s) => s.status === "registered"))
|
||||||
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-rose-400");
|
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);
|
map[dateStr] = dots.slice(0, 3);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
@@ -106,7 +128,9 @@ export default function WeeklySchedule({
|
|||||||
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}>
|
<span
|
||||||
|
className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
|
||||||
|
>
|
||||||
{DAY_LABELS_EN[i]}
|
{DAY_LABELS_EN[i]}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -122,7 +146,10 @@ export default function WeeklySchedule({
|
|||||||
</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) => (
|
||||||
<span key={idx} className={`h-1.5 w-1.5 rounded-full ${dot}`} />
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${dot}`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -133,19 +160,30 @@ 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((s) => s.department === dept.id);
|
const deptShifts = getShiftsForDate(selectedDateStr).filter(
|
||||||
|
(s) => s.department === dept.id,
|
||||||
|
);
|
||||||
if (deptShifts.length === 0 && !onCreateShift) return null;
|
if (deptShifts.length === 0 && !onCreateShift) return null;
|
||||||
return (
|
return (
|
||||||
<div key={dept.id} className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
<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">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i>
|
<i
|
||||||
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{deptShifts.map((shift) => (
|
{deptShifts.map((shift) => (
|
||||||
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} />
|
<ShiftCard
|
||||||
|
key={shift.id}
|
||||||
|
shift={shift}
|
||||||
|
onClick={onShiftClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{onCreateShift && (
|
{onCreateShift && (
|
||||||
<button
|
<button
|
||||||
@@ -174,13 +212,13 @@ export default function WeeklySchedule({
|
|||||||
<table className="w-full min-w-225 border-collapse">
|
<table className="w-full min-w-225 border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-28 border-b border-r border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
||||||
Bộ phận
|
Bộ phận
|
||||||
</th>
|
</th>
|
||||||
{weekDates.map((date, i) => (
|
{weekDates.map((date, i) => (
|
||||||
<th
|
<th
|
||||||
key={i}
|
key={i}
|
||||||
className={`border-b border-r 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(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)"
|
||||||
@@ -197,9 +235,11 @@ export default function WeeklySchedule({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{DEPARTMENTS.map((dept) => (
|
{DEPARTMENTS.map((dept) => (
|
||||||
<tr key={dept.id}>
|
<tr key={dept.id}>
|
||||||
<td className="border-b border-r border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
|
<td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i>
|
<i
|
||||||
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
||||||
|
></i>
|
||||||
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -213,7 +253,7 @@ export default function WeeklySchedule({
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={i}
|
key={i}
|
||||||
className={`border-b border-r 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(date) ? "bg-(--color-primary)/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: frontend-container
|
- name: frontend-container
|
||||||
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.0
|
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.2
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+34
-6
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode, createContext, useCallback, useContext, useState } from "react";
|
import {
|
||||||
|
ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import { MOCK_SHIFT_SLOTS } from "./constants";
|
import { MOCK_SHIFT_SLOTS } from "./constants";
|
||||||
import type { ShiftSlot, ShiftStatus } from "./types";
|
import type { ShiftSlot, ShiftStatus } from "./types";
|
||||||
@@ -24,7 +30,11 @@ interface ShiftContextType {
|
|||||||
goToToday: () => void;
|
goToToday: () => void;
|
||||||
|
|
||||||
// Shift actions
|
// Shift actions
|
||||||
registerShift: (shiftId: string, staffId: number, staffName: string) => { success: boolean; error?: string };
|
registerShift: (
|
||||||
|
shiftId: string,
|
||||||
|
staffId: number,
|
||||||
|
staffName: string,
|
||||||
|
) => { success: boolean; error?: string };
|
||||||
unregisterShift: (shiftId: string, staffId: number) => void;
|
unregisterShift: (shiftId: string, staffId: number) => void;
|
||||||
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
createShift: (shift: Omit<ShiftSlot, "id">) => void;
|
||||||
updateShift: (shift: ShiftSlot) => void;
|
updateShift: (shift: ShiftSlot) => void;
|
||||||
@@ -34,7 +44,13 @@ interface ShiftContextType {
|
|||||||
getShiftsForDate: (date: string) => ShiftSlot[];
|
getShiftsForDate: (date: string) => ShiftSlot[];
|
||||||
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
|
||||||
getWeekDates: () => Date[];
|
getWeekDates: () => Date[];
|
||||||
hasConflict: (date: string, startTime: string, endTime: string, staffId: number, excludeShiftId?: string) => boolean;
|
hasConflict: (
|
||||||
|
date: string,
|
||||||
|
startTime: string,
|
||||||
|
endTime: string,
|
||||||
|
staffId: number,
|
||||||
|
excludeShiftId?: string,
|
||||||
|
) => boolean;
|
||||||
getWeeklyBudget: () => number;
|
getWeeklyBudget: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +214,15 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
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." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasConflict(shift.date, shift.startTime, shift.endTime, staffId, shiftId)) {
|
if (
|
||||||
|
hasConflict(
|
||||||
|
shift.date,
|
||||||
|
shift.startTime,
|
||||||
|
shift.endTime,
|
||||||
|
staffId,
|
||||||
|
shiftId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.",
|
error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.",
|
||||||
@@ -210,7 +234,10 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
s.id === shiftId
|
s.id === shiftId
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
registeredStaff: [...s.registeredStaff, { id: staffId, name: staffName }],
|
registeredStaff: [
|
||||||
|
...s.registeredStaff,
|
||||||
|
{ id: staffId, name: staffName },
|
||||||
|
],
|
||||||
status: "registered" as ShiftStatus,
|
status: "registered" as ShiftStatus,
|
||||||
}
|
}
|
||||||
: s,
|
: s,
|
||||||
@@ -230,7 +257,8 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
|
|||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
registeredStaff: updated,
|
registeredStaff: updated,
|
||||||
status: updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
status:
|
||||||
|
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-1
@@ -116,7 +116,11 @@ export interface Combo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== SHIFT / SCHEDULE TYPES =====
|
// ===== SHIFT / SCHEDULE TYPES =====
|
||||||
export type ShiftStatus = "available" | "registered" | "approved_leave" | "absent";
|
export type ShiftStatus =
|
||||||
|
| "available"
|
||||||
|
| "registered"
|
||||||
|
| "approved_leave"
|
||||||
|
| "absent";
|
||||||
|
|
||||||
export interface RegisteredStaff {
|
export interface RegisteredStaff {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@types/node": "^20.19.37",
|
"@types/node": "^20.19.37",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temp",
|
"name": "temp",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.1.7",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.2",
|
||||||
"prettier-plugin-embed": "^0.5.1",
|
"prettier-plugin-embed": "^0.5.1",
|
||||||
"prettier-plugin-groovy": "^0.2.1",
|
"prettier-plugin-groovy": "^0.2.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
|||||||
Generated
+192
-193
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,21 @@
|
|||||||
|
|
||||||
## Tổng Quan
|
## Tổng Quan
|
||||||
|
|
||||||
Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng ký ca làm việc theo khung thời gian do quản lý tạo ra. Tính năng hỗ trợ cả hai vai trò: **Manager** và **Staff**, với giao diện responsive thích ứng theo kích thước màn hình.
|
Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng ký ca làm
|
||||||
|
việc theo khung thời gian do quản lý tạo ra. Tính năng hỗ trợ cả hai vai trò:
|
||||||
|
**Manager** và **Staff**, với giao diện responsive thích ứng theo kích thước màn
|
||||||
|
hình.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mục Đích
|
## Mục Đích
|
||||||
|
|
||||||
- Cho phép nhân viên xem danh sách các khung thời gian ca làm khả dụng và đăng ký vào ca mà họ muốn làm.
|
- Cho phép nhân viên xem danh sách các khung thời gian ca làm khả dụng và đăng
|
||||||
- Quản lý (Manager) có toàn quyền tạo, chỉnh sửa và xóa các khung thời gian ca làm.
|
ký vào ca mà họ muốn làm.
|
||||||
- Nhân viên có thể thấy ca làm của đồng nghiệp để tránh xung đột hoặc điều chỉnh lịch phù hợp.
|
- Quản lý (Manager) có toàn quyền tạo, chỉnh sửa và xóa các khung thời gian ca
|
||||||
|
làm.
|
||||||
|
- Nhân viên có thể thấy ca làm của đồng nghiệp để tránh xung đột hoặc điều chỉnh
|
||||||
|
lịch phù hợp.
|
||||||
- Cả Manager và Staff đều có thể chỉnh sửa hoặc hủy ca đã đăng ký của nhân viên.
|
- Cả Manager và Staff đều có thể chỉnh sửa hoặc hủy ca đã đăng ký của nhân viên.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -18,6 +24,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
## Vai Trò & Phân Quyền
|
## Vai Trò & Phân Quyền
|
||||||
|
|
||||||
### Manager
|
### Manager
|
||||||
|
|
||||||
- Tạo, chỉnh sửa, xóa khung thời gian ca làm (shift slots).
|
- Tạo, chỉnh sửa, xóa khung thời gian ca làm (shift slots).
|
||||||
- Xem lịch làm việc của toàn bộ nhân viên theo tuần hoặc tháng.
|
- Xem lịch làm việc của toàn bộ nhân viên theo tuần hoặc tháng.
|
||||||
- Phê duyệt hoặc từ chối yêu cầu đăng ký ca.
|
- Phê duyệt hoặc từ chối yêu cầu đăng ký ca.
|
||||||
@@ -25,10 +32,12 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
- Gán ca làm cho nhân viên cụ thể nếu cần.
|
- Gán ca làm cho nhân viên cụ thể nếu cần.
|
||||||
|
|
||||||
### Staff (Nhân Viên)
|
### Staff (Nhân Viên)
|
||||||
|
|
||||||
- Xem danh sách ca làm khả dụng trong tuần hoặc tháng.
|
- Xem danh sách ca làm khả dụng trong tuần hoặc tháng.
|
||||||
- Đăng ký vào ca mà mình muốn làm.
|
- Đăng ký vào ca mà mình muốn làm.
|
||||||
- Xem ca đã được đăng ký bởi các nhân viên khác.
|
- Xem ca đã được đăng ký bởi các nhân viên khác.
|
||||||
- Hủy hoặc chỉnh sửa ca đã đăng ký của bản thân (trong giới hạn thời gian cho phép).
|
- Hủy hoặc chỉnh sửa ca đã đăng ký của bản thân (trong giới hạn thời gian cho
|
||||||
|
phép).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,28 +45,34 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
|
|
||||||
### Desktop (≥ 1024px) — Dạng Bảng Lịch Tuần/Tháng
|
### Desktop (≥ 1024px) — Dạng Bảng Lịch Tuần/Tháng
|
||||||
|
|
||||||
- Hiển thị lịch làm việc dạng **table theo tuần** (mặc định), với khả năng chuyển sang **xem theo tháng**.
|
- Hiển thị lịch làm việc dạng **table theo tuần** (mặc định), với khả năng
|
||||||
|
chuyển sang **xem theo tháng**.
|
||||||
- Mỗi cột đại diện cho một ngày trong tuần (Mon–Sun).
|
- Mỗi cột đại diện cho một ngày trong tuần (Mon–Sun).
|
||||||
- Mỗi hàng đại diện cho một nhân viên hoặc một khung giờ.
|
- Mỗi hàng đại diện cho một nhân viên hoặc một khung giờ.
|
||||||
- Các ca làm được hiển thị dưới dạng **card màu** trong ô tương ứng, gồm:
|
- Các ca làm được hiển thị dưới dạng **card màu** trong ô tương ứng, gồm:
|
||||||
- Tên ca / Khung giờ (ví dụ: `08:00 – 12:00`)
|
- Tên ca / Khung giờ (ví dụ: `08:00 – 12:00`)
|
||||||
- Số giờ làm & mức lương dự kiến (ví dụ: `4h · 120k`)
|
- Số giờ làm & mức lương dự kiến (ví dụ: `4h · 120k`)
|
||||||
- Trạng thái: `Available`, `Registered`, `Approved Leave`, `Absent`
|
- Trạng thái: `Available`, `Registered`, `Approved Leave`, `Absent`
|
||||||
- **Bộ lọc tuần/tháng** ở góc trên, cho phép điều hướng qua lại giữa các tuần/tháng.
|
- **Bộ lọc tuần/tháng** ở góc trên, cho phép điều hướng qua lại giữa các
|
||||||
|
tuần/tháng.
|
||||||
- Hiển thị **tổng ngân sách tuần** (Weekly Budget) ở đầu bảng.
|
- Hiển thị **tổng ngân sách tuần** (Weekly Budget) ở đầu bảng.
|
||||||
- Nhóm nhân viên theo **department/role** (ví dụ: Bar Staff, Janitors,...).
|
- Nhóm nhân viên theo **department/role** (ví dụ: Bar Staff, Janitors,...).
|
||||||
- Mỗi ca làm có thể click để xem chi tiết, chỉnh sửa hoặc xóa.
|
- Mỗi ca làm có thể click để xem chi tiết, chỉnh sửa hoặc xóa.
|
||||||
|
|
||||||
#### Chế Độ Xem Theo Tháng (Month View)
|
#### Chế Độ Xem Theo Tháng (Month View)
|
||||||
|
|
||||||
- Hiển thị dạng **calendar grid** (lưới 7 cột × ~5 hàng).
|
- Hiển thị dạng **calendar grid** (lưới 7 cột × ~5 hàng).
|
||||||
- Mỗi ô ngày hiển thị số ca đã đăng ký hoặc dấu chấm màu trạng thái (tương tự ảnh tham khảo).
|
- Mỗi ô ngày hiển thị số ca đã đăng ký hoặc dấu chấm màu trạng thái (tương tự
|
||||||
- Cho phép nhân viên và manager tính toán ngày có thể nghỉ hoặc đăng ký ca xa hơn trong tháng.
|
ảnh tham khảo).
|
||||||
|
- Cho phép nhân viên và manager tính toán ngày có thể nghỉ hoặc đăng ký ca xa
|
||||||
|
hơn trong tháng.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Mobile (< 768px) — Dạng Lịch Dọc + Dot Indicator
|
### Mobile (< 768px) — Dạng Lịch Dọc + Dot Indicator
|
||||||
|
|
||||||
- Hiển thị **calendar theo tháng dạng nhỏ gọn** (compact calendar) ở đầu màn hình.
|
- Hiển thị **calendar theo tháng dạng nhỏ gọn** (compact calendar) ở đầu màn
|
||||||
|
hình.
|
||||||
- Các ngày có ca làm được đánh dấu bằng **dot màu** phía dưới số ngày:
|
- Các ngày có ca làm được đánh dấu bằng **dot màu** phía dưới số ngày:
|
||||||
- 🟡 Vàng: Ca đang mở / khả dụng
|
- 🟡 Vàng: Ca đang mở / khả dụng
|
||||||
- 🟢 Xanh lá: Ca đã được đăng ký
|
- 🟢 Xanh lá: Ca đã được đăng ký
|
||||||
@@ -75,7 +90,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
## Trạng Thái Ca Làm (Shift Status)
|
## Trạng Thái Ca Làm (Shift Status)
|
||||||
|
|
||||||
| Trạng Thái | Màu | Mô Tả |
|
| Trạng Thái | Màu | Mô Tả |
|
||||||
|----------------|-----------------|-----------------------------------|
|
| -------------- | --------------- | --------------------------------- |
|
||||||
| Available | Xanh dương nhạt | Ca đang mở, chưa có ai đăng ký |
|
| Available | Xanh dương nhạt | Ca đang mở, chưa có ai đăng ký |
|
||||||
| Registered | Xanh dương đậm | Nhân viên đã đăng ký ca này |
|
| Registered | Xanh dương đậm | Nhân viên đã đăng ký ca này |
|
||||||
| Approved Leave | Tím/Lavender | Nhân viên đã được duyệt nghỉ phép |
|
| Approved Leave | Tím/Lavender | Nhân viên đã được duyệt nghỉ phép |
|
||||||
@@ -86,6 +101,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
## Luồng Chức Năng Chính
|
## Luồng Chức Năng Chính
|
||||||
|
|
||||||
### Staff — Đăng Ký Ca
|
### Staff — Đăng Ký Ca
|
||||||
|
|
||||||
1. Truy cập màn hình Register Shift.
|
1. Truy cập màn hình Register Shift.
|
||||||
2. Chọn tuần hoặc tháng muốn xem.
|
2. Chọn tuần hoặc tháng muốn xem.
|
||||||
3. Xem các ca làm khả dụng (Available).
|
3. Xem các ca làm khả dụng (Available).
|
||||||
@@ -94,21 +110,26 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
6. Ca chuyển sang trạng thái **Registered**.
|
6. Ca chuyển sang trạng thái **Registered**.
|
||||||
|
|
||||||
### Manager — Tạo Ca Làm Mới
|
### Manager — Tạo Ca Làm Mới
|
||||||
|
|
||||||
1. Vào giao diện dashboard của manager.
|
1. Vào giao diện dashboard của manager.
|
||||||
2. Vào giao diện lịch của nhóm/department.
|
2. Vào giao diện lịch của nhóm/department.
|
||||||
3. Click vào ô ngày muốn tạo ca.
|
3. Click vào ô ngày muốn tạo ca.
|
||||||
4. Nhập thông tin: Khung giờ bắt đầu – kết thúc, số lượng nhân viên cần, mức lương.
|
4. Nhập thông tin: Khung giờ bắt đầu – kết thúc, số lượng nhân viên cần, mức
|
||||||
|
lương.
|
||||||
5. Lưu → Ca hiển thị trên lịch với trạng thái **Available**.
|
5. Lưu → Ca hiển thị trên lịch với trạng thái **Available**.
|
||||||
|
|
||||||
### Xóa / Chỉnh Sửa Ca
|
### Xóa / Chỉnh Sửa Ca
|
||||||
|
|
||||||
- **Manager**: Có thể chỉnh sửa hoặc xóa bất kỳ ca nào của bất kỳ nhân viên nào.
|
- **Manager**: Có thể chỉnh sửa hoặc xóa bất kỳ ca nào của bất kỳ nhân viên nào.
|
||||||
- **Staff**: Chỉ có thể hủy ca của bản thân, trong phạm vi thời gian cho phép (ví dụ: trước 24h so với giờ bắt đầu ca).
|
- **Staff**: Chỉ có thể hủy ca của bản thân, trong phạm vi thời gian cho phép
|
||||||
|
(ví dụ: trước 24h so với giờ bắt đầu ca).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dữ Liệu & Logic
|
## Dữ Liệu & Logic
|
||||||
|
|
||||||
### Shift Slot Object
|
### Shift Slot Object
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "shift_001",
|
"id": "shift_001",
|
||||||
@@ -119,9 +140,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
"wage": 80,
|
"wage": 80,
|
||||||
"department": "Bar Staff",
|
"department": "Bar Staff",
|
||||||
"maxStaff": 2,
|
"maxStaff": 2,
|
||||||
"registeredStaff": [
|
"registeredStaff": [{ "id": "staff_01", "name": "Carol Saragosa" }],
|
||||||
{ "id": "staff_01", "name": "Carol Saragosa" }
|
|
||||||
],
|
|
||||||
"status": "registered"
|
"status": "registered"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -131,5 +150,7 @@ Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng
|
|||||||
## Ghi Chú Bổ Sung
|
## Ghi Chú Bổ Sung
|
||||||
|
|
||||||
- Nhân viên **không được đăng ký 2 ca trùng giờ** trong cùng một ngày.
|
- Nhân viên **không được đăng ký 2 ca trùng giờ** trong cùng một ngày.
|
||||||
- Hệ thống cần hiển thị **cảnh báo xung đột** khi nhân viên cố đăng ký ca bị trùng lịch.
|
- Hệ thống cần hiển thị **cảnh báo xung đột** khi nhân viên cố đăng ký ca bị
|
||||||
- Cần hỗ trợ **thông báo (notification)** khi ca làm bị thay đổi hoặc bị hủy bởi manager.
|
trùng lịch.
|
||||||
|
- Cần hỗ trợ **thông báo (notification)** khi ca làm bị thay đổi hoặc bị hủy bởi
|
||||||
|
manager.
|
||||||
|
|||||||
Reference in New Issue
Block a user