-
+
+
+ setValue("clockInTime", val, {
+ shouldValidate: true,
+ shouldDirty: true,
+ })
+ }
+ error={!!errors.clockInTime}
+ />
~
-
+
+ setValue("clockOutTime", val, {
+ shouldValidate: true,
+ shouldDirty: true,
+ })
+ }
+ error={!!errors.clockOutTime}
+ />
{(errors.clockInTime || errors.clockOutTime) && (
diff --git a/src/pages/schedule/boss/AttendanceEditForm.tsx b/src/pages/schedule/boss/AttendanceEditForm.tsx
index 32a9a02..643146e 100644
--- a/src/pages/schedule/boss/AttendanceEditForm.tsx
+++ b/src/pages/schedule/boss/AttendanceEditForm.tsx
@@ -12,24 +12,18 @@ import {
updateAttendance,
deleteAttendance,
} from "../../../api/boss/schedule.ts";
-import { useEffect } from "react";
import { DailyAttendanceRecord } from "../../../types/calendar.ts";
import { parseDateStringToKST } from "../../../libs/date.ts";
import { toast } from "react-toastify";
import { showConfirm } from "../../../libs/showConfirm.ts";
+import TimeInput from "../../../components/common/TimeInput.tsx";
+import { isValidStoreId } from "../../../utils/store.ts";
-const schema = z.discriminatedUnion("clockInStatus", [
- z.object({
- clockInStatus: z.literal("ABSENT"),
- clockInTime: z.literal(null),
- clockOutTime: z.literal(null),
- }),
- z.object({
- clockInStatus: z.enum(["NORMAL", "LATE"]),
- clockInTime: z.string().min(1, "출근 시간을 입력해주세요"),
- clockOutTime: z.string().min(1, "퇴근 시간을 입력해주세요"),
- }),
-]);
+const schema = z.object({
+ clockInStatus: z.enum(["NORMAL", "LATE", "ABSENT"]),
+ clockInTime: z.string().min(1, "출근 시간을 입력해주세요"),
+ clockOutTime: z.string().min(1, "퇴근 시간을 입력해주세요"),
+});
type FormData = z.infer;
@@ -43,7 +37,6 @@ const AttendanceEditForm = ({
const storeId = selectedStore?.storeId;
const {
- register,
handleSubmit,
setValue,
watch,
@@ -51,36 +44,33 @@ const AttendanceEditForm = ({
} = useForm({
resolver: zodResolver(schema),
mode: "onChange",
- defaultValues:
- attendance?.clockInStatus === "ABSENT"
- ? {
- clockInStatus: "ABSENT",
- clockInTime: null,
- clockOutTime: null,
- }
- : {
- clockInStatus: attendance?.clockInStatus ?? "NORMAL",
- clockInTime: attendance?.clockInTime?.slice(11, 16) ?? "",
- clockOutTime: attendance?.clockOutTime?.slice(11, 16) ?? "",
- },
+ defaultValues: {
+ clockInStatus: attendance?.clockInStatus ?? "NORMAL",
+ clockInTime:
+ attendance?.clockInStatus === "ABSENT"
+ ? "00:00"
+ : (attendance?.clockInTime?.slice(11, 16) ?? ""),
+ clockOutTime:
+ attendance?.clockInStatus === "ABSENT"
+ ? "00:00"
+ : (attendance?.clockOutTime?.slice(11, 16) ?? ""),
+ },
});
const clockInStatus = watch("clockInStatus");
- useEffect(() => {
- if (clockInStatus === "ABSENT") {
- setValue("clockInTime", null);
- setValue("clockOutTime", null);
- }
- }, [clockInStatus, setValue]);
-
const onSubmit = async (data: FormData) => {
- if (!storeId) return;
+ if (!isValidStoreId(storeId)) {
+ setBottomSheetOpen(false);
+ return;
+ }
try {
await updateAttendance(storeId, schedule.scheduleId, {
clockInStatus: data.clockInStatus,
- clockInTime: data.clockInTime ?? null,
- clockOutTime: data.clockOutTime ?? null,
+ clockInTime:
+ data.clockInStatus === "ABSENT" ? "00:00" : data.clockInTime,
+ clockOutTime:
+ data.clockInStatus === "ABSENT" ? "00:00" : data.clockOutTime,
});
const dateKey = formatFullDate(parseDateStringToKST(schedule.workDate));
await useScheduleStore.getState().syncScheduleAndDot(storeId, dateKey);
@@ -93,7 +83,10 @@ const AttendanceEditForm = ({
};
const onDelete = async () => {
- if (!storeId) return;
+ if (!isValidStoreId(storeId)) {
+ setBottomSheetOpen(false);
+ return;
+ }
const confirmed = await showConfirm({
title: "정말 삭제할까요?",
@@ -142,24 +135,22 @@ const AttendanceEditForm = ({
-
-
+ {}}
disabled
/>
~
- {}}
disabled
/>
@@ -186,23 +177,34 @@ const AttendanceEditForm = ({
{clockInStatus !== "ABSENT" && (
-
-
+
+ setValue("clockInTime", val, {
+ shouldValidate: true,
+ shouldDirty: true,
+ })
+ }
+ error={!!errors.clockInTime}
/>
~
-
+ setValue("clockOutTime", val, {
+ shouldValidate: true,
+ shouldDirty: true,
+ })
+ }
+ error={!!errors.clockOutTime}
/>
+ {(errors.clockInTime || errors.clockOutTime) && (
+
+ {errors.clockInTime?.message || errors.clockOutTime?.message}
+
+ )}
)}
diff --git a/src/pages/schedule/boss/Schedule.tsx b/src/pages/schedule/boss/Schedule.tsx
index d505c8f..2c2d01b 100644
--- a/src/pages/schedule/boss/Schedule.tsx
+++ b/src/pages/schedule/boss/Schedule.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import Calendar from "react-calendar";
import "react-calendar/dist/Calendar.css";
import ArrowIcon from "../../../components/icons/ArrowIcon.tsx";
-import { formatFullDate } from "../../../utils/date.ts";
+import { formatFullDate, formatKRDate } from "../../../utils/date.ts";
import useBottomSheetStore from "../../../stores/useBottomSheetStore.ts";
import useStoreStore from "../../../stores/storeStore.ts";
import SingleScheduleAddForm from "./SingleScheduleAddForm.tsx";
@@ -20,11 +20,13 @@ import { useScheduleFilters } from "../../../hooks/useScheduleFilters.ts";
import StaffScheduleList from "./StaffScheduleList.tsx";
import dayjs from "dayjs";
import { getKSTDate } from "../../../libs/date.ts";
+import { isValidStoreId } from "../../../utils/store.ts";
const Schedule = () => {
const [selectedDate, setSelectedDate] = useState(getKSTDate());
const [calendarViewDate, setCalendarViewDate] = useState(getKSTDate());
const dateKey = formatFullDate(selectedDate);
+ const displayDate = formatKRDate(selectedDate);
const { scheduleMap, dotMap, fetchDailySchedule, fetchDotRange } =
useScheduleStore();
@@ -119,13 +121,13 @@ const Schedule = () => {
};
useEffect(() => {
- if (storeId && calendarViewDate) {
+ if (isValidStoreId(storeId) && calendarViewDate) {
fetchDotRange(storeId, calendarViewDate);
}
}, [storeId, calendarViewDate, fetchDotRange]);
useEffect(() => {
- if (storeId) {
+ if (isValidStoreId(storeId)) {
fetchDailySchedule(storeId, dateKey);
}
}, [storeId, dateKey, fetchDailySchedule]);
@@ -201,20 +203,16 @@ const Schedule = () => {
}}
/>
-
-
-
{dateKey}
+
+
+
{displayDate}
{isPast ? (
-
@@ -223,7 +221,7 @@ const Schedule = () => {
-
+
{
diff --git a/src/pages/schedule/boss/ScheduleFilter.tsx b/src/pages/schedule/boss/ScheduleFilter.tsx
index 0ad4192..9bf3f62 100644
--- a/src/pages/schedule/boss/ScheduleFilter.tsx
+++ b/src/pages/schedule/boss/ScheduleFilter.tsx
@@ -15,7 +15,7 @@ const ScheduleFilter = () => {
const { filters, toggleFilter } = useScheduleFilters();
return (
-
+
{scheduleFilterItems.map(({ key, label }) => (
-
- {staffList.map((staff) => (
- -
- setValue("staffId", staff.staffId, { shouldValidate: true })
- }
- >
-
+
+ {staffList.map((staff) => (
+
- {staff.name}
-
- ))}
-
+ onClick={() =>
+ setValue("staffId", staff.staffId, { shouldValidate: true })
+ }
+ >
+

+
{staff.name}
+
+ ))}
+
+
+
{errors.staffId && (
{errors.staffId.message}
)}
@@ -165,21 +179,29 @@ const SingleScheduleAddForm = ({ defaultDate }: SingleScheduleAddFormProps) => {
근무 시간 *
-
-
+ (
+
+ )}
/>
~
- (
+
+ )}
/>
{(errors.startTime || errors.endTime) && (
diff --git a/src/pages/schedule/boss/SingleScheduleEditForm.tsx b/src/pages/schedule/boss/SingleScheduleEditForm.tsx
index c26bced..17198bf 100644
--- a/src/pages/schedule/boss/SingleScheduleEditForm.tsx
+++ b/src/pages/schedule/boss/SingleScheduleEditForm.tsx
@@ -2,7 +2,6 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import SingleDatePicker from "../../../components/common/SingleDatePicker.tsx";
-import TextField from "../../../components/common/TextField.tsx";
import Button from "../../../components/common/Button.tsx";
import useBottomSheetStore from "../../../stores/useBottomSheetStore.ts";
import useStoreStore from "../../../stores/storeStore.ts";
@@ -19,6 +18,8 @@ import {
} from "../../../libs/date.ts";
import { toast } from "react-toastify";
import { showConfirm } from "../../../libs/showConfirm.ts";
+import TimeInput from "../../../components/common/TimeInput.tsx";
+import { isValidStoreId } from "../../../utils/store.ts";
interface SingleScheduleEditFormProps {
schedule: DailyAttendanceRecord["schedule"];
@@ -43,7 +44,6 @@ const SingleScheduleEditForm = ({
const storeId = selectedStore?.storeId;
const {
- register,
handleSubmit,
setValue,
watch,
@@ -58,8 +58,13 @@ const SingleScheduleEditForm = ({
},
});
+ const startTime = watch("startTime");
+ const endTime = watch("endTime");
+
const onSubmit = async (data: FormData) => {
- if (!storeId) return;
+ if (!isValidStoreId(storeId)) {
+ return;
+ }
try {
await updateSingleSchedule(storeId, schedule.scheduleId, {
@@ -80,7 +85,9 @@ const SingleScheduleEditForm = ({
};
const onDelete = async () => {
- if (!storeId) return;
+ if (!isValidStoreId(storeId)) {
+ return;
+ }
const confirmed = await showConfirm({
title: "정말로 삭제할까요?",
@@ -149,21 +156,27 @@ const SingleScheduleEditForm = ({
근무 시간 *
-
-
+
+ setValue("startTime", val, {
+ shouldValidate: true,
+ shouldDirty: true,
+ })
+ }
+ error={!!errors.startTime}
/>
~
-
+ setValue("endTime", val, {
+ shouldValidate: true,
+ shouldDirty: true,
+ })
+ }
+ error={!!errors.endTime}
/>
{(errors.startTime || errors.endTime) && (
diff --git a/src/pages/schedule/boss/StaffScheduleList.tsx b/src/pages/schedule/boss/StaffScheduleList.tsx
index 1656849..dba8f68 100644
--- a/src/pages/schedule/boss/StaffScheduleList.tsx
+++ b/src/pages/schedule/boss/StaffScheduleList.tsx
@@ -98,19 +98,23 @@ const StaffScheduleList = ({ records, onClick, emptyMessage }: Props) => {
{/* 퇴근 상태 */}
- {attendance?.clockInStatus !== "ABSENT" && (
-
+ {attendance?.clockInStatus &&
+ attendance?.clockOutStatus !== "NORMAL" && (
- {clockOutLabel} {extraLabel}
-
- )}
+ >
+
+ {clockOutLabel} {extraLabel}
+
+ )}
diff --git a/src/pages/schedule/staff/ScheduleStaff.tsx b/src/pages/schedule/staff/ScheduleStaff.tsx
index 3cc9e13..d445478 100644
--- a/src/pages/schedule/staff/ScheduleStaff.tsx
+++ b/src/pages/schedule/staff/ScheduleStaff.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import Calendar from "react-calendar";
import "react-calendar/dist/Calendar.css";
import ArrowIcon from "../../../components/icons/ArrowIcon.tsx";
-import { formatFullDate } from "../../../utils/date.ts";
+import { formatFullDate, formatKRDate } from "../../../utils/date.ts";
import useBottomSheetStore from "../../../stores/useBottomSheetStore.ts";
import "../../../styles/schedulePageCalendar.css";
import { getClockInStyle } from "../../../utils/attendance.ts";
@@ -13,12 +13,13 @@ import ScheduleFilter from "../boss/ScheduleFilter.tsx";
import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts";
import useStaffScheduleStore from "../../../stores/staff/useStaffScheduleStore.ts";
import StaffScheduleList from "../boss/StaffScheduleList.tsx";
-import SingleScheduleEditForm from "../boss/SingleScheduleEditForm.tsx";
-import AttendanceEditForm from "../boss/AttendanceEditForm.tsx";
import { getKSTDate } from "../../../libs/date.ts";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
+import StaffAttendanceEditForm from "./StaffAttendanceEditForm.tsx";
+import StaffScheduleEditForm from "./StaffScheduleEditForm.tsx";
+import { isValidStoreId } from "../../../utils/store.ts";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -26,6 +27,7 @@ const ScheduleStaff = () => {
const [selectedDate, setSelectedDate] = useState
(getKSTDate());
const [calendarViewDate, setCalendarViewDate] = useState(getKSTDate());
const dateKey = formatFullDate(selectedDate);
+ const displayDate = formatKRDate(selectedDate);
const { scheduleMap, dotMap, fetchDailySchedule, fetchDotRange } =
useStaffScheduleStore();
@@ -59,7 +61,6 @@ const ScheduleStaff = () => {
});
});
- // TODO: 알바생 바텀시트 따로 만들어야 함
// 스케줄 상세 보기 바텀시트 오픈 함수
const handleOpenScheduleDetail = (
schedule: DailyAttendanceRecord["schedule"],
@@ -67,7 +68,7 @@ const ScheduleStaff = () => {
attendance: DailyAttendanceRecord["attendance"],
) => {
setBottomSheetContent(
- {
staff: DailyAttendanceRecord["staff"],
attendance: DailyAttendanceRecord["attendance"],
) => {
+ const { clockInTime, clockOutTime, clockInStatus } = attendance;
+
+ const shouldOpen =
+ (clockInTime && clockOutTime) || clockInStatus === "ABSENT";
+
+ if (!shouldOpen) return;
setBottomSheetContent(
- {
};
useEffect(() => {
- if (storeId && calendarViewDate) {
+ if (isValidStoreId(storeId) && calendarViewDate) {
fetchDotRange(storeId, calendarViewDate);
}
}, [storeId, calendarViewDate, fetchDotRange]);
useEffect(() => {
- if (storeId) {
+ if (isValidStoreId(storeId)) {
fetchDailySchedule(storeId, dateKey);
}
}, [storeId, dateKey, fetchDailySchedule]);
@@ -182,12 +189,15 @@ const ScheduleStaff = () => {
}}
/>
-
+
+
-
+
{
diff --git a/src/pages/schedule/staff/StaffAttendanceEditForm.tsx b/src/pages/schedule/staff/StaffAttendanceEditForm.tsx
new file mode 100644
index 0000000..33e24da
--- /dev/null
+++ b/src/pages/schedule/staff/StaffAttendanceEditForm.tsx
@@ -0,0 +1,273 @@
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import TextField from "../../../components/common/TextField.tsx";
+import Button from "../../../components/common/Button.tsx";
+import useBottomSheetStore from "../../../stores/useBottomSheetStore.ts";
+import { formatFullDate } from "../../../utils/date.ts";
+import SelectField from "../../../components/common/SelectField.tsx";
+import { useEffect, useState } from "react";
+import { DailyAttendanceRecord } from "../../../types/calendar.ts";
+import { parseDateStringToKST } from "../../../libs/date.ts";
+import { toast } from "react-toastify";
+import { requestAttendanceEdit } from "../../../api/staff/attendance.ts";
+import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts";
+import TimeInput from "../../../components/common/TimeInput.tsx";
+import { isValidStoreId } from "../../../utils/store.ts";
+
+const schema = z
+ .object({
+ clockInStatus: z.enum(["NORMAL", "LATE", "ABSENT"]),
+ clockInTime: z.string().optional(),
+ clockOutTime: z.string().optional(),
+ reason: z.string().min(1, "사유를 입력해주세요"),
+ })
+ .superRefine((data, ctx) => {
+ if (data.clockInStatus !== "ABSENT") {
+ if (!data.clockInTime || data.clockInTime.trim() === "") {
+ ctx.addIssue({
+ path: ["clockInTime"],
+ code: z.ZodIssueCode.custom,
+ message: "출근 시간을 입력해주세요",
+ });
+ }
+ if (!data.clockOutTime || data.clockOutTime.trim() === "") {
+ ctx.addIssue({
+ path: ["clockOutTime"],
+ code: z.ZodIssueCode.custom,
+ message: "퇴근 시간을 입력해주세요",
+ });
+ }
+ }
+ });
+
+type FormData = z.infer;
+
+const StaffAttendanceEditForm = ({
+ schedule,
+ staff,
+ attendance,
+}: DailyAttendanceRecord) => {
+ const { setBottomSheetOpen } = useBottomSheetStore();
+ const { selectedStore } = useStaffStoreStore();
+ const storeId = selectedStore?.storeId;
+ const [isEditMode, setIsEditMode] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ setValue,
+ watch,
+ formState: { errors, isValid, isDirty },
+ } = useForm({
+ resolver: zodResolver(schema),
+ mode: "onChange",
+ defaultValues:
+ attendance?.clockInStatus === "ABSENT"
+ ? {
+ clockInStatus: "ABSENT",
+ clockInTime: "",
+ clockOutTime: "",
+ reason: "",
+ }
+ : {
+ clockInStatus: attendance?.clockInStatus ?? "NORMAL",
+ clockInTime: attendance?.clockInTime?.slice(11, 16) ?? "",
+ clockOutTime: attendance?.clockOutTime?.slice(11, 16) ?? "",
+ reason: "",
+ },
+ });
+
+ const clockInStatus = watch("clockInStatus");
+
+ useEffect(() => {
+ if (clockInStatus === "ABSENT") {
+ setValue("clockInTime", "00:00");
+ setValue("clockOutTime", "00:00");
+ }
+ }, [clockInStatus, setValue]);
+
+ const onSubmit = async (data: FormData) => {
+ if (!isValidStoreId(storeId)) {
+ setBottomSheetOpen(false);
+ return;
+ }
+
+ try {
+ await requestAttendanceEdit(storeId, schedule.scheduleId, {
+ requestedClockInStatus: data.clockInStatus,
+ requestedClockInTime:
+ data.clockInStatus === "ABSENT" ? "00:00" : data.clockInTime!,
+ requestedClockOutTime:
+ data.clockInStatus === "ABSENT" ? "00:00" : data.clockOutTime!,
+ reason: data.reason,
+ });
+ toast.success("근태 수정 요청이 성공적으로 발송되었습니다.");
+ } catch (err) {
+ console.error("근태 수정 요청 실패", err);
+ } finally {
+ setBottomSheetOpen(false);
+ }
+ };
+
+ if (!selectedStore) {
+ return (
+
+ 선택된 매장이 없습니다.
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default StaffAttendanceEditForm;
diff --git a/src/pages/schedule/staff/StaffScheduleEditForm.tsx b/src/pages/schedule/staff/StaffScheduleEditForm.tsx
new file mode 100644
index 0000000..3e3d56a
--- /dev/null
+++ b/src/pages/schedule/staff/StaffScheduleEditForm.tsx
@@ -0,0 +1,237 @@
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { toast } from "react-toastify";
+
+import TextField from "../../../components/common/TextField.tsx";
+import Button from "../../../components/common/Button.tsx";
+import useBottomSheetStore from "../../../stores/useBottomSheetStore.ts";
+import { formatFullDate } from "../../../utils/date.ts";
+import { parseDateStringToKST } from "../../../libs/date.ts";
+import {
+ requestSubstitution,
+ fetchSubstituteCandidates,
+} from "../../../api/staff/schedule.ts";
+import { DailyAttendanceRecord } from "../../../types/calendar.ts";
+import { SubstituteCandidate } from "../../../types/schedule.ts";
+import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts";
+import TimeInput from "../../../components/common/TimeInput.tsx";
+import { isValidStoreId } from "../../../utils/store.ts";
+
+const schema = z.object({
+ targetStaffId: z.number({ required_error: "대타 근무자를 선택해주세요" }),
+ reason: z.string().min(1, "사유를 입력해주세요"),
+});
+
+type FormData = z.infer;
+
+interface Props {
+ schedule: DailyAttendanceRecord["schedule"];
+ staff: DailyAttendanceRecord["staff"];
+ attendance: DailyAttendanceRecord["attendance"];
+}
+
+const StaffScheduleEditForm = ({ schedule, staff }: Props) => {
+ const { setBottomSheetOpen } = useBottomSheetStore();
+ const { selectedStore } = useStaffStoreStore();
+ const storeId = selectedStore?.storeId;
+
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [candidates, setCandidates] = useState([]);
+
+ const {
+ register,
+ handleSubmit,
+ setValue,
+ watch,
+ formState: { errors, isValid, isDirty },
+ } = useForm({
+ resolver: zodResolver(schema),
+ mode: "onChange",
+ defaultValues: {
+ targetStaffId: 0,
+ reason: "",
+ },
+ });
+
+ const selectedStaffId = watch("targetStaffId");
+
+ const onSubmit = async (data: FormData) => {
+ if (!isValidStoreId(storeId)) {
+ setBottomSheetOpen(false);
+ return;
+ }
+
+ try {
+ await requestSubstitution(storeId, schedule.scheduleId, data);
+ toast.success("대타 근무 요청을 보냈습니다.");
+ } catch (err) {
+ console.error("대타 요청 실패", err);
+ toast.error("요청에 실패했습니다.");
+ } finally {
+ setBottomSheetOpen(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!isValidStoreId(storeId)) return;
+
+ const fetch = async () => {
+ try {
+ const result = await fetchSubstituteCandidates(
+ storeId,
+ schedule.scheduleId,
+ );
+ setCandidates(result);
+ } catch (err) {
+ console.error("대타 후보 조회 실패", err);
+ }
+ };
+
+ fetch();
+ }, [storeId, schedule.scheduleId]);
+ if (!selectedStore) {
+ return (
+
+ 선택된 매장이 없습니다.
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default StaffScheduleEditForm;
diff --git a/src/pages/signup/SignupStep2.tsx b/src/pages/signup/SignupStep2.tsx
index eb709a2..d7d95a1 100644
--- a/src/pages/signup/SignupStep2.tsx
+++ b/src/pages/signup/SignupStep2.tsx
@@ -15,6 +15,7 @@ import { signup } from "../../api/common/auth.ts";
import { useAuthStore } from "../../stores/authStore.ts";
import FullScreenLoading from "../../components/common/FullScreenLoading.tsx";
import { toast } from "react-toastify";
+import { useState } from "react";
interface SignupStep2Props {
role: "BOSS" | "STAFF";
@@ -34,6 +35,8 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
const { isLoggedIn, isLoading } = useAuth();
const { user } = useUserStore();
const navigate = useNavigate();
+ const [isRequestingLocation, setIsRequestingLocation] = useState(false);
+ const [isRequestingCamera, setIsRequestingCamera] = useState(false);
if (isLoading) return ;
if (!isLoggedIn || !user) return ;
@@ -72,6 +75,8 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
setValue("isAgreePrivacy", checked, { shouldValidate: true });
if (checked) {
+ setIsRequestingLocation(true);
+ setIsRequestingCamera(true);
requestLocation();
requestCamera();
} else {
@@ -80,24 +85,26 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
}
};
- // 카메라 권한 요청 성공/실패 처리
const { permissionState: locationPermission, requestLocation } =
useGeolocationPermission({
onSuccess: () => {
+ setIsRequestingLocation(false);
setValue("isAgreeLocation", true, { shouldValidate: true });
},
onError: () => {
+ setIsRequestingLocation(false);
setValue("isAgreeLocation", false, { shouldValidate: true });
},
});
- // 카메라 권한 요청 성공/실패 처리
const { permissionState: cameraPermission, requestCamera } =
useCameraPermission({
onSuccess: () => {
+ setIsRequestingCamera(false);
setValue("isAgreeCamera", true, { shouldValidate: true });
},
onError: () => {
+ setIsRequestingCamera(false);
setValue("isAgreeCamera", false, { shouldValidate: true });
},
});
@@ -108,13 +115,8 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
try {
const { accessToken, refreshToken } = await signup(data.role);
useAuthStore.getState().setTokens(accessToken, refreshToken);
- if (role === "BOSS") {
- navigate("/boss");
- } else if (role === "STAFF") {
- navigate("/staff");
- } else {
- navigate("/");
- }
+ toast.success("가입이 완료되었습니다!");
+ navigate("/login", { replace: true });
} catch (error: any) {
if (error.response?.status === 409) {
toast.error("이미 가입된 사용자입니다.");
@@ -259,6 +261,7 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
if (field.value) {
setValue("isAgreeLocation", false, { shouldValidate: true });
} else {
+ setIsRequestingLocation(true);
requestLocation();
}
}}
@@ -269,6 +272,7 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
: "동의 시 위치 권한이 요청되며, 출퇴근 기능 등에 활용됩니다."
}
required
+ isLoading={isRequestingLocation}
/>
)}
/>
@@ -283,6 +287,7 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
if (field.value) {
setValue("isAgreeCamera", false, { shouldValidate: true });
} else {
+ setIsRequestingCamera(true);
requestCamera();
}
}}
@@ -293,6 +298,7 @@ const SignupStep2 = ({ role, onBack }: SignupStep2Props) => {
: "동의 시 카메라 사용권한이 요청되며, 출퇴근 기능 등에 활용됩니다."
}
required
+ isLoading={isRequestingCamera}
/>
)}
/>
diff --git a/src/pages/store/boss/AddressSearchPopup.tsx b/src/pages/store/boss/AddressSearchPopup.tsx
index d8f5c51..5ec69cb 100644
--- a/src/pages/store/boss/AddressSearchPopup.tsx
+++ b/src/pages/store/boss/AddressSearchPopup.tsx
@@ -3,8 +3,12 @@ import { useLayout } from "../../../hooks/useLayout.ts";
const AddressSearchPopup = () => {
useLayout({
- headerVisible: false,
+ title: "주소 검색",
+ theme: "plain",
+ headerVisible: true,
bottomNavVisible: false,
+ onBack: () => window.close(),
+ rightIcon: null,
});
const handleComplete = (data: any) => {
@@ -20,7 +24,6 @@ const AddressSearchPopup = () => {
return (
-
주소 검색
);
diff --git a/src/pages/store/boss/BossStoreCard.tsx b/src/pages/store/boss/BossStoreCard.tsx
index ab506c5..c0d28e0 100644
--- a/src/pages/store/boss/BossStoreCard.tsx
+++ b/src/pages/store/boss/BossStoreCard.tsx
@@ -11,7 +11,8 @@ import SkeletonStoreCard from "../../../components/skeleton/SkeletonStoreCard.ts
const BossStoreCard = () => {
const navigate = useNavigate();
- const { selectedStore, setSelectedStore } = useStoreStore();
+ const { selectedStore, setSelectedStore, clearSelectedStore } =
+ useStoreStore();
const { setBottomSheetContent } = useBottomSheetStore();
const [storeList, setStoreList] = useState(null);
const [loading, setLoading] = useState(true);
@@ -20,35 +21,37 @@ const BossStoreCard = () => {
const fetchStores = async () => {
try {
const stores = await getStoreList();
+
setStoreList(stores);
- if (stores.length === 0) return;
+ if (stores.length === 0) {
+ clearSelectedStore();
+ return;
+ }
- const firstStore = stores[0];
+ const matched = selectedStore
+ ? stores.find((s) => s.storeId === selectedStore.storeId)
+ : null;
- if (!selectedStore) {
- setSelectedStore(firstStore);
- } else {
- const matched = stores.find(
- (s) => s.storeId === selectedStore.storeId,
- );
-
- if (
- matched &&
- JSON.stringify(matched) !== JSON.stringify(selectedStore)
- ) {
+ if (matched) {
+ // 서버 데이터와 로컬 상태가 다르면 갱신
+ if (JSON.stringify(matched) !== JSON.stringify(selectedStore)) {
setSelectedStore(matched);
}
+ } else {
+ // electedStore가 서버 목록에 없음 => 초기화
+ setSelectedStore(stores[0]);
}
} catch (error) {
console.error("매장 조회 실패", error);
+ clearSelectedStore();
} finally {
setLoading(false);
}
};
fetchStores();
- }, [selectedStore, setSelectedStore]);
+ }, []);
const openStoreSheet = () => {
setBottomSheetContent(, {
diff --git a/src/pages/store/boss/SalarySettingPage.tsx b/src/pages/store/boss/SalarySettingPage.tsx
deleted file mode 100644
index 73b29a9..0000000
--- a/src/pages/store/boss/SalarySettingPage.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useLayout } from "../../../hooks/useLayout.ts";
-
-const SalarySettingPage = () => {
- useLayout({
- title: "급여 설정",
- theme: "plain",
- bottomNavVisible: false,
- });
- return (
-
-
급여 설정
-
이 페이지에서 급여 설정를 확인하거나 수정할 수 있습니다.
-
- );
-};
-
-export default SalarySettingPage;
diff --git a/src/pages/store/boss/StoreInfoEditPage.tsx b/src/pages/store/boss/StoreInfoEditPage.tsx
index 5c19d67..2eadc84 100644
--- a/src/pages/store/boss/StoreInfoEditPage.tsx
+++ b/src/pages/store/boss/StoreInfoEditPage.tsx
@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useLayout } from "../../../hooks/useLayout.ts";
import useStoreStore from "../../../stores/storeStore.ts";
import {
+ deleteStore,
getStoreInfo,
reissueInviteCode,
updateStoreInfo,
@@ -17,6 +18,7 @@ import { getCoordsFromAddress } from "../../../utils/kakaoGeocoder.ts";
import ResetIcon from "../../../components/icons/ResetIcon.tsx";
import GpsMapPreview from "../../../components/common/GpsMapPreview.tsx";
import { toast } from "react-toastify";
+import { showConfirm } from "../../../libs/showConfirm.ts";
const StoreInfoEditPage = () => {
useLayout({
@@ -26,7 +28,7 @@ const StoreInfoEditPage = () => {
});
const navigate = useNavigate();
- const { selectedStore } = useStoreStore();
+ const { selectedStore, clearSelectedStore } = useStoreStore();
const [inviteCode, setInviteCode] = useState(null);
const [isSpinning, setIsSpinning] = useState(false);
@@ -36,7 +38,7 @@ const StoreInfoEditPage = () => {
watch,
setError,
handleSubmit,
- formState: { isValid },
+ formState: { isValid, errors },
} = useForm({
mode: "onChange",
resolver: zodResolver(storeSchema),
@@ -47,6 +49,7 @@ const StoreInfoEditPage = () => {
address: "",
latitude: 0,
longitude: 0,
+ overtimeLimit: 0,
},
});
@@ -59,6 +62,7 @@ const StoreInfoEditPage = () => {
setValue("businessNumber", data.businessNumber);
setValue("storeType", data.storeType);
setValue("address", data.address);
+ setValue("overtimeLimit", data.overtimeLimit);
// 주소 기반 좌표 설정
try {
@@ -82,11 +86,13 @@ const StoreInfoEditPage = () => {
if (!selectedStore) return;
try {
- const { address, storeType, latitude, longitude } = formData;
+ const { address, storeType, latitude, longitude, overtimeLimit } =
+ formData;
await updateStoreInfo(selectedStore.storeId, {
address,
storeType,
gps: { latitude, longitude },
+ overtimeLimit,
});
toast.success("매장 정보가 수정되었습니다.");
@@ -102,11 +108,40 @@ const StoreInfoEditPage = () => {
try {
setIsSpinning(true);
const result = await reissueInviteCode(selectedStore.storeId);
- setInviteCode(result.inviteCode); // 새 코드 반영
+ setInviteCode(result.inviteCode);
} catch (err) {
console.error(err);
} finally {
- setTimeout(() => setIsSpinning(false), 1000); // 1초 후 애니메이션 해제
+ setTimeout(() => setIsSpinning(false), 1000);
+ }
+ };
+
+ const handleStoreDelete = async () => {
+ const navigate = useNavigate();
+
+ if (!selectedStore) {
+ toast.error("선택된 매장이 없습니다.");
+ return;
+ }
+
+ const confirmed = await showConfirm({
+ title: "정말 매장을 삭제할까요?",
+ text: `해당 매장을 삭제하면\n모든 알바 정보와 기록이 사라집니다.\n삭제 후에는 되돌릴 수 없습니다.`,
+ confirmText: "삭제하기",
+ cancelText: "취소",
+ icon: "warning",
+ });
+
+ if (!confirmed) return;
+
+ try {
+ await deleteStore(selectedStore.storeId);
+ toast.success("매장이 성공적으로 삭제되었습니다.");
+
+ clearSelectedStore();
+ navigate("/boss", { replace: true });
+ } catch (err) {
+ console.error("매장 삭제 실패", err);
}
};
@@ -135,7 +170,7 @@ const StoreInfoEditPage = () => {
if (!selectedStore) {
toast.error("선택된 매장이 없습니다.");
- navigate("/store");
+ navigate("/boss");
return null;
}
@@ -242,7 +277,34 @@ const StoreInfoEditPage = () => {
<>>} />
<>>} />
- {}}>
+ (
+ {
+ const value = e.target.value;
+ field.onChange(value === "" ? 0 : Number(value));
+ }}
+ title="초과근무 허용 시간"
+ description="급여에 반영되는 초과근무 허용시간을 설정합니다."
+ theme="suffix"
+ suffix="분"
+ type="number"
+ min="0"
+ max="360"
+ required
+ state={errors.overtimeLimit ? "warning" : "none"}
+ helperText={errors.overtimeLimit?.message}
+ />
+ )}
+ />
+
+
매장 삭제하기
{/* 수정 버튼 */}
diff --git a/src/pages/store/boss/StoreInfoPage.tsx b/src/pages/store/boss/StoreInfoPage.tsx
index 84ef797..ca3bdde 100644
--- a/src/pages/store/boss/StoreInfoPage.tsx
+++ b/src/pages/store/boss/StoreInfoPage.tsx
@@ -7,9 +7,10 @@ import PinLocationIcon from "../../../components/icons/PinLocationIcon.tsx";
import { PersonOff } from "../../../components/icons/PersonIcon.tsx";
import MenuIcon from "../../../components/icons/MenuIcon.tsx";
import MailIcon from "../../../components/icons/MailIcon.tsx";
-import { StoreInfo } from "../../../types/store.ts";
+import { StoreSummaryBoss } from "../../../types/store.ts";
import FullScreenLoading from "../../../components/common/FullScreenLoading.tsx";
import { toast } from "react-toastify";
+import TimeIcon from "../../../components/icons/TimeIcon.tsx";
const StoreInfoPage = () => {
useLayout({
@@ -20,7 +21,7 @@ const StoreInfoPage = () => {
const navigate = useNavigate();
const { selectedStore } = useStoreStore();
- const [storeInfo, setStoreInfo] = useState(null);
+ const [storeInfo, setStoreInfo] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -85,6 +86,11 @@ const StoreInfoPage = () => {
{storeInfo.inviteCode}
+
+
+
+ 초과근무 최대 {storeInfo.overtimeLimit}분
+
);
diff --git a/src/pages/store/boss/StoreModalContent.tsx b/src/pages/store/boss/StoreModalContent.tsx
deleted file mode 100644
index b86bf69..0000000
--- a/src/pages/store/boss/StoreModalContent.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { useNavigate } from "react-router-dom";
-import Button from "../../../components/common/Button.tsx";
-import modalStore from "../../../stores/modalStore.ts";
-
-const StoreModalContent = () => {
- const navigate = useNavigate();
- const { setModalOpen } = modalStore();
-
- const handleStoreRegister = () => {
- navigate("/boss/store/register");
- setModalOpen(false);
- };
-
- return (
-
- {/* 상단 인사 + 설명 */}
-
-
김사장님
-
- 매장을 추가하고 망고보스를 이용해보세요.
-
-
-
- {/* 상단 버튼 */}
-
-
- + 매장 추가하기
-
-
-
- {/* 매장 리스트 (임시 카드 예시 1개) */}
-
-
-
매장 1 123-45-67890
-
- 경기 수원시 영통구 월드컵로206번길 가상의점포 23호
-
-
대표번호: 000000
-
- {/* 추후 storeList.map() 으로 렌더링 */}
-
-
- {/* 하단 완료 버튼 */}
-
- 완료하기
-
-
- );
-};
-
-export default StoreModalContent;
diff --git a/src/pages/store/boss/StoreRegisterBossPage.tsx b/src/pages/store/boss/StoreRegisterBossPage.tsx
index 6c3284a..c3fb53b 100644
--- a/src/pages/store/boss/StoreRegisterBossPage.tsx
+++ b/src/pages/store/boss/StoreRegisterBossPage.tsx
@@ -11,11 +11,14 @@ import { getCoordsFromAddress } from "../../../utils/kakaoGeocoder.ts";
import { useEffect, useState } from "react";
import SelectField from "../../../components/common/SelectField.tsx";
import {
+ getStoreList,
registerStore,
validateBusinessNumber,
} from "../../../api/boss/store.ts";
import Spinner from "../../../components/common/Spinner.tsx";
import FullScreenLoading from "../../../components/common/FullScreenLoading.tsx";
+import useStoreStore from "../../../stores/storeStore.ts";
+import { toast } from "react-toastify";
const StoreRegisterBossPage = () => {
useLayout({
@@ -29,8 +32,10 @@ const StoreRegisterBossPage = () => {
const { isLoggedIn, isLoading } = useAuth();
const { user } = useUserStore();
+ const { setSelectedStore } = useStoreStore();
const [isBusinessNumberChecked, setIsBusinessNumberChecked] = useState(false);
const [isChecking, setIsChecking] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
const navigate = useNavigate();
const {
@@ -39,7 +44,7 @@ const StoreRegisterBossPage = () => {
watch,
setValue,
getValues,
- formState: { isValid },
+ formState: { isValid, errors },
setError,
clearErrors,
} = useForm
({
@@ -52,6 +57,7 @@ const StoreRegisterBossPage = () => {
storeType: "CAFE",
latitude: undefined,
longitude: undefined,
+ overtimeLimit: 0,
},
});
@@ -109,6 +115,9 @@ const StoreRegisterBossPage = () => {
}, []);
const onSubmit = async (data: StoreFormValues) => {
+ if (isSubmitting) return;
+
+ setIsSubmitting(true);
try {
const {
storeName,
@@ -117,17 +126,31 @@ const StoreRegisterBossPage = () => {
storeType,
latitude,
longitude,
+ overtimeLimit,
} = data;
+
const payload = {
storeName,
businessNumber,
address,
storeType,
gps: { latitude, longitude },
+ overtimeLimit,
};
- await registerStore(payload);
+
+ const { storeId } = await registerStore(payload);
+ const stores = await getStoreList();
+ const registered = stores.find((s) => s.storeId === storeId);
+ if (registered) {
+ setSelectedStore(registered);
+ }
+ toast.success("매장 등록에 성공했습니다!");
navigate(-1);
- } catch (err) {}
+ } catch (err) {
+ console.error("매장 등록 실패", err);
+ } finally {
+ setIsSubmitting(false);
+ }
};
if (isLoading) return ;
@@ -252,15 +275,39 @@ const StoreRegisterBossPage = () => {
<>>} />
<>>} />
+ (
+ {
+ const value = e.target.value;
+ field.onChange(value === "" ? 0 : Number(value));
+ }}
+ title="초과근무 허용 시간"
+ description="급여에 반영되는 초과근무 허용시간을 설정합니다."
+ theme="suffix"
+ suffix="분"
+ type="number"
+ min="0"
+ max="360"
+ required
+ state={errors.overtimeLimit ? "warning" : "none"}
+ helperText={errors.overtimeLimit?.message}
+ />
+ )}
+ />
+
- 매장 등록하기
+ {isSubmitting ? : "매장 등록하기"}
);
diff --git a/src/pages/store/staff/StaffStoreCard.tsx b/src/pages/store/staff/StaffStoreCard.tsx
index 63f6703..b6a8614 100644
--- a/src/pages/store/staff/StaffStoreCard.tsx
+++ b/src/pages/store/staff/StaffStoreCard.tsx
@@ -31,22 +31,20 @@ const StaffStoreCard = () => {
const fetchStores = async () => {
try {
const stores = await fetchStaffStores();
- setStoreList(stores);
-
+ console.log(stores);
if (stores.length === 0) return;
+ setStoreList(stores);
+
const firstStore = stores[0];
if (!selectedStore) {
- // 초기값이 없는 경우 첫 번째 매장 선택
setSelectedStore(firstStore);
} else {
- // storeList에서 같은 storeId를 가진 최신 정보를 찾음
const matched = stores.find(
(s) => s.storeId === selectedStore.storeId,
);
- // storeId는 같지만 다른 내용(주소, 출퇴근방식 등)이 있다면 업데이트
if (
matched &&
JSON.stringify(matched) !== JSON.stringify(selectedStore)
@@ -68,7 +66,7 @@ const StaffStoreCard = () => {
if (!storeList || storeList.length === 0) {
return (
-
+
diff --git a/src/pages/store/staff/StoreBottomSheetContentStaff.tsx b/src/pages/store/staff/StoreBottomSheetContentStaff.tsx
deleted file mode 100644
index f93dc37..0000000
--- a/src/pages/store/staff/StoreBottomSheetContentStaff.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { useNavigate } from "react-router-dom";
-import Button from "../../../components/common/Button.tsx";
-import useBottomSheetStore from "../../../stores/useBottomSheetStore.ts";
-import { getStoreList } from "../../../api/boss/store.ts";
-import { useEffect, useState } from "react";
-import FullScreenLoading from "../../../components/common/FullScreenLoading.tsx";
-import ErrorIcon from "../../../components/icons/ErrorIcon.tsx";
-import useStoreStore from "../../../stores/storeStore.ts";
-import { StoreSummaryBoss } from "../../../types/store.ts";
-
-const StoreBottomSheetContent = () => {
- const navigate = useNavigate();
- const { setBottomSheetOpen } = useBottomSheetStore();
- const { selectedStore, setSelectedStore } = useStoreStore();
- const [tempSelectedStore, setTempSelectedStore] =
- useState
(selectedStore); // ✅ 임시 상태
- const [storeList, setStoreList] = useState(null);
- const [loading, setLoading] = useState(false);
-
- const handleStoreRegister = () => {
- setBottomSheetOpen(false);
- navigate("/staff/store/register");
- };
-
- const handleSelectStore = (store: StoreSummaryBoss) => {
- setTempSelectedStore(store); // ✅ 전역 상태 아님
- };
-
- const handleConfirm = () => {
- if (tempSelectedStore) {
- setSelectedStore(tempSelectedStore); // ✅ 여기서만 전역 store 업데이트
- }
- setBottomSheetOpen(false); // 바텀시트 닫기
- };
-
- useEffect(() => {
- const fetchStores = async () => {
- try {
- const stores = await getStoreList();
- setStoreList(stores);
- } catch (error) {
- console.error("매장 조회 실패", error);
- } finally {
- setLoading(false);
- }
- };
- fetchStores();
- }, []);
-
- if (loading) return ;
-
- return (
-
- {/* 매장 리스트 */}
-
-
- {!storeList || storeList.length === 0 ? (
-
-
-
-
- 현재 등록된 매장이 없습니다!
-
- 매장을 추가해 주세요.
-
-
- + 매장 추가하기
-
-
-
- ) : (
-
-
-
- + 매장 추가하기
-
-
- {storeList.map((store) => {
- const isSelected = tempSelectedStore?.storeId === store.storeId;
- return (
-
handleSelectStore(store)}
- className={`flex p-4 border border-grayscale-300 bg-white rounded-xl flex-col justify-center items-start gap-2 cursor-pointer transition-shadow duration-150 ${
- isSelected ? "shadow-blue-shadow" : "shadow-basic"
- }`}
- >
-
- {store.storeType}
-
-
{store.storeName}
-
- 주소
-
- {store.address}
-
-
-
-
- 초대코드
-
-
- {store.inviteCode}
-
-
-
- );
- })}
-
- )}
-
-
-
- {/* 하단 완료 버튼 */}
-
- 완료
-
-
- );
-};
-
-export default StoreBottomSheetContent;
diff --git a/src/pages/store/staff/StoreRegisterStaffPage.tsx b/src/pages/store/staff/StoreRegisterStaffPage.tsx
index b3e4ad6..19481c2 100644
--- a/src/pages/store/staff/StoreRegisterStaffPage.tsx
+++ b/src/pages/store/staff/StoreRegisterStaffPage.tsx
@@ -7,8 +7,12 @@ import Button from "../../../components/common/Button.tsx";
import { useLayout } from "../../../hooks/useLayout.ts";
import { useAuth } from "../../../hooks/useAuth.ts";
import { useUserStore } from "../../../stores/userStore.ts";
-import { joinStoreAsStaff } from "../../../api/staff/store.ts";
+import {
+ fetchStaffStores,
+ joinStoreAsStaff,
+} from "../../../api/staff/store.ts";
import FullScreenLoading from "../../../components/common/FullScreenLoading.tsx";
+import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts";
const inviteCodeSchema = z.object({
inviteCode: z
@@ -30,6 +34,7 @@ const StoreRegisterStaffPage = () => {
const { isLoggedIn, isLoading } = useAuth();
const { user } = useUserStore();
+ const { setSelectedStore } = useStaffStoreStore();
const navigate = useNavigate();
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@@ -74,7 +79,14 @@ const StoreRegisterStaffPage = () => {
const onSubmit = async (data: InviteCodeForm) => {
try {
- await joinStoreAsStaff(data.inviteCode);
+ const { storeId } = await joinStoreAsStaff(data.inviteCode);
+
+ const stores = await fetchStaffStores();
+ const joinedStore = stores.find((store) => store.storeId === storeId);
+
+ if (joinedStore) {
+ setSelectedStore(joinedStore);
+ }
navigate("/staff");
} catch (err: any) {
console.log(err);
diff --git a/src/pages/task/WeekCalendar.tsx b/src/pages/task/WeekCalendar.tsx
new file mode 100644
index 0000000..f6e29c6
--- /dev/null
+++ b/src/pages/task/WeekCalendar.tsx
@@ -0,0 +1,62 @@
+// src/pages/task/WeekCalendar.tsx
+import { format, startOfWeek, endOfWeek, eachDayOfInterval } from "date-fns";
+import ArrowIcon from "../../components/icons/ArrowIcon.tsx";
+import { cn } from "../../libs";
+
+interface WeekCalendarProps {
+ currentDate: Date;
+ onChange: (date: Date) => void;
+ onPrevWeek: () => void;
+ onNextWeek: () => void;
+}
+
+const WeekCalendar = ({
+ currentDate,
+ onChange,
+ onPrevWeek,
+ onNextWeek,
+}: WeekCalendarProps) => {
+ const weekDays = eachDayOfInterval({
+ start: startOfWeek(currentDate),
+ end: endOfWeek(currentDate),
+ });
+
+ return (
+
+
+
+
+
+ {weekDays.map((day, index) => (
+
onChange(day)}
+ >
+ {format(day, "d") === "1" && (
+ {format(day, "M")}월
+ )}
+
+ {format(day, "d")}
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default WeekCalendar;
diff --git a/src/pages/task/boss/TaskPage.tsx b/src/pages/task/boss/TaskPage.tsx
new file mode 100644
index 0000000..4151700
--- /dev/null
+++ b/src/pages/task/boss/TaskPage.tsx
@@ -0,0 +1,217 @@
+import React, { useState, useEffect } from "react";
+import { format, addWeeks, subWeeks } from "date-fns";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import "react-calendar/dist/Calendar.css";
+import "../../../styles/taskPageCalendar.css";
+import { TaskStatus } from "../../../types/task";
+import { BossTaskAPI } from "../../../api/boss/task";
+import { useTaskFilters } from "../../../hooks/useTaskFilters";
+import useBottomSheetStore from "../../../stores/useBottomSheetStore";
+import useStoreStore from "../../../stores/storeStore";
+import TaskList from "./checklist/TaskList.tsx";
+import { useLayout } from "../../../hooks/useLayout";
+import Button from "../../../components/common/Button";
+import TaskAddForm from "./checklist/TaskAddForm.tsx";
+import { getKSTDate } from "../../../libs/date";
+import { isValidStoreId } from "../../../utils/store";
+import WeekCalendar from "../WeekCalendar.tsx";
+import TaskFilterBar from "./checklist/TaskFilterBar.tsx";
+import BossReportListTab from "./report/BossReportListTab.tsx";
+import { WorkReportItem } from "../../../types/report.ts";
+import { getBpssWorkReportsByDate } from "../../../api/boss/report.ts";
+
+const tabItems = [
+ { label: "업무", value: "task" },
+ { label: "보고사항", value: "report" },
+];
+
+const TaskPage: React.FC = () => {
+ const [currentDate, setCurrentDate] = useState(getKSTDate());
+ const [tasks, setTasks] = useState([]);
+ const [reports, setReports] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const currentTab = searchParams.get("type") || "task";
+ const navigate = useNavigate();
+
+ const { selectedStore } = useStoreStore();
+ const storeId = selectedStore?.storeId;
+ const { setBottomSheetContent, setBottomSheetOpen } = useBottomSheetStore();
+ const { filters } = useTaskFilters();
+
+ useLayout({
+ title: "업무 관리",
+ theme: "default",
+ headerVisible: true,
+ bottomNavVisible: true,
+ onBack: () => navigate("/boss"),
+ });
+
+ const fetchTasks = async () => {
+ if (!isValidStoreId(storeId)) {
+ setIsLoading(false);
+ return;
+ }
+ try {
+ setIsLoading(true);
+ const date = format(currentDate, "yyyy-MM-dd");
+ const fetchedTasks = await BossTaskAPI.getTasksByDate(storeId, date);
+ setTasks(fetchedTasks);
+ console.log(fetchedTasks);
+ } catch (error) {
+ console.error("Failed to fetch tasks:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const fetchReports = async () => {
+ if (!isValidStoreId(storeId)) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ const date = format(currentDate, "yyyy-MM-dd");
+ const fetchedReports = await getBpssWorkReportsByDate(storeId, date);
+ setReports(fetchedReports);
+ } catch (error) {
+ console.error("보고사항 조회 실패:", error);
+ }
+ };
+
+ useEffect(() => {
+ fetchTasks();
+ fetchReports();
+ }, [currentDate, storeId]);
+
+ const refetchTasks = async () => {
+ await fetchTasks();
+ };
+
+ useEffect(() => {
+ if (!searchParams.get("type")) {
+ setSearchParams({ type: "task" });
+ }
+ }, [searchParams, setSearchParams]);
+
+ const handlePrevWeek = () => {
+ setCurrentDate((prev) => subWeeks(prev, 1));
+ };
+
+ const handleNextWeek = () => {
+ setCurrentDate((prev) => addWeeks(prev, 1));
+ };
+
+ const handleTabChange = (type: string) => {
+ setSearchParams({ type });
+ };
+
+ const handleAddTask = () => {
+ setBottomSheetContent(
+ setBottomSheetOpen(false)}
+ onSuccess={refetchTasks}
+ />,
+ {
+ closeOnClickOutside: true,
+ title: "업무 추가하기",
+ },
+ );
+ };
+
+ const handleRoutineTask = () => {
+ navigate("/boss/task/routine");
+ };
+
+ const filteredTasks = tasks.filter((task) => {
+ if (filters.has("all")) return true;
+
+ let stateOk = true;
+ let typeOk = true;
+
+ const states = Array.from(filters).filter((f) => f.startsWith("state:"));
+ const types = Array.from(filters).filter((f) => f.startsWith("type:"));
+
+ if (states.length > 0) {
+ stateOk = states.some((f) => {
+ const value = f.split(":")[1];
+ return task.taskLog ? value === "COMPLETED" : value === "IN_PROGRESS";
+ });
+ }
+
+ if (types.length > 0) {
+ typeOk = types.some((f) => {
+ const value = f.split(":")[1];
+ if (value === "PHOTO") return task.isPhotoRequired === true;
+ if (value === "CHECK") return task.isPhotoRequired === false;
+ return false;
+ });
+ }
+
+ return stateOk && typeOk;
+ });
+
+ return (
+
+ {/* 탭 영역 */}
+
+ {tabItems.map((tab) => (
+ handleTabChange(tab.value)}
+ className={
+ currentTab === tab.value
+ ? "py-3 text-center body-2 text-grayscale-900 border-b-2 border-black font-semibold"
+ : "py-3 text-center body-2 text-grayscale-400"
+ }
+ >
+ {tab.label}
+
+ ))}
+
+
+ {/* 주간 캘린더 영역 */}
+
+
+ {/* 컨텐츠 영역 */}
+
+ {currentTab === "task" && (
+ <>
+
+
+ 고정 업무 관리하기
+
+
+ 업무 추가하기
+
+
+
+
+ >
+ )}
+ {currentTab === "report" && (
+
+ )}
+
+
+ );
+};
+
+export default TaskPage;
diff --git a/src/pages/task/boss/checklist/TaskAddForm.tsx b/src/pages/task/boss/checklist/TaskAddForm.tsx
new file mode 100644
index 0000000..3b03247
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskAddForm.tsx
@@ -0,0 +1,143 @@
+import { FormProvider, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import TaskStep1 from "./TaskStep1.tsx";
+import TaskStep2 from "./TaskStep2.tsx";
+import { useState } from "react";
+import {
+ getDefaultTaskAddFormValues,
+ TaskAddFormValues,
+ taskAddSchema,
+} from "../../../../schemas/useTaskAddSchema.ts";
+import { formatDateToKSTString, getKSTDate } from "../../../../libs/date.ts";
+import useStoreStore from "../../../../stores/storeStore.ts";
+import { isValidStoreId } from "../../../../utils/store.ts";
+import { toast } from "react-toastify";
+import {
+ DailyTaskRoutineRequest,
+ MonthlyTaskRoutineRequest,
+ SingleTaskRequest,
+ WeeklyTaskRoutineRequest,
+} from "../../../../types/task.ts";
+import { BossTaskAPI } from "../../../../api/boss/task.ts";
+import { toISOStringWithTime } from "../../../../utils/task.ts";
+import { addMonths } from "date-fns";
+
+interface TaskAddFormProps {
+ defaultDate?: Date;
+ onClose?: () => void;
+ onSuccess?: () => void;
+}
+
+const TaskAddForm = ({ defaultDate, onClose, onSuccess }: TaskAddFormProps) => {
+ const [step, setStep] = useState(1);
+ const startDate = defaultDate ?? getKSTDate();
+ const endDate = addMonths(startDate, 1);
+
+ const methods = useForm({
+ resolver: zodResolver(taskAddSchema),
+ defaultValues: {
+ ...getDefaultTaskAddFormValues(),
+ startDate,
+ endDate,
+ },
+ });
+
+ const { getValues } = methods;
+ const { selectedStore } = useStoreStore();
+ const storeId = selectedStore?.storeId;
+
+ const onSubmit = async () => {
+ const values = getValues();
+
+ if (!isValidStoreId(storeId)) {
+ toast.error("매장이 선택되지 않았습니다.");
+ return;
+ }
+
+ const common = {
+ title: values.title,
+ photoRequired: values.photoRequired,
+ description: values.description,
+ referenceImageUrl: values.referenceImageUrl ?? "",
+ };
+
+ try {
+ if (values.taskRoutineRepeatType === "ONCE") {
+ const payload: SingleTaskRequest = {
+ ...common,
+ taskDate: formatDateToKSTString(values.startDate),
+ startTime: toISOStringWithTime(values.startDate, values.startTime),
+ endTime: toISOStringWithTime(values.startDate, values.endTime),
+ };
+ await BossTaskAPI.createSingleTask(storeId, payload);
+ }
+
+ if (values.taskRoutineRepeatType === "DAILY") {
+ const payload: DailyTaskRoutineRequest = {
+ ...common,
+ taskRoutineRepeatType: "DAILY",
+ startDate: formatDateToKSTString(values.startDate),
+ endDate: formatDateToKSTString(values.endDate),
+ startTime: values.startTime,
+ endTime: values.endTime,
+ };
+ await BossTaskAPI.createTaskRoutine(storeId, payload);
+ }
+
+ if (values.taskRoutineRepeatType === "WEEKLY") {
+ const payload: WeeklyTaskRoutineRequest = {
+ ...common,
+ taskRoutineRepeatType: "WEEKLY",
+ startDate: formatDateToKSTString(values.startDate),
+ endDate: formatDateToKSTString(values.endDate),
+ startTime: values.startTime,
+ endTime: values.endTime,
+ repeatRule: {
+ repeatDays: values.repeatRule?.repeatDays ?? ["MONDAY"], // 최소 하나는 필요
+ },
+ };
+ await BossTaskAPI.createTaskRoutine(storeId, payload);
+ }
+
+ if (values.taskRoutineRepeatType === "MONTHLY") {
+ const payload: MonthlyTaskRoutineRequest = {
+ ...common,
+ taskRoutineRepeatType: "MONTHLY",
+ startDate: formatDateToKSTString(values.startDate),
+ endDate: formatDateToKSTString(values.endDate),
+ startTime: values.startTime,
+ endTime: values.endTime,
+ repeatRule: {
+ repeatDates: values.repeatRule?.repeatDates ?? [1], // 최소 하나는 필요
+ },
+ };
+ await BossTaskAPI.createTaskRoutine(storeId, payload);
+ }
+
+ toast.success("업무가 성공적으로 생성되었습니다.");
+ onSuccess?.();
+ onClose?.();
+ } catch (err) {
+ console.error(err);
+ toast.error("업무 생성에 실패했습니다.");
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default TaskAddForm;
diff --git a/src/pages/task/boss/checklist/TaskCard.tsx b/src/pages/task/boss/checklist/TaskCard.tsx
new file mode 100644
index 0000000..0bb6803
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskCard.tsx
@@ -0,0 +1,79 @@
+import React from "react";
+import { TaskStatus } from "../../../../types/task.ts";
+import { formatTaskTime } from "../../../../utils/task.ts";
+import Label from "../../../../components/common/Label.tsx";
+import { useNavigate } from "react-router-dom";
+
+export const TaskCard: React.FC = ({
+ taskId,
+ title,
+ isPhotoRequired,
+ startTime,
+ endTime,
+ taskLog,
+}) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/boss/task/${taskId}`);
+ };
+
+ return (
+
+
+
+ {isPhotoRequired ? (
+
+ 인증샷
+
+ ) : (
+
+ 체크
+
+ )}
+
+
{title}
+
+
+
+ {formatTaskTime(startTime)} - {formatTaskTime(endTime)}
+
+ {taskLog?.checkedStaff && (
+
+

+
+ {taskLog.checkedStaff.name}
+
+
+ )}
+
+
+
+ {taskLog ? (
+ taskLog.taskLogImageUrl ? (
+

+ ) : (
+
+ 완료
+
+ )
+ ) : (
+
+ 미완료
+
+ )}
+
+
+ );
+};
diff --git a/src/pages/task/boss/checklist/TaskDetailPage.tsx b/src/pages/task/boss/checklist/TaskDetailPage.tsx
new file mode 100644
index 0000000..8732580
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskDetailPage.tsx
@@ -0,0 +1,154 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { TaskDetail } from "../../../../types/task.ts";
+import { BossTaskAPI } from "../../../../api/boss/task.ts";
+import useStoreStore from "../../../../stores/storeStore.ts";
+import { toast } from "react-toastify";
+import Button from "../../../../components/common/Button.tsx";
+import Label from "../../../../components/common/Label.tsx";
+import { formatTaskTime } from "../../../../utils/task.ts";
+import { useLayout } from "../../../../hooks/useLayout.ts";
+
+const TaskDetailPage = () => {
+ const { taskId } = useParams();
+ const navigate = useNavigate();
+ const { selectedStore } = useStoreStore();
+ const storeId = selectedStore?.storeId;
+
+ const [task, setTask] = useState(null);
+
+ useLayout({
+ title: "업무 상세",
+ theme: "plain",
+ headerVisible: true,
+ bottomNavVisible: false,
+ onBack: () => navigate(-1),
+ rightIcon: null,
+ });
+
+ const fetchTaskDetail = async () => {
+ if (!storeId || !taskId) return;
+
+ try {
+ const data = await BossTaskAPI.getTaskDetail(storeId, Number(taskId));
+ setTask(data);
+ } catch (err) {
+ console.error("업무 상세 조회 실패", err);
+ toast.error("업무 정보를 불러오는데 실패했습니다.");
+ }
+ };
+
+ useEffect(() => {
+ fetchTaskDetail();
+ }, [storeId, taskId]);
+
+ const handleDelete = async () => {
+ if (
+ !storeId ||
+ !taskId ||
+ !window.confirm("정말로 이 업무를 삭제하시겠습니까?")
+ )
+ return;
+
+ try {
+ await BossTaskAPI.deleteTask(storeId, Number(taskId));
+ toast.success("업무가 삭제되었습니다.");
+ navigate(-1);
+ } catch (err) {
+ console.error("업무 삭제 실패", err);
+ toast.error("업무 삭제에 실패했습니다.");
+ }
+ };
+
+ if (!task) {
+ return 로딩 중...
;
+ }
+
+ return (
+
+
+
+
+
+ {task.taskLog ? "완료" : "미완료"}
+
+ {task.isPhotoRequired ? (
+
+ 인증샷
+
+ ) : (
+
+ 체크
+
+ )}
+
+
+
+ {task.taskDate}
+
+ {formatTaskTime(task.startTime)} ~{" "}
+ {formatTaskTime(task.endTime)}
+
+
+
+
{task.title}
+ {task.description && (
+
+ {task.description}
+
+ )}
+ {task.referenceImageUrl && (
+

+ )}
+
+ {task.taskLog ? (
+
+
+
작성자
+
+

+
+ {task.taskLog.checkedStaff.name}
+
+
+
+
+ {task.taskLog.taskLogImageUrl && (
+
+
인증 사진
+

+
+ )}
+
+ ) : (
+
미완료
+ )}
+
+
+
+
+ 업무 삭제
+
+
+
+ );
+};
+
+export default TaskDetailPage;
diff --git a/src/pages/task/boss/checklist/TaskFilterBar.tsx b/src/pages/task/boss/checklist/TaskFilterBar.tsx
new file mode 100644
index 0000000..1350dd7
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskFilterBar.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { useTaskFilters } from "../../../../hooks/useTaskFilters.ts";
+
+const filterButtons = [
+ { key: "all", label: "전체" },
+ { key: "state:COMPLETED", label: "완료됨" },
+ { key: "state:IN_PROGRESS", label: "미완료" },
+ { key: "type:PHOTO", label: "인증샷" },
+ { key: "type:CHECK", label: "체크" },
+];
+
+const TaskFilterBar: React.FC = () => {
+ const { filters, toggleFilter } = useTaskFilters();
+
+ const isActive = (key: string) => filters.has(key);
+
+ return (
+
+ {filterButtons.map(({ key, label }) => (
+ toggleFilter(key)}
+ className={`px-2 py-1 text-sm rounded-full border whitespace-nowrap
+ ${
+ isActive(key)
+ ? "bg-primary-100 text-primary-900 border-primary-900"
+ : "bg-white text-gray-600 border-gray-300"
+ }`}
+ >
+ {label}
+
+ ))}
+
+ );
+};
+
+export default TaskFilterBar;
diff --git a/src/pages/task/boss/checklist/TaskList.tsx b/src/pages/task/boss/checklist/TaskList.tsx
new file mode 100644
index 0000000..7519032
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskList.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { TaskStatus } from "../../../../types/task.ts";
+import { TaskCard } from "./TaskCard.tsx";
+
+interface TaskListProps {
+ tasks: TaskStatus[];
+ isLoading: boolean;
+}
+
+const TaskList: React.FC = ({ tasks, isLoading }) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (tasks.length === 0) {
+ return (
+
+ 등록된 업무가 없습니다.
+
+ );
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+};
+
+export default TaskList;
diff --git a/src/pages/task/boss/checklist/TaskRoutineCard.tsx b/src/pages/task/boss/checklist/TaskRoutineCard.tsx
new file mode 100644
index 0000000..2898393
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskRoutineCard.tsx
@@ -0,0 +1,143 @@
+import React from "react";
+import { TaskRoutine } from "../../../../types/task.ts";
+import Label from "../../../../components/common/Label.tsx";
+import { isValidStoreId } from "../../../../utils/store.ts";
+import { BossTaskAPI } from "../../../../api/boss/task.ts";
+import Button from "../../../../components/common/Button.tsx";
+import { toast } from "react-toastify";
+import { showConfirm } from "../../../../libs/showConfirm.ts";
+
+interface Props {
+ routine: TaskRoutine;
+ storeId: number | undefined;
+ fetchTaskRoutines: () => void;
+}
+
+const weekdayMap: Record = {
+ MONDAY: "월",
+ TUESDAY: "화",
+ WEDNESDAY: "수",
+ THURSDAY: "목",
+ FRIDAY: "금",
+ SATURDAY: "토",
+ SUNDAY: "일",
+};
+
+const TaskRoutineCard: React.FC = ({
+ routine,
+ storeId,
+ fetchTaskRoutines,
+}) => {
+ const {
+ id: taskRoutineId,
+ title,
+ description,
+ repeatType,
+ repeatDays,
+ repeatDates,
+ startDate,
+ endDate,
+ startTime,
+ endTime,
+ photoRequired,
+ referenceImageUrl,
+ } = routine;
+
+ const getRepeatLabel = () => {
+ switch (repeatType) {
+ case "DAILY":
+ return "매일";
+ case "WEEKLY":
+ return `매주 ${repeatDays.map((d) => weekdayMap[d]).join(", ")}`;
+ case "MONTHLY":
+ return `매달 ${repeatDates.map((d) => `${d}일`).join(", ")}`;
+ default:
+ return "-";
+ }
+ };
+
+ const handleDelete = async (deleteOption: "ALL" | "PENDING") => {
+ if (!isValidStoreId(storeId)) return;
+
+ const confirmed = await showConfirm({
+ title:
+ deleteOption === "ALL"
+ ? "모든 반복 업무를 삭제할까요?"
+ : "미완료된 반복 업무만 삭제할까요?",
+ text:
+ deleteOption === "ALL"
+ ? "해당 반복 업무에 속한 모든 업무가 삭제됩니다.\n되돌릴 수 없습니다."
+ : "이미 수행한 업무는 남기고, 미완료 업무만 삭제됩니다.",
+ icon: "warning",
+ confirmText: "삭제하기",
+ cancelText: "취소",
+ });
+
+ if (!confirmed) return;
+
+ try {
+ await BossTaskAPI.deleteTaskRoutine(storeId, taskRoutineId, deleteOption);
+ toast.success(
+ deleteOption === "ALL"
+ ? "모든 반복 업무가 삭제되었습니다."
+ : "미완료 반복 업무가 삭제되었습니다.",
+ );
+ fetchTaskRoutines();
+ } catch (err) {
+ console.error("반복 업무 삭제 실패:", err);
+ toast.error("삭제에 실패했습니다.");
+ }
+ };
+
+ return (
+
+
+
+
{title}
+
+ {photoRequired ? "인증샷" : "체크"}
+
+
+
{description}
+
+
+ 반복: {getRepeatLabel()}
+
+
+ 시간: {startTime} - {endTime}
+
+
+
+ 기간: {startDate} ~ {endDate}
+
+ {referenceImageUrl && (
+

+ )}
+
+ handleDelete("ALL")}
+ className="flex-1 text-warning h-10 border-warning"
+ >
+ 전체 삭제
+
+ handleDelete("PENDING")}
+ className="flex-1 h-10 text-orange-500 border-orange-500"
+ >
+ 미완료만 삭제
+
+
+
+
+ );
+};
+
+export default TaskRoutineCard;
diff --git a/src/pages/task/boss/checklist/TaskRoutinePage.tsx b/src/pages/task/boss/checklist/TaskRoutinePage.tsx
new file mode 100644
index 0000000..6889263
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskRoutinePage.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useState } from "react";
+import { BossTaskAPI } from "../../../../api/boss/task.ts";
+import { TaskRoutine } from "../../../../types/task.ts";
+import TaskRoutineCard from "./TaskRoutineCard.tsx";
+import { toast } from "react-toastify";
+import "react-toastify/dist/ReactToastify.css";
+import useStoreStore from "../../../../stores/storeStore.ts";
+import { isValidStoreId } from "../../../../utils/store.ts";
+import { useLayout } from "../../../../hooks/useLayout.ts";
+
+const TaskRoutinePage = () => {
+ const [taskRoutines, setTaskRoutines] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { selectedStore } = useStoreStore();
+ const storeId = selectedStore?.storeId;
+
+ useLayout({
+ title: "반복 업무 관리",
+ theme: "plain",
+ headerVisible: true,
+ bottomNavVisible: true,
+ onBack: () => history.back(),
+ });
+
+ const fetchTaskRoutines = async () => {
+ setLoading(true);
+ setError(null);
+ if (!isValidStoreId(storeId)) {
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const routines = await BossTaskAPI.getTaskRoutines(storeId);
+ setTaskRoutines(routines);
+ } catch (err) {
+ console.error("반복 업무 조회 실패:", err);
+ setError("반복 업무를 불러오는 데 실패했습니다.");
+ toast.error("반복 업무를 불러오는 데 실패했습니다.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchTaskRoutines();
+ }, [storeId]);
+
+ if (loading) return 불러오는 중...
;
+ if (error) return {error}
;
+
+ return (
+
+ {taskRoutines.length === 0 ? (
+
등록된 반복 업무가 없습니다.
+ ) : (
+ taskRoutines.map((routine) => (
+
+ ))
+ )}
+
+ );
+};
+
+export default TaskRoutinePage;
diff --git a/src/pages/task/boss/checklist/TaskStep1.tsx b/src/pages/task/boss/checklist/TaskStep1.tsx
new file mode 100644
index 0000000..eb84a9c
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskStep1.tsx
@@ -0,0 +1,67 @@
+import { useFormContext } from "react-hook-form";
+import TextField from "../../../../components/common/TextField.tsx";
+import Button from "../../../../components/common/Button.tsx";
+
+interface Props {
+ onNext: () => void;
+}
+
+const MAX_TITLE_LENGTH = 32;
+
+const TaskStep1 = ({ onNext }: Props) => {
+ const {
+ register,
+ watch,
+ formState: { errors },
+ } = useFormContext();
+ const title = watch("title");
+ const description = watch("description");
+
+ const isValid = !!title?.trim() && !!description?.trim();
+
+ return (
+
+ {/* 업무 제목 */}
+
+
+ {/* 업무 설명 */}
+
+
+ {/* 다음 버튼 */}
+
+
+ 다음
+
+
+
+ );
+};
+
+export default TaskStep1;
diff --git a/src/pages/task/boss/checklist/TaskStep2.tsx b/src/pages/task/boss/checklist/TaskStep2.tsx
new file mode 100644
index 0000000..85d3219
--- /dev/null
+++ b/src/pages/task/boss/checklist/TaskStep2.tsx
@@ -0,0 +1,399 @@
+import { useEffect, useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+import { Camera, CheckCircle } from "lucide-react";
+import Button from "../../../../components/common/Button.tsx";
+import RangeDatePicker from "../../../../components/common/RangeDatePicker.tsx";
+import TimeInput from "../../../../components/common/TimeInput.tsx";
+import FileDropzone from "../../../../components/common/FileDropzone.tsx";
+import { TaskAddFormValues } from "../../../../schemas/useTaskAddSchema.ts";
+import { toast } from "react-toastify";
+import { isValidStoreId } from "../../../../utils/store.ts";
+import { BossTaskAPI } from "../../../../api/boss/task.ts";
+import useStoreStore from "../../../../stores/storeStore.ts";
+import { DayOfWeek, dayOfWeekList } from "../../../../types/staff.ts";
+import SingleDatePicker from "../../../../components/common/SingleDatePicker.tsx";
+import { validateImageFile } from "../../../../utils/task.ts";
+
+interface Props {
+ onBack: () => void;
+}
+
+const TaskStep2 = ({ onBack }: Props) => {
+ const {
+ control,
+ watch,
+ setValue,
+ formState: { errors, isValid },
+ } = useFormContext();
+
+ const taskRoutineRepeatType = watch("taskRoutineRepeatType");
+
+ const photoRequired = watch("photoRequired");
+
+ const { selectedStore } = useStoreStore();
+ const storeId = selectedStore?.storeId;
+
+ const [isUploading, setIsUploading] = useState(false);
+ const [previewFile, setPreviewFile] = useState(null);
+
+ const handleUpload = async (
+ file: File | null,
+ onChange: (val: string) => void,
+ ) => {
+ if (!file) {
+ setPreviewFile(null);
+ onChange("");
+ return;
+ }
+
+ if (!validateImageFile(file)) {
+ toast.error(
+ "유효하지 않은 이미지입니다. JPG, PNG, GIF 형식만 가능하며 5MB 이하만 업로드할 수 있어요.",
+ );
+ return;
+ }
+
+ if (!isValidStoreId(storeId)) {
+ toast.error("매장 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ const extension = file.name.split(".").pop()!;
+ const contentType = file.type;
+
+ try {
+ setIsUploading(true);
+ const { uploadUrl, publicUrl } =
+ await BossTaskAPI.getReferenceImageUploadUrl(
+ storeId,
+ extension,
+ contentType,
+ );
+
+ await BossTaskAPI.uploadReferenceImage(uploadUrl, file);
+
+ setPreviewFile(file);
+ onChange(publicUrl);
+ toast.success("이미지가 업로드되었습니다.");
+ } catch {
+ toast.error("이미지 업로드에 실패했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (taskRoutineRepeatType === "WEEKLY") {
+ setValue("repeatRule", { repeatDays: ["MONDAY"] });
+ } else if (taskRoutineRepeatType === "MONTHLY") {
+ setValue("repeatRule", { repeatDates: [1] });
+ } else {
+ setValue("repeatRule", {} as any);
+ }
+ }, [taskRoutineRepeatType, setValue]);
+
+ return (
+
+ {/* 인증 방식 */}
+
+
+ 인증 방식
+ *
+
+
+ }
+ onClick={() => setValue("photoRequired", false)}
+ >
+ 체크 인증
+
+ }
+ onClick={() => setValue("photoRequired", true)}
+ >
+ 사진 인증
+
+
+
+
+ {photoRequired && (
+
+
+ 참고 이미지
+
+ (
+ handleUpload(file, field.onChange)}
+ placeholder={
+ isUploading ? "업로드 중..." : "예시 사진을 업로드하세요"
+ }
+ />
+ )}
+ />
+
+ )}
+
+ {/* 반복 설정 */}
+
+
+ 반복 설정
+ *
+
+
+ {["ONCE", "DAILY", "WEEKLY", "MONTHLY"].map((type) => (
+
+ setValue(
+ "taskRoutineRepeatType",
+ type as TaskAddFormValues["taskRoutineRepeatType"],
+ )
+ }
+ >
+ {type === "ONCE"
+ ? "하루"
+ : type === "DAILY"
+ ? "매일"
+ : type === "WEEKLY"
+ ? "매주"
+ : "매달"}
+
+ ))}
+
+
+
+ {/* 요일 선택 (주간 반복인 경우에만 표시) */}
+ {taskRoutineRepeatType === "WEEKLY" && (
+
+
+ 반복 요일 선택
+ *
+
+ {
+ const value = (field.value ?? []) as DayOfWeek[];
+ const toggleDay = (day: DayOfWeek) => {
+ if (value.includes(day)) {
+ field.onChange(value.filter((d) => d !== day));
+ } else {
+ field.onChange([...value, day]);
+ }
+ };
+
+ return (
+
+ {dayOfWeekList.map((day) => (
+ toggleDay(day)}
+ className="p-0"
+ >
+ {day === "SUNDAY"
+ ? "일"
+ : day === "MONDAY"
+ ? "월"
+ : day === "TUESDAY"
+ ? "화"
+ : day === "WEDNESDAY"
+ ? "수"
+ : day === "THURSDAY"
+ ? "목"
+ : day === "FRIDAY"
+ ? "금"
+ : "토"}
+
+ ))}
+
+ );
+ }}
+ />
+
+ )}
+ {/* 반복 날짜 선택 (월 반복일 경우) */}
+ {taskRoutineRepeatType === "MONTHLY" && (
+
+
+ 반복 날짜 선택
+ *
+
+ {
+ const value = (field.value ?? []) as number[];
+ const toggleDate = (date: number) => {
+ if (value.includes(date)) {
+ field.onChange(value.filter((d) => d !== date));
+ } else {
+ field.onChange([...value, date]);
+ }
+ };
+
+ return (
+
+
+
+ {value.length === 0 ? (
+
+ 선택된 날짜 없음
+
+ ) : (
+ value
+ .sort((a, b) => a - b)
+ .map((d) => (
+
+ {d}일
+
+ ))
+ )}
+
+
+ {Array.from({ length: 31 }, (_, i) => i + 1).map(
+ (date) => (
+ toggleDate(date)}
+ className={`text-sm border rounded-full px-2 py-2.5 hover:bg-primary-light ${
+ value.includes(date)
+ ? "bg-gray-100 text-black"
+ : "bg-white text-black"
+ }`}
+ >
+ {date}
+
+ ),
+ )}
+
+
+
+ );
+ }}
+ />
+
+ )}
+
+ {/* 기간 설정 */}
+ {taskRoutineRepeatType === "ONCE" ? (
+
+
+ 업무 날짜
+ *
+
+ (
+
+ )}
+ />
+
+ ) : (
+
+
+ 기간 설정
+ *
+
+ (
+ {
+ if (start) onChange(start);
+ if (end) setValue("endDate", end);
+ }}
+ mode="future"
+ />
+ )}
+ />
+
+ )}
+
+ {/* 수행 시간 */}
+
+
+ 수행 시간
+ *
+
+
+ (
+
+ )}
+ />
+ ~
+ (
+
+ )}
+ />
+
+ {(errors.startTime || errors.endTime) && (
+ 시간을 모두 입력해주세요.
+ )}
+
+
+ {/* 고정 버튼 */}
+
+
+
+ 이전
+
+
+ 업무 추가하기
+
+
+
+
+ );
+};
+
+export default TaskStep2;
diff --git a/src/pages/task/boss/report/BossReportCard.tsx b/src/pages/task/boss/report/BossReportCard.tsx
new file mode 100644
index 0000000..9d900eb
--- /dev/null
+++ b/src/pages/task/boss/report/BossReportCard.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import { format } from "date-fns";
+import { WorkReportItem } from "../../../../types/report";
+
+const formatDateTime = (dateTime: string) => {
+ const date = new Date(dateTime);
+ return format(date, "yyyy-MM-dd HH:mm");
+};
+
+const BossReportCard: React.FC = ({
+ workReportId,
+ content,
+ reportImageUrl,
+ createdAt,
+ staff,
+}) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/boss/report/${workReportId}`);
+ };
+
+ return (
+
+
+
{content}
+
+

+
{staff.name}
+
|
+
+ {formatDateTime(createdAt)}
+
+
+
+ {reportImageUrl && (
+

+ )}
+
+ );
+};
+
+export default BossReportCard;
diff --git a/src/pages/task/boss/report/BossReportDetailPage.tsx b/src/pages/task/boss/report/BossReportDetailPage.tsx
new file mode 100644
index 0000000..9372307
--- /dev/null
+++ b/src/pages/task/boss/report/BossReportDetailPage.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { toast } from "react-toastify";
+import { useLayout } from "../../../../hooks/useLayout";
+import { format } from "date-fns";
+import { WorkReportItem } from "../../../../types/report";
+import useStoreStore from "../../../../stores/storeStore";
+import { isValidStoreId } from "../../../../utils/store";
+import { getBossWorkReportDetail } from "../../../../api/boss/report.ts";
+
+const formatDateTime = (datetime: string) => {
+ const date = new Date(datetime);
+ return format(date, "yyyy-MM-dd HH:mm");
+};
+
+const BossReportDetailPage = () => {
+ const { workReportId } = useParams();
+ const { selectedStore } = useStoreStore();
+ const storeId = selectedStore?.storeId;
+ const navigate = useNavigate();
+ const [report, setReport] = useState(null);
+
+ useLayout({
+ title: "보고사항 상세",
+ theme: "plain",
+ headerVisible: true,
+ bottomNavVisible: false,
+ onBack: () => navigate(-1),
+ rightIcon: null,
+ });
+
+ const fetchReport = async () => {
+ if (!workReportId || !isValidStoreId(storeId)) return;
+ try {
+ const data = await getBossWorkReportDetail(storeId, Number(workReportId));
+ setReport(data);
+ } catch (err) {
+ console.error("보고사항 상세 조회 실패", err);
+ toast.error("보고사항을 불러오지 못했습니다.");
+ }
+ };
+
+ useEffect(() => {
+ fetchReport();
+ }, [workReportId]);
+
+ if (!report) {
+ return 로딩 중...
;
+ }
+
+ return (
+
+
+
+

+
{report.staff.name}
+
+ {formatDateTime(report.createdAt)}
+
+
+
+ {report.content}
+
+
+
+ {report.reportImageUrl && (
+

+ )}
+
+ );
+};
+
+export default BossReportDetailPage;
diff --git a/src/pages/task/boss/report/BossReportListTab.tsx b/src/pages/task/boss/report/BossReportListTab.tsx
new file mode 100644
index 0000000..8c23c38
--- /dev/null
+++ b/src/pages/task/boss/report/BossReportListTab.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { WorkReportItem } from "../../../../types/report";
+import BossReportCard from "./BossReportCard";
+
+interface BossReportListTabProps {
+ reports: WorkReportItem[];
+ isLoading: boolean;
+}
+
+const BossReportListTab: React.FC = ({
+ reports,
+ isLoading,
+}) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (reports.length === 0) {
+ return (
+
+ 등록된 보고사항이 없습니다.
+
+ );
+ }
+
+ return (
+
+ {reports.map((report) => (
+
+ ))}
+
+ );
+};
+
+export default BossReportListTab;
diff --git a/src/pages/task/staff/StaffTaskPage.tsx b/src/pages/task/staff/StaffTaskPage.tsx
new file mode 100644
index 0000000..49e6ecc
--- /dev/null
+++ b/src/pages/task/staff/StaffTaskPage.tsx
@@ -0,0 +1,201 @@
+import React, { useState, useEffect } from "react";
+import {
+ format,
+ startOfWeek,
+ endOfWeek,
+ addWeeks,
+ subWeeks,
+ eachDayOfInterval,
+} from "date-fns";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import "react-calendar/dist/Calendar.css";
+import "../../../styles/taskPageCalendar.css";
+import { getTasksByDate } from "../../../api/staff/task";
+import { useLayout } from "../../../hooks/useLayout";
+import { cn } from "../../../libs";
+import ArrowIcon from "../../../components/icons/ArrowIcon";
+import { toast } from "react-toastify";
+import { getKSTDate } from "../../../libs/date.ts";
+import { TaskStatus } from "../../../types/task.ts";
+import StaffCheckListTab from "./checklist/StaffCheckListTab.tsx";
+import { getStaffWorkReportsByDate } from "../../../api/staff/report.ts";
+import { WorkReportItem } from "../../../types/report.ts";
+import StaffReportListTab from "./report/StaffReportListTab.tsx";
+import { isValidStoreId } from "../../../utils/store.ts";
+import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts";
+import Button from "../../../components/common/Button.tsx";
+import modalStore from "../../../stores/modalStore.ts";
+import ReportAddModalContent from "./report/ReportAddModalContent.tsx";
+
+const tabItems = [
+ { label: "업무", value: "task" },
+ { label: "보고사항", value: "report" },
+];
+
+const StaffTaskPage: React.FC = () => {
+ const [currentDate, setCurrentDate] = useState(getKSTDate());
+ const [tasks, setTasks] = useState([]);
+ const [reports, setReports] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const currentTab = searchParams.get("type") || "task";
+ const navigate = useNavigate();
+
+ const { selectedStore } = useStaffStoreStore();
+ const storeId = selectedStore?.storeId;
+ const { setModalOpen, setModalContent } = modalStore();
+
+ useLayout({
+ title: "업무 목록",
+ theme: "default",
+ headerVisible: true,
+ bottomNavVisible: true,
+ onBack: () => navigate("/staff"),
+ });
+
+ const fetchTasks = async () => {
+ if (!isValidStoreId(storeId)) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ const date = format(currentDate, "yyyy-MM-dd");
+ const fetchedTasks = await getTasksByDate(storeId, date);
+ setTasks(fetchedTasks);
+ } catch (error) {
+ console.error("업무 목록 조회 실패:", error);
+ toast.error("업무 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ const fetchReports = async () => {
+ if (!isValidStoreId(storeId)) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ const date = format(currentDate, "yyyy-MM-dd");
+ const fetchedReports = await getStaffWorkReportsByDate(storeId, date);
+ setReports(fetchedReports);
+ } catch (error) {
+ console.error("보고사항 조회 실패:", error);
+ toast.error("보고사항을 불러오는데 실패했습니다.");
+ }
+ };
+
+ useEffect(() => {
+ fetchTasks();
+ fetchReports();
+ }, [currentDate, storeId]);
+
+ useEffect(() => {
+ if (!searchParams.get("type")) {
+ setSearchParams({ type: "task" });
+ }
+ }, [searchParams, setSearchParams]);
+
+ const handleAddReport = () => {
+ setModalContent();
+ setModalOpen(true);
+ };
+
+ const handlePrevWeek = () => {
+ setCurrentDate((prev) => subWeeks(prev, 1));
+ };
+
+ const handleNextWeek = () => {
+ setCurrentDate((prev) => addWeeks(prev, 1));
+ };
+
+ const handleTabChange = (type: string) => {
+ setSearchParams({ type });
+ };
+
+ const weekDays = eachDayOfInterval({
+ start: startOfWeek(currentDate),
+ end: endOfWeek(currentDate),
+ });
+
+ return (
+
+ {/* 탭 영역 */}
+
+ {tabItems.map((tab) => (
+ handleTabChange(tab.value)}
+ className={cn(
+ "py-3 text-center body-2",
+ currentTab === tab.value
+ ? "text-grayscale-900 border-b-2 border-black font-semibold"
+ : "text-grayscale-400",
+ )}
+ >
+ {tab.label}
+
+ ))}
+
+
+ {/* 주간 달력 영역 */}
+
+
+
+
+
+ {weekDays.map((day, index) => (
+
setCurrentDate(day)}
+ >
+
+ {format(day, "d")}
+
+
+ ))}
+
+
+
+
+
+
+ {/* 컨텐츠 영역 */}
+
+ {currentTab === "task" && (
+
+ )}
+ {currentTab === "report" && (
+ <>
+
+
+ 보고사항 추가하기
+
+
+ {/*
*/}
+
+ >
+ )}
+
+
+ );
+};
+
+export default StaffTaskPage;
diff --git a/src/pages/task/staff/checklist/StaffCheckListTab.tsx b/src/pages/task/staff/checklist/StaffCheckListTab.tsx
new file mode 100644
index 0000000..38e98fa
--- /dev/null
+++ b/src/pages/task/staff/checklist/StaffCheckListTab.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { TaskStatus } from "../../../../types/task.ts";
+import { StaffChecklistCard } from "./StaffChecklistCard.tsx";
+
+interface TaskListProps {
+ tasks: TaskStatus[];
+ isLoading: boolean;
+}
+
+const StaffCheckListTab: React.FC = ({ tasks, isLoading }) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (tasks.length === 0) {
+ return (
+
+ 등록된 업무가 없습니다.
+
+ );
+ }
+
+ return (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ );
+};
+
+export default StaffCheckListTab;
diff --git a/src/pages/task/staff/checklist/StaffChecklistCard.tsx b/src/pages/task/staff/checklist/StaffChecklistCard.tsx
new file mode 100644
index 0000000..813cf73
--- /dev/null
+++ b/src/pages/task/staff/checklist/StaffChecklistCard.tsx
@@ -0,0 +1,79 @@
+import React from "react";
+import { TaskStatus } from "../../../../types/task.ts";
+import { formatTaskTime } from "../../../../utils/task.ts";
+import Label from "../../../../components/common/Label.tsx";
+import { useNavigate } from "react-router-dom";
+
+export const StaffChecklistCard: React.FC = ({
+ taskId,
+ title,
+ isPhotoRequired,
+ startTime,
+ endTime,
+ taskLog,
+}) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/staff/task/${taskId}`);
+ };
+
+ return (
+
+
+
+ {isPhotoRequired ? (
+
+ 인증샷
+
+ ) : (
+
+ 체크
+
+ )}
+
+
{title}
+
+
+
+ {formatTaskTime(startTime)} - {formatTaskTime(endTime)}
+
+ {taskLog?.checkedStaff && (
+
+

+
+ {taskLog.checkedStaff.name}
+
+
+ )}
+
+
+
+ {taskLog ? (
+ taskLog.taskLogImageUrl ? (
+

+ ) : (
+
+ 완료
+
+ )
+ ) : (
+
+ 미완료
+
+ )}
+
+
+ );
+};
diff --git a/src/pages/task/staff/checklist/StaffChecklistDetailPage.tsx b/src/pages/task/staff/checklist/StaffChecklistDetailPage.tsx
new file mode 100644
index 0000000..8551865
--- /dev/null
+++ b/src/pages/task/staff/checklist/StaffChecklistDetailPage.tsx
@@ -0,0 +1,305 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { TaskDetail } from "../../../../types/task.ts";
+import { toast } from "react-toastify";
+import Button from "../../../../components/common/Button.tsx";
+import Label from "../../../../components/common/Label.tsx";
+import { formatTaskTime, validateImageFile } from "../../../../utils/task.ts";
+import { useLayout } from "../../../../hooks/useLayout.ts";
+import useStaffStoreStore from "../../../../stores/useStaffStoreStore.ts";
+import {
+ cancelTaskCompletion,
+ completeTask,
+ getTaskDetail,
+ getTaskLogImageUploadUrl,
+ uploadReferenceImage,
+} from "../../../../api/staff/task.ts";
+import FileDropzone from "../../../../components/common/FileDropzone.tsx";
+import { isValidStoreId } from "../../../../utils/store.ts";
+import { Controller, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { showConfirm } from "../../../../libs/showConfirm.ts";
+
+export const completeTaskSchema = z.object({
+ reportImageUrl: z
+ .string()
+ .url()
+ .nullable()
+ .refine((val) => val !== null, { message: "인증샷을 업로드해주세요." }),
+});
+
+const StaffChecklistDetailPage = () => {
+ const { taskId } = useParams();
+ const navigate = useNavigate();
+ const { selectedStore } = useStaffStoreStore();
+ const storeId = selectedStore?.storeId;
+ const myStaffId = selectedStore?.staff.staffId;
+
+ const [task, setTask] = useState(null);
+
+ const [isUploading, setIsUploading] = useState(false);
+ const [previewFile, setPreviewFile] = useState(null);
+
+ const {
+ handleSubmit,
+ control,
+ formState: { isValid },
+ } = useForm({
+ resolver: zodResolver(
+ task?.isPhotoRequired
+ ? completeTaskSchema
+ : z.object({ reportImageUrl: z.string().nullable() }),
+ ),
+ mode: "onChange",
+ defaultValues: {
+ reportImageUrl: null as string | null,
+ },
+ });
+
+ const handleUpload = async (
+ file: File | null,
+ onChange: (val: string) => void,
+ ) => {
+ if (!file) {
+ setPreviewFile(null);
+ onChange("");
+ return;
+ }
+
+ if (!validateImageFile(file)) {
+ toast.error(
+ "유효하지 않은 이미지입니다. JPG, PNG, GIF 형식만 가능하며 5MB 이하만 업로드할 수 있어요.",
+ );
+ return;
+ }
+
+ if (!isValidStoreId(storeId)) {
+ toast.error("매장 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ const extension = file.name.split(".").pop()!;
+ const contentType = file.type;
+
+ try {
+ setIsUploading(true);
+ const { uploadUrl, publicUrl } = await getTaskLogImageUploadUrl(
+ storeId,
+ extension,
+ contentType,
+ );
+
+ await uploadReferenceImage(uploadUrl, file);
+
+ setPreviewFile(file);
+ onChange(publicUrl);
+ toast.success("이미지가 업로드되었습니다.");
+ } catch {
+ toast.error("이미지 업로드에 실패했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ useLayout({
+ title: "업무 상세",
+ theme: "plain",
+ headerVisible: true,
+ bottomNavVisible: false,
+ onBack: () => navigate(-1),
+ rightIcon: null,
+ });
+
+ const fetchTaskDetail = async () => {
+ if (!storeId || !taskId) return;
+
+ try {
+ const data = await getTaskDetail(storeId, Number(taskId));
+ setTask(data);
+ } catch (err) {
+ console.error("업무 상세 조회 실패", err);
+ toast.error("업무 정보를 불러오는데 실패했습니다.");
+ }
+ };
+
+ useEffect(() => {
+ fetchTaskDetail();
+ }, [storeId, taskId]);
+
+ const handleComplete = handleSubmit(async (data) => {
+ if (!isValidStoreId(storeId) || !taskId) return;
+
+ try {
+ await completeTask(storeId, Number(taskId), data.reportImageUrl);
+ toast.success("업무가 완료 처리되었습니다.");
+ navigate(-1);
+ } catch (err: any) {
+ const msg =
+ err?.response?.data?.ErrorCode === "ALREADY_COMPLETED_TASK"
+ ? "이미 완료된 업무입니다."
+ : "업무 완료 처리에 실패했습니다.";
+ toast.error(msg);
+ }
+ });
+
+ const handleCancel = async () => {
+ if (!isValidStoreId(storeId) || !taskId) return;
+
+ const confirmed = await showConfirm({
+ title: "완료를 취소하시겠습니까?",
+ text: "해당 업무를 다시 미완료 상태로 되돌립니다.",
+ icon: "warning",
+ confirmText: "취소하기",
+ cancelText: "닫기",
+ });
+
+ if (!confirmed) return;
+
+ try {
+ await cancelTaskCompletion(storeId, Number(taskId));
+ toast.success("업무 완료가 취소되었습니다.");
+ fetchTaskDetail(); // 상태 다시 불러오기
+ } catch (err) {
+ console.error("업무 완료 취소 실패", err);
+ toast.error("완료 취소에 실패했습니다.");
+ }
+ };
+
+ if (!task) {
+ return 로딩 중...
;
+ }
+
+ return (
+
+
+
+
+
+ {task.taskLog ? "완료" : "미완료"}
+
+ {task.isPhotoRequired ? (
+
+ 인증샷
+
+ ) : (
+
+ 체크
+
+ )}
+
+
+
+ {task.taskDate}
+
+ {formatTaskTime(task.startTime)} ~{" "}
+ {formatTaskTime(task.endTime)}
+
+
+
+
{task.title}
+ {task.description && (
+
+ {task.description}
+
+ )}
+ {task.referenceImageUrl && (
+

+ )}
+
+ {task.taskLog ? (
+
+
+
작성자
+
+

+
+ {task.taskLog.checkedStaff.name}
+
+
+
+
+ {task.taskLog.taskLogImageUrl && (
+
+
인증 사진
+

+
+ )}
+
+ ) : (
+
+
+ 아직 업무를 수행한 알바생이 없습니다.
+
+ 업무를 완료하고 버튼을 눌러주세요.
+
+ {task.isPhotoRequired && (
+
(
+
+
handleUpload(file, field.onChange)}
+ placeholder={
+ isUploading
+ ? "업로드 중..."
+ : "완료 사진을 업로드하세요"
+ }
+ />
+ {fieldState.error && (
+
+ {fieldState.error.message}
+
+ )}
+
+ )}
+ />
+ )}
+
+ )}
+
+
+
+ {task.taskLog ? (
+ myStaffId === task.taskLog.checkedStaff.staffId && (
+
+ 완료 취소하기
+
+ )
+ ) : (
+
+ 완료하기
+
+ )}
+
+
+ );
+};
+
+export default StaffChecklistDetailPage;
diff --git a/src/pages/task/staff/report/ReportAddModalContent.tsx b/src/pages/task/staff/report/ReportAddModalContent.tsx
new file mode 100644
index 0000000..e671728
--- /dev/null
+++ b/src/pages/task/staff/report/ReportAddModalContent.tsx
@@ -0,0 +1,197 @@
+import React, { useState } from "react";
+import { useForm, Controller } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { toast } from "react-toastify";
+import modalStore from "../../../../stores/modalStore.ts";
+import useStaffStoreStore from "../../../../stores/useStaffStoreStore.ts";
+import { isValidStoreId } from "../../../../utils/store.ts";
+import {
+ createWorkReport,
+ getWorkReportImageUploadUrl,
+ uploadImageToPresignedUrl,
+} from "../../../../api/staff/report.ts";
+import FileDropzone from "../../../../components/common/FileDropzone.tsx";
+import Button from "../../../../components/common/Button.tsx";
+import {
+ RadioOff,
+ RadioSecondary,
+} from "../../../../components/icons/RadioIcon.tsx";
+
+interface ReportAddModalContentProps {
+ fetchReports: () => void;
+}
+
+const schema = z.object({
+ content: z.string().min(1, "내용을 입력해주세요."),
+ reportImageUrl: z.string().url().nullable(),
+ targetType: z.enum(["TO_BOSS", "TO_STAFF"], {
+ errorMap: () => ({ message: "대상을 선택해주세요." }),
+ }),
+});
+
+type FormValues = z.infer;
+
+const ReportAddModalContent: React.FC = ({
+ fetchReports,
+}) => {
+ const { setModalOpen } = modalStore();
+ const { selectedStore } = useStaffStoreStore();
+ const storeId = selectedStore?.storeId;
+
+ const [isUploading, setIsUploading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [previewFile, setPreviewFile] = useState(null);
+
+ const {
+ control,
+ register,
+ handleSubmit,
+ setValue,
+ formState: { errors, isValid },
+ } = useForm({
+ resolver: zodResolver(schema),
+ mode: "onChange",
+ defaultValues: {
+ content: "",
+ reportImageUrl: null,
+ targetType: "TO_STAFF",
+ },
+ });
+
+ const handleUpload = async (file: File | null) => {
+ if (!file) {
+ setPreviewFile(null);
+ setValue("reportImageUrl", null);
+ return;
+ }
+
+ if (!isValidStoreId(storeId)) {
+ toast.error("매장 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ try {
+ setIsUploading(true);
+ const extension = file.name.split(".").pop()!;
+ const contentType = file.type;
+
+ const { uploadUrl, publicUrl } = await getWorkReportImageUploadUrl(
+ storeId,
+ extension,
+ contentType,
+ );
+
+ await uploadImageToPresignedUrl(uploadUrl, file);
+
+ setPreviewFile(file);
+ setValue("reportImageUrl", publicUrl, { shouldValidate: true });
+ toast.success("이미지가 업로드되었습니다.");
+ } catch {
+ toast.error("이미지 업로드에 실패했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const onSubmit = async (data: FormValues) => {
+ if (!isValidStoreId(storeId)) return;
+
+ try {
+ setIsSubmitting(true);
+ await createWorkReport(storeId, data);
+ toast.success("보고사항이 등록되었습니다.");
+ fetchReports();
+ setModalOpen(false);
+ } catch {
+ toast.error("보고사항 등록에 실패했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default ReportAddModalContent;
diff --git a/src/pages/task/staff/report/StaffReportCard.tsx b/src/pages/task/staff/report/StaffReportCard.tsx
new file mode 100644
index 0000000..5d34ae5
--- /dev/null
+++ b/src/pages/task/staff/report/StaffReportCard.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import { format } from "date-fns";
+import { WorkReportItem } from "../../../../types/report.ts";
+
+const formatDateTime = (dateTime: string) => {
+ const date = new Date(dateTime);
+ return format(date, "yyyy-MM-dd HH:mm");
+};
+
+const StaffReportCard: React.FC = ({
+ workReportId,
+ content,
+ reportImageUrl,
+ createdAt,
+ staff,
+}) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/staff/report/${workReportId}`);
+ };
+
+ return (
+
+
+
{content}
+
+

+
{staff.name}
+
|
+
+ {formatDateTime(createdAt)}
+
+
+
+ {reportImageUrl && (
+

+ )}
+
+ );
+};
+
+export default StaffReportCard;
diff --git a/src/pages/task/staff/report/StaffReportDetailPage.tsx b/src/pages/task/staff/report/StaffReportDetailPage.tsx
new file mode 100644
index 0000000..fbf1405
--- /dev/null
+++ b/src/pages/task/staff/report/StaffReportDetailPage.tsx
@@ -0,0 +1,84 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { getStaffWorkReportDetail } from "../../../../api/staff/report";
+import { toast } from "react-toastify";
+import { useLayout } from "../../../../hooks/useLayout";
+import { format } from "date-fns";
+import { WorkReportItem } from "../../../../types/report.ts";
+import useStaffStoreStore from "../../../../stores/useStaffStoreStore.ts";
+import { isValidStoreId } from "../../../../utils/store.ts";
+
+const formatDateTime = (datetime: string) => {
+ const date = new Date(datetime);
+ return format(date, "yyyy-MM-dd HH:mm");
+};
+
+const StaffReportDetailPage = () => {
+ const { workReportId } = useParams();
+ const { selectedStore } = useStaffStoreStore();
+ const storeId = selectedStore?.storeId;
+ const navigate = useNavigate();
+ const [report, setReport] = useState(null);
+
+ useLayout({
+ title: "보고사항 상세",
+ theme: "plain",
+ headerVisible: true,
+ bottomNavVisible: false,
+ onBack: () => navigate(-1),
+ rightIcon: null,
+ });
+
+ const fetchReport = async () => {
+ if (!workReportId || !isValidStoreId(storeId)) return;
+ try {
+ const data = await getStaffWorkReportDetail(
+ storeId,
+ Number(workReportId),
+ );
+ setReport(data);
+ } catch (err) {
+ console.error("보고사항 상세 조회 실패", err);
+ toast.error("보고사항을 불러오지 못했습니다.");
+ }
+ };
+
+ useEffect(() => {
+ fetchReport();
+ }, [workReportId]);
+
+ if (!report) {
+ return 로딩 중...
;
+ }
+
+ return (
+
+
+
+

+
{report.staff.name}
+
+ {formatDateTime(report.createdAt)}
+
+
+
+ {report.content}
+
+
+
+ {report.reportImageUrl && (
+

+ )}
+
+ );
+};
+
+export default StaffReportDetailPage;
diff --git a/src/pages/task/staff/report/StaffReportListTab.tsx b/src/pages/task/staff/report/StaffReportListTab.tsx
new file mode 100644
index 0000000..5e9ba6b
--- /dev/null
+++ b/src/pages/task/staff/report/StaffReportListTab.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { WorkReportItem } from "../../../../types/report.ts";
+import StaffReportCard from "./StaffReportCard.tsx"; // 카드 컴포넌트 별도로 구성
+
+interface StaffReportListTabProps {
+ reports: WorkReportItem[];
+ isLoading: boolean;
+}
+
+const StaffReportListTab: React.FC = ({
+ reports,
+ isLoading,
+}) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (reports.length === 0) {
+ return (
+
+ 등록된 보고사항이 없습니다.
+
+ );
+ }
+
+ return (
+
+ {reports.map((report) => (
+
+ ))}
+
+ );
+};
+
+export default StaffReportListTab;
diff --git a/src/routes/AppInitializer.tsx b/src/routes/AppInitializer.tsx
index 93d4443..3d9a6a8 100644
--- a/src/routes/AppInitializer.tsx
+++ b/src/routes/AppInitializer.tsx
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom";
import { useAuthStore } from "../stores/authStore";
import { useUserStore } from "../stores/userStore";
import { fetchUserProfile } from "../api/common/user.ts";
+import { initForegroundFCM } from "../libs/fcm/messaging.tsx";
+import { requestUserPermission } from "../libs/fcm/requestPermission.ts";
const AppInitializer = () => {
const navigate = useNavigate();
@@ -16,6 +18,11 @@ const AppInitializer = () => {
try {
const user = await fetchUserProfile();
setUser(user);
+ const hasToken = localStorage.getItem("fcmToken");
+ if (!hasToken && Notification.permission !== "granted") {
+ requestUserPermission();
+ }
+ initForegroundFCM();
} catch (err) {
logout();
navigate("/login", { replace: true });
diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx
index 935c7db..4057f79 100644
--- a/src/routes/ProtectedRoute.tsx
+++ b/src/routes/ProtectedRoute.tsx
@@ -13,14 +13,23 @@ const ProtectedRoute = () => {
if (!isLoggedIn) return ;
- if (user?.role === "UNASSIGNED" && location.pathname !== "/signup") {
+ const role = user?.role;
+
+ // 아직 가입 절차가 끝나지 않은 사용자
+ if (role === "UNASSIGNED" && location.pathname !== "/signup") {
return ;
}
- if (user?.role === "BOSS" && location.pathname === "/signup") {
- return ;
- } else if (user?.role === "STAFF" && location.pathname === "/signup") {
- return ;
+ // 가입완료 후 /signup에 접근 시 해당 역할 메인으로 리다이렉트
+ if (location.pathname === "/signup") {
+ switch (role) {
+ case "BOSS":
+ return ;
+ case "STAFF":
+ return ;
+ case "ADMIN":
+ return ;
+ }
}
return ;
diff --git a/src/routes/PublicRoute.tsx b/src/routes/PublicRoute.tsx
index f2a1293..88a1999 100644
--- a/src/routes/PublicRoute.tsx
+++ b/src/routes/PublicRoute.tsx
@@ -10,15 +10,18 @@ const PublicRoute = () => {
if (isLoading) return ;
- return isLoggedIn ? (
- user?.role === "BOSS" ? (
-
- ) : (
-
- )
- ) : (
-
- );
+ if (!isLoggedIn) return ;
+
+ switch (user?.role) {
+ case "BOSS":
+ return ;
+ case "STAFF":
+ return ;
+ case "ADMIN":
+ return ;
+ default:
+ return ;
+ }
};
export default PublicRoute;
diff --git a/src/routes/RoleRoute.tsx b/src/routes/RoleRoute.tsx
index ccc2241..57562ba 100644
--- a/src/routes/RoleRoute.tsx
+++ b/src/routes/RoleRoute.tsx
@@ -5,7 +5,7 @@ import { useUserStore } from "../stores/userStore";
import FullScreenLoading from "../components/common/FullScreenLoading.tsx";
interface RoleRouteProps {
- allowedRole: "BOSS" | "STAFF";
+ allowedRole: "BOSS" | "STAFF" | "ADMIN";
}
const RoleRoute = ({ allowedRole }: RoleRouteProps) => {
diff --git a/src/schemas/accountRegisterSchema.ts b/src/schemas/accountRegisterSchema.ts
new file mode 100644
index 0000000..e2cb73f
--- /dev/null
+++ b/src/schemas/accountRegisterSchema.ts
@@ -0,0 +1,14 @@
+// src/schemas/accountRegisterSchema.ts
+import { z } from "zod";
+
+export const accountRegisterSchema = z.object({
+ bankName: z.literal("농협은행"),
+ accountNumber: z.string().min(10, "계좌번호를 정확히 입력해주세요."),
+ birthdate: z.string().regex(/^\d{8}$/, "생년월일은 8자리여야 합니다."),
+ password: z.string().regex(/^\d{4}$/, "비밀번호는 4자리 숫자여야 합니다."),
+ agreeWithdraw: z.boolean().refine((val) => val === true, {
+ message: "출금 동의가 필요합니다.",
+ }),
+});
+
+export type AccountRegisterForm = z.infer;
diff --git a/src/schemas/attendanceSchema.ts b/src/schemas/attendanceSchema.ts
index a52dc39..f8791ac 100644
--- a/src/schemas/attendanceSchema.ts
+++ b/src/schemas/attendanceSchema.ts
@@ -12,7 +12,7 @@ export const gpsSchema = z.object({
scheduleId: z.string().min(1),
latitude: z.number(),
longitude: z.number(),
- locationFetchedAt: z.string().datetime(),
+ locationFetchedAt: z.string(),
});
export const bothSchema = z.object({
@@ -21,7 +21,7 @@ export const bothSchema = z.object({
qrCode: z.string().min(1),
latitude: z.number(),
longitude: z.number(),
- locationFetchedAt: z.string().datetime(),
+ locationFetchedAt: z.string(),
});
export const schemaUnion = z.union([qrSchema, gpsSchema, bothSchema]);
diff --git a/src/schemas/payrollSettingsSchema.ts b/src/schemas/payrollSettingsSchema.ts
new file mode 100644
index 0000000..898ca18
--- /dev/null
+++ b/src/schemas/payrollSettingsSchema.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+
+export const payrollSettingsSchema = z.object({
+ autoTransferEnabled: z.boolean(),
+ transferDate: z.union([z.number(), z.null()]),
+ deductionUnit: z.enum(["ZERO_MIN", "FIVE_MIN", "TEN_MIN", "THIRTY_MIN"]),
+ commutingAllowance: z.number().min(0),
+});
+
+export type PayrollSettingsForm = z.infer;
diff --git a/src/schemas/storeSchema.ts b/src/schemas/storeSchema.ts
index d6771eb..31ac44f 100644
--- a/src/schemas/storeSchema.ts
+++ b/src/schemas/storeSchema.ts
@@ -10,13 +10,12 @@ export const storeSchema = z.object({
storeType: z.enum(["CAFE", "RESTAURANT", "CONVENIENCE_STORE"]),
latitude: z.number(),
longitude: z.number(),
+ overtimeLimit: z
+ .number({
+ required_error: "연장 근무 한도를 입력해주세요.",
+ invalid_type_error: "숫자를 입력해주세요.",
+ })
+ .min(0, "연장 근무 한도는 0 이상이어야 합니다."),
});
export type StoreFormValues = z.infer;
-
-export const storeEditSchema = storeSchema.pick({
- address: true,
- storeType: true,
-});
-
-export type StoreEditFormValues = z.infer;
diff --git a/src/schemas/useTaskAddSchema.ts b/src/schemas/useTaskAddSchema.ts
new file mode 100644
index 0000000..2cb5b12
--- /dev/null
+++ b/src/schemas/useTaskAddSchema.ts
@@ -0,0 +1,94 @@
+import { z } from "zod";
+import { getKSTDate } from "../libs/date.ts";
+import { dayOfWeekList } from "../types/staff.ts";
+
+// 공통 필드
+const baseFields = {
+ title: z
+ .string({ required_error: "업무 제목을 입력해주세요." })
+ .min(1, { message: "업무 제목은 1자 이상이어야 해요." })
+ .max(32, { message: "업무 제목은 최대 32자까지 입력할 수 있어요." })
+ .transform((v) => v.trim()),
+
+ description: z
+ .string({ required_error: "업무 설명을 입력해주세요." })
+ .min(1, { message: "업무 설명은 1자 이상이어야 해요." })
+ .transform((v) => v.trim()),
+
+ photoRequired: z.boolean(),
+
+ startTime: z.string().min(1, { message: "시작 시간을 입력해주세요." }),
+ endTime: z.string().min(1, { message: "종료 시간을 입력해주세요." }),
+
+ referenceImageUrl: z.string().optional(),
+};
+
+// ONCE: 단일 업무
+const onceSchema = z.object({
+ ...baseFields,
+ taskRoutineRepeatType: z.literal("ONCE"),
+ startDate: z.date(),
+ endDate: z.date(), // 사용하지 않지만 폼 구조상 필요
+});
+
+// DAILY: 매일 반복
+const dailySchema = z.object({
+ ...baseFields,
+ taskRoutineRepeatType: z.literal("DAILY"),
+ startDate: z.date(),
+ endDate: z.date(),
+});
+
+// WEEKLY: 주간 반복
+const weeklySchema = z.object({
+ ...baseFields,
+ taskRoutineRepeatType: z.literal("WEEKLY"),
+ startDate: z.date(),
+ endDate: z.date(),
+ repeatRule: z.object({
+ repeatDays: z
+ .array(z.enum(dayOfWeekList), { required_error: "요일을 선택해주세요." })
+ .nonempty("요일을 하나 이상 선택해주세요."),
+ }),
+});
+
+// MONTHLY: 월간 반복
+const monthlySchema = z.object({
+ ...baseFields,
+ taskRoutineRepeatType: z.literal("MONTHLY"),
+ startDate: z.date(),
+ endDate: z.date(),
+ repeatRule: z.object({
+ repeatDates: z
+ .array(z.number().min(1).max(31), {
+ required_error: "반복 날짜를 선택해주세요.",
+ })
+ .nonempty("날짜를 하나 이상 선택해주세요."),
+ }),
+});
+
+// 통합 스키마
+export const taskAddSchema = z.discriminatedUnion("taskRoutineRepeatType", [
+ onceSchema,
+ dailySchema,
+ weeklySchema,
+ monthlySchema,
+]);
+
+// 타입 정의
+export type TaskAddFormValues = z.infer & {
+ referenceImageFile?: File | null;
+};
+
+// 기본값 생성 함수
+export const getDefaultTaskAddFormValues = (): TaskAddFormValues => ({
+ title: "",
+ description: "",
+ photoRequired: false,
+ taskRoutineRepeatType: "ONCE",
+ startDate: getKSTDate(),
+ endDate: getKSTDate(),
+ startTime: "",
+ endTime: "",
+ referenceImageUrl: "",
+});
diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts
index 45f8894..376a5b4 100644
--- a/src/stores/authStore.ts
+++ b/src/stores/authStore.ts
@@ -1,6 +1,9 @@
// src/stores/authStore.ts
import { create } from "zustand";
import { useUserStore } from "./userStore";
+import { logoutFromServer } from "../api/common/auth.ts";
+import useStoreStore from "./storeStore.ts";
+import useStaffStoreStore from "./useStaffStoreStore.ts";
interface AuthState {
accessToken: string | null;
@@ -25,10 +28,27 @@ export const useAuthStore = create((set) => ({
set({ accessToken });
},
- logout: () => {
+ logout: async () => {
+ const refreshToken = localStorage.getItem("refreshToken");
+ try {
+ if (refreshToken) {
+ await logoutFromServer(refreshToken);
+ }
+ } catch (err) {
+ console.error("서버 로그아웃 실패:", err);
+ }
+
+ useUserStore.getState().clearUser();
+ useStoreStore.getState().clearSelectedStore();
+ useStaffStoreStore.getState().clearSelectedStore();
+
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
- useUserStore.getState().clearUser();
+ localStorage.removeItem("fcmToken");
+ localStorage.removeItem("pwaInstallDismissed");
+ localStorage.removeItem("selected-store");
+ localStorage.removeItem("selected-staff-store");
+
set({ accessToken: null, refreshToken: null });
},
}));
diff --git a/src/stores/storeStore.ts b/src/stores/storeStore.ts
index d3eee9c..ec966b2 100644
--- a/src/stores/storeStore.ts
+++ b/src/stores/storeStore.ts
@@ -6,6 +6,7 @@ import { StoreSummaryBoss } from "../types/store.ts";
interface StoreState {
selectedStore: StoreSummaryBoss | null;
setSelectedStore: (store: StoreSummaryBoss) => void;
+ clearSelectedStore: () => void;
}
const useStoreStore = create()(
@@ -13,9 +14,10 @@ const useStoreStore = create()(
(set) => ({
selectedStore: null,
setSelectedStore: (store) => set({ selectedStore: store }),
+ clearSelectedStore: () => set({ selectedStore: null }),
}),
{
- name: "selected-store", // localStorage key
+ name: "selected-store",
},
),
);
diff --git a/src/stores/useStaffStoreStore.ts b/src/stores/useStaffStoreStore.ts
index 2b7f82c..ded467e 100644
--- a/src/stores/useStaffStoreStore.ts
+++ b/src/stores/useStaffStoreStore.ts
@@ -6,6 +6,7 @@ import { StaffStore } from "../types/store.ts";
interface StaffStoreState {
selectedStore: StaffStore | null;
setSelectedStore: (store: StaffStore) => void;
+ clearSelectedStore: () => void;
}
const useStaffStoreStore = create()(
@@ -13,6 +14,7 @@ const useStaffStoreStore = create()(
(set) => ({
selectedStore: null,
setSelectedStore: (store) => set({ selectedStore: store }),
+ clearSelectedStore: () => set({ selectedStore: null }),
}),
{
name: "selected-staff-store", // localStorage 키
diff --git a/src/styles/taskPageCalendar.css b/src/styles/taskPageCalendar.css
new file mode 100644
index 0000000..e1578e9
--- /dev/null
+++ b/src/styles/taskPageCalendar.css
@@ -0,0 +1,53 @@
+.calendar-grid {
+ display: grid;
+ width: 100%;
+ margin: 0 auto;
+ grid-template-columns: repeat(7, 1fr);
+ text-align: center;
+ align-items: center;
+ padding: 1.3rem 0 1.2rem 0;
+}
+
+.calendar-day {
+ width: 2rem;
+ height: 2rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.calendar-day.sunday {
+ color: #ef4444;
+}
+
+.calendar-day.saturday {
+ color: #3b82f6;
+}
+
+.calendar-day .selected {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 9999px;
+ background-color: #fbc42e;
+ color: white;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.calendar-month {
+ position: absolute;
+ top: -1rem;
+ width: 1.6rem;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.6rem;
+ font-weight: 500;
+ color: white;
+ pointer-events: none;
+ background: dimgray;
+ border-radius: 9999px;
+ z-index: 10;
+}
+
diff --git a/src/types/admin.ts b/src/types/admin.ts
new file mode 100644
index 0000000..e4f2516
--- /dev/null
+++ b/src/types/admin.ts
@@ -0,0 +1,9 @@
+export interface BossStatisticsItem {
+ bossName: string;
+ storeCount: number;
+ staffCount: number;
+}
+
+export interface BossStatisticsResponse {
+ result: BossStatisticsItem[];
+}
diff --git a/src/types/attendance.ts b/src/types/attendance.ts
index 56130af..a46faf6 100644
--- a/src/types/attendance.ts
+++ b/src/types/attendance.ts
@@ -85,3 +85,10 @@ export const clockOutStatusLabelMap: Record<
EARLY_LEAVE: "조기 퇴근",
OVERTIME: "연장 근무",
};
+
+export interface StaffAttendanceEditRequest {
+ reason: string;
+ requestedClockInTime: string; // "HH:mm" 형식
+ requestedClockOutTime: string; // "HH:mm" 형식
+ requestedClockInStatus: "NORMAL" | "LATE" | "ABSENT";
+}
diff --git a/src/types/notification.ts b/src/types/notification.ts
new file mode 100644
index 0000000..ba0bc70
--- /dev/null
+++ b/src/types/notification.ts
@@ -0,0 +1,40 @@
+import { ClockInStatus } from "./calendar.ts";
+
+export interface SubstituteRequest {
+ substituteRequestId: number;
+ requesterName: string;
+ targetName: string;
+ reason: string;
+ substituteRequestState: "PENDING" | "APPROVED" | "REJECTED";
+ workDate: string; // YYYY-MM-DD
+ startTime: string; // ISO datetime
+ endTime: string; // ISO datetime
+ createdAt: string; // ISO datetime
+}
+
+export interface AttendanceEditRequest {
+ attendanceEditId: number;
+ staffName: string;
+ reason: string;
+ attendanceEditState: "PENDING" | "APPROVED" | "REJECTED";
+ workDate: string; // YYYY-MM-DD
+ originalAttendance: AttendanceRecord;
+ requestedAttendance: AttendanceRecord;
+ createdAt: string; // ISO datetime
+}
+
+export interface AttendanceRecord {
+ clockInTime: string | null;
+ clockOutTime: string | null;
+ clockInStatus: ClockInStatus;
+}
+
+export interface NotificationItem {
+ id: number;
+ title: string;
+ content: string;
+ imageUrl: string | null;
+ clickUrl: string;
+ type: "CONTRACT" | "DOCUMENT" | string; // enum 확장 가능
+ createdAt: string; // ISO datetime
+}
diff --git a/src/types/payroll.ts b/src/types/payroll.ts
index 4fad3a6..009d915 100644
--- a/src/types/payroll.ts
+++ b/src/types/payroll.ts
@@ -1,48 +1,218 @@
-export interface PayrollStaff {
- staffId: number;
- name: string;
- profileImageUrl: string;
+export interface AccountVerificationRequest {
+ bankName: "농협은행"; // 고정
+ accountNumber: string;
+ birthdate: string; // YYYYMMDD
+ password: string; // 숫자 4자리
+}
+export interface AccountVerificationResponse {
+ bankName: string;
+ accountHolder: string;
+ accountNumber: string;
+}
+
+export type DeductionUnit = "ZERO_MIN" | "FIVE_MIN" | "TEN_MIN" | "THIRTY_MIN";
+
+export interface PayrollSettingsRequest {
+ autoTransferEnabled: boolean;
+ transferDate: number | null;
+ deductionUnit: DeductionUnit;
+ commutingAllowance: number;
+}
+
+export interface BossPayrollSettingsResponse {
+ account: {
+ bankName: string;
+ accountNumber: string;
+ } | null;
+ autoTransferEnabled: boolean;
+ transferDate: number | null;
+ deductionUnit: DeductionUnit;
+ commutingAllowance: number;
+}
+
+export type TransferState = "COMPLETED" | "FAILED" | "PENDING";
+
+export interface ConfirmedTransferItem {
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ data: {
+ bankCode: string | null;
+ account: string | null;
+ month: string; // ISO date string, e.g. "2025-04-01"
+ totalTime: number;
+ netAmount: number;
+ };
+ info: {
+ payrollId: number;
+ transferState: TransferState;
+ payslipId: number | null;
+ };
+}
+
+export type WithholdingType = "INCOME_TAX" | "SOCIAL_INSURANCE" | "NONE";
+
+export interface EstimatedPayrollItem {
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ payroll: {
+ key: string | null;
+ data: {
+ staffName: string;
+ bankCode: string | null;
+ account: string | null;
+ month: string; // ISO 형식 "YYYY-MM-DD"
+ withholdingType: WithholdingType;
+ totalTime: number;
+ baseAmount: number;
+ weeklyAllowance: number;
+ totalCommutingAllowance: number;
+ totalAmount: number;
+ withholdingTax: number;
+ netAmount: number;
+ };
+ };
+}
+export interface ConfirmPayrollTargetsRequest {
+ payrollKeys: string[];
}
-export interface PayrollDetail {
- key: string;
+export interface PayslipDownloadLink {
+ url: string;
+ expiresAt: string; // ISO datetime string
+}
+
+export interface MonthlyPayrollItem {
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ data: {
+ bankCode: string | null;
+ account: string | null;
+ month: string; // ISO date string e.g. "2025-04-01"
+ totalTime: number;
+ netAmount: number;
+ };
+ info: {
+ payrollId: number;
+ transferState: TransferState;
+ payslipId: number | null;
+ } | null;
+}
+
+export interface PayrollDetailResponse {
+ data: {
+ staffName: string;
+ bankCode: string;
+ account: string;
+ month: string;
+ withholdingType: WithholdingType;
+ totalTime: number;
+ baseAmount: number;
+ weeklyAllowance: number;
+ totalCommutingAllowance: number;
+ totalAmount: number;
+ withholdingTax: number;
+ netAmount: number;
+ };
+ info: {
+ payrollId: number;
+ transferState: TransferState;
+ payslipId: number;
+ } | null;
+}
+
+export interface StaffWithholdingItem {
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ withholdingType: WithholdingType;
+}
+
+export interface UpdateWithholdingRequest {
+ withholdingType: WithholdingType;
+}
+
+export interface StaffHourlyWage {
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ hourlyWage: number;
+}
+
+export interface UpdateHourlyWageRequest {
+ hourlyWage: number;
+}
+
+/* staff */
+
+export interface StaffPayrollData {
+ staffName: string;
bankCode: string;
account: string;
- month: string;
- withholdingType: string;
+ month: string; // YYYY-MM-DD 형식
+ withholdingType: WithholdingType;
totalTime: number;
baseAmount: number;
weeklyAllowance: number;
+ totalCommutingAllowance: number;
totalAmount: number;
withholdingTax: number;
netAmount: number;
}
-export interface StaffPayroll {
- staff: PayrollStaff;
- payroll: PayrollDetail;
+export interface StaffPayrollInfo {
+ payrollId: number;
+ transferState: TransferState;
+ payslipId: number;
}
-export type DeductionUnit = "ZERO_MIN" | "FIVE_MIN" | "TEN_MIN" | "THIRTY_MIN";
+export interface StaffPayrollResponse {
+ data: StaffPayrollData;
+ info: StaffPayrollInfo | null;
+}
-export interface PayrollAccount {
- bankName: string;
- accountNumber: string;
- accountHolder: string;
+export interface PayslipDownloadResponse {
+ url: string;
+ expiresAt: string; // ISO datetime 문자열
}
-export interface PayrollSettingsResponse {
- account: PayrollAccount | null;
+export interface StaffPayrollSettingsResponse {
autoTransferEnabled: boolean;
transferDate: number | null;
- overtimeLimit: number;
deductionUnit: DeductionUnit;
+ commutingAllowance: number;
}
-export interface ConfirmPayrollTransfersRequest {
- payrollKeys: string[];
+export interface VerifyAccountRequest {
+ bankName: string;
+ accountNumber: string;
+}
+
+export interface VerifyAccountResponse {
+ bankName: string;
+ accountHolder: string;
+ accountNumber: string;
+}
+
+export interface StaffAccountInfo {
+ bankName: string | null;
+ accountHolder: string | null;
+ accountNumber: string | null;
}
-export interface ConfirmPayrollTransfersResponse {
- result: StaffPayroll[];
+export interface StaffPayrollBriefInfo {
+ hourlyWage: number;
+ withholdingType: WithholdingType;
}
diff --git a/src/types/report.ts b/src/types/report.ts
new file mode 100644
index 0000000..1aaa7ee
--- /dev/null
+++ b/src/types/report.ts
@@ -0,0 +1,29 @@
+// 보고 대상: 사장에게 보고 or 알바생에게 전달
+export type WorkReportTargetType = "TO_BOSS" | "TO_STAFF";
+
+// 보고사항 공통 구조 (리스트 + 상세 공용)
+export interface WorkReportItem {
+ workReportId: number;
+ content: string;
+ reportImageUrl: string | null;
+ createdAt: string;
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ targetType: WorkReportTargetType;
+}
+
+// 보고사항 작성 요청
+export interface CreateWorkReportRequest {
+ content: string;
+ reportImageUrl: string | null;
+ targetType: WorkReportTargetType;
+}
+
+export interface WorkReportImageUploadUrlResponse {
+ uploadUrl: string;
+ publicUrl: string;
+ expiresAt: string; // ISO datetime
+}
diff --git a/src/types/schedule.ts b/src/types/schedule.ts
index 26b43c8..a275e50 100644
--- a/src/types/schedule.ts
+++ b/src/types/schedule.ts
@@ -46,3 +46,17 @@ export interface UpdateAttendanceResponse {
clockInStatus: "NORMAL" | "LATE" | "ABSENT";
clockOutStatus: "NORMAL" | "EARLY_LEAVE" | "OVERTIME";
}
+
+export interface StaffSubstitutionRequest {
+ targetStaffId: number;
+ reason: string;
+}
+
+export interface SubstituteCandidate {
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ valid: boolean;
+}
diff --git a/src/types/store.ts b/src/types/store.ts
index eae8107..2f2706e 100644
--- a/src/types/store.ts
+++ b/src/types/store.ts
@@ -13,6 +13,7 @@ export interface UpdateStoreInfoRequest {
latitude: number;
longitude: number;
};
+ overtimeLimit: number;
}
export interface StoreSummaryBoss {
@@ -22,13 +23,7 @@ export interface StoreSummaryBoss {
storeType: "CAFE" | "RESTAURANT" | "CONVENIENCE_STORE";
address: string;
inviteCode: string;
-}
-
-export interface StoreSummaryStaff {
- storeId: number;
- storeName: string;
- storeType: "CAFE" | "RESTAURANT" | "CONVENIENCE_STORE";
- address: string;
+ overtimeLimit: number;
}
export interface ReissueInviteCodeResponse {
@@ -59,4 +54,14 @@ export interface StaffStore {
address: string;
storeType: "CAFE" | "RESTAURANT" | "CONVENIENCE_STORE";
attendanceMethod: string;
+ staff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+}
+
+export interface StoreRequestSummary {
+ requestedCount: number;
+ profileImageUrls: string[];
}
diff --git a/src/types/subscription.ts b/src/types/subscription.ts
new file mode 100644
index 0000000..fd4378c
--- /dev/null
+++ b/src/types/subscription.ts
@@ -0,0 +1,20 @@
+// src/types/subscription.ts
+
+export type SubscriptionPlanType = "PREMIUM" | null;
+export type PaymentStatus = "DONE" | "FAILED";
+
+export interface SubscriptionInfo {
+ planType: SubscriptionPlanType;
+ startedAt: string | null;
+ expiredAt: string | null;
+ nextPaymentDate: string | null;
+}
+
+export interface SubscriptionOrderHistory {
+ orderId: string;
+ planType: "PREMIUM";
+ amount: number;
+ paymentStatus: PaymentStatus;
+ failReason: string | null;
+ createdAt: string;
+}
diff --git a/src/types/task.ts b/src/types/task.ts
new file mode 100644
index 0000000..c3f7692
--- /dev/null
+++ b/src/types/task.ts
@@ -0,0 +1,147 @@
+export type TaskRoutineRepeatType = "DAILY" | "WEEKLY" | "MONTHLY";
+export type DayOfWeek =
+ | "MONDAY"
+ | "TUESDAY"
+ | "WEDNESDAY"
+ | "THURSDAY"
+ | "FRIDAY"
+ | "SATURDAY"
+ | "SUNDAY";
+
+// 단일 업무 생성 요청
+export interface SingleTaskRequest {
+ title: string;
+ description: string;
+ taskDate: string;
+ startTime: string;
+ endTime: string;
+ photoRequired: boolean;
+ referenceImageUrl?: string;
+}
+
+// 기본 반복 업무 요청 타입
+interface BaseTaskRoutineRequest {
+ title: string;
+ description: string;
+ photoRequired: boolean;
+ referenceImageUrl?: string;
+ startDate: string;
+ endDate: string;
+}
+
+// 일일 반복 업무 요청
+export interface DailyTaskRoutineRequest extends BaseTaskRoutineRequest {
+ taskRoutineRepeatType: "DAILY";
+ startTime: string;
+ endTime: string;
+}
+
+// 주간 반복 업무 요청
+export interface WeeklyTaskRoutineRequest extends BaseTaskRoutineRequest {
+ taskRoutineRepeatType: "WEEKLY";
+ repeatRule: {
+ repeatDays: DayOfWeek[];
+ };
+ startTime: string;
+ endTime: string;
+}
+
+// 월 반복 업무 요청
+export interface MonthlyTaskRoutineRequest extends BaseTaskRoutineRequest {
+ taskRoutineRepeatType: "MONTHLY";
+ repeatRule: {
+ repeatDates: number[];
+ };
+ startTime: string;
+ endTime: string;
+}
+
+// 통합 반복 업무 요청 타입
+export type TaskRoutineRequest =
+ | DailyTaskRoutineRequest
+ | WeeklyTaskRoutineRequest
+ | MonthlyTaskRoutineRequest;
+
+// 참고 사진 업로드 URL 응답
+export interface ReferenceImageUploadUrlResponse {
+ uploadUrl: string;
+ publicUrl: string;
+ expiresAt: string;
+}
+
+// 업무 상세 조회 응답
+export interface TaskDetail {
+ taskId: number;
+ title: string;
+ description: string | null;
+ taskDate: string;
+ startTime: string;
+ endTime: string;
+ isPhotoRequired: boolean;
+ referenceImageUrl: string | null;
+ taskLog: {
+ taskLogImageUrl: string | null;
+ checkedStaff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ };
+}
+
+// 반복 업무 조회 응답
+export interface TaskRoutine {
+ id: number;
+ title: string;
+ description: string;
+ repeatType: TaskRoutineRepeatType;
+ repeatDays: DayOfWeek[];
+ repeatDates: number[];
+ startDate: string;
+ endDate: string;
+ startTime: string;
+ endTime: string;
+ photoRequired: boolean;
+ referenceImageUrl: string;
+}
+
+// 날짜별 업무 상태 응답
+export interface TaskStatus {
+ taskId: number;
+ title: string;
+ description: string | null;
+ taskDate: string;
+ startTime: string;
+ endTime: string;
+ isPhotoRequired: boolean;
+ referenceImageUrl: string | null;
+ taskLog: {
+ taskLogImageUrl: string | null;
+ checkedStaff: {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+ };
+ };
+}
+
+export interface TaskLog {
+ taskLogImageUrl: string | null;
+ checkedStaff: CheckedStaff | null;
+}
+
+export interface CheckedStaff {
+ staffId: number;
+ name: string;
+ profileImageUrl: string;
+}
+
+export interface TaskUploadUrlResponse {
+ uploadUrl: string;
+ publicUrl: string;
+ expiresAt: string;
+}
+
+export interface StaffTaskListResponse {
+ result: TaskStatus[];
+}
diff --git a/src/types/user.ts b/src/types/user.ts
index f66d2c3..623c03e 100644
--- a/src/types/user.ts
+++ b/src/types/user.ts
@@ -1,6 +1,6 @@
export interface User {
userId: number;
- role: "UNASSIGNED" | "BOSS" | "STAFF";
+ role: "UNASSIGNED" | "BOSS" | "STAFF" | "ADMIN";
name: string;
phone: string;
email: string;
diff --git a/src/utils/date.ts b/src/utils/date.ts
index 52a6977..8a65902 100644
--- a/src/utils/date.ts
+++ b/src/utils/date.ts
@@ -14,6 +14,18 @@ import { getKSTDate, getKSTISOString } from "../libs/date";
export const formatFullDate = (date: Date): string =>
dayjs(date).tz("Asia/Seoul").format("YYYY-MM-DD");
+/**
+ * Date를 'MM월 DD일' 형식의 문자열로 변환하는 함수 (KST 기준)
+ */
+export const formatKRDate = (date: Date): string =>
+ dayjs(date).tz("Asia/Seoul").format("MM월 DD일");
+
+/**
+ * Date를 'YYYY년 MM월 DD일' 형식의 문자열로 변환하는 함수 (KST 기준)
+ */
+export const formatKRDateWithYear = (date: Date): string =>
+ dayjs(date).tz("Asia/Seoul").format("YYYY년 MM월 DD일");
+
/**
* Date를 '2024년 1월 1일 월요일 오전 9시 30분' 형식의 문자열로 변환 (로컬 기준이지만 한국어 표기 목적)
*/
@@ -69,7 +81,7 @@ export const getStartAndEndDates = (ym: string): [string, string] => {
*/
export const getRemainingDays = (transferDate: number): number => {
const today = getKSTDate();
- return transferDate - today.getDate();
+ return today.getDate() - transferDate;
};
/**
diff --git a/src/utils/store.ts b/src/utils/store.ts
new file mode 100644
index 0000000..86c05fb
--- /dev/null
+++ b/src/utils/store.ts
@@ -0,0 +1,10 @@
+export const isValidStoreId = (
+ storeId: number | null | undefined,
+): storeId is number => {
+ return (
+ typeof storeId === "number" &&
+ !Number.isNaN(storeId) &&
+ Number.isInteger(storeId) &&
+ storeId > 0
+ );
+};
diff --git a/src/utils/task.ts b/src/utils/task.ts
new file mode 100644
index 0000000..b51dd8c
--- /dev/null
+++ b/src/utils/task.ts
@@ -0,0 +1,58 @@
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { DayOfWeek } from "../types/task";
+import { parseDateStringToKST } from "../libs/date.ts";
+
+export const formatTaskTime = (time: string): string => {
+ return format(parseDateStringToKST(time), "HH:mm");
+};
+
+export const formatTaskDate = (date: string): string => {
+ return format(parseDateStringToKST(date), "M월 d일", { locale: ko });
+};
+
+export const getDayOfWeekText = (day: DayOfWeek): string => {
+ switch (day) {
+ case "MONDAY":
+ return "월요일";
+ case "TUESDAY":
+ return "화요일";
+ case "WEDNESDAY":
+ return "수요일";
+ case "THURSDAY":
+ return "목요일";
+ case "FRIDAY":
+ return "금요일";
+ case "SATURDAY":
+ return "토요일";
+ case "SUNDAY":
+ return "일요일";
+ }
+};
+
+export const getFileExtensionFromType = (contentType: string): string => {
+ switch (contentType) {
+ case "image/jpeg":
+ return "jpg";
+ case "image/png":
+ return "png";
+ case "image/gif":
+ return "gif";
+ default:
+ return "jpg";
+ }
+};
+
+export const validateImageFile = (file: File): boolean => {
+ const validTypes = ["image/jpeg", "image/png", "image/gif"];
+ const maxSize = 5 * 1024 * 1024; // 5MB
+
+ return validTypes.includes(file.type) && file.size <= maxSize;
+};
+
+export const toISOStringWithTime = (date: Date, time: string): string => {
+ const [hour, minute] = time.split(":");
+ const result = new Date(date);
+ result.setHours(Number(hour), Number(minute), 0, 0);
+ return result.toISOString();
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
index b42930f..e9a0ce6 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -6,6 +6,8 @@ const config: Config = {
extend: {
animation: {
"spin-one-time": "spin 1s linear",
+ "slide-up": "slideUp 0.3s ease-out",
+ "slide-down": "slideDown 0.3s ease-out",
},
screens: {
mobile: "320px",
@@ -71,6 +73,16 @@ const config: Config = {
"input-box": "2px 2px 8px 0px rgba(0, 0, 0, 0.25)",
"layout-box": "0px 8px 36px 0px rgba(0, 0, 0, 0.15)",
},
+ keyframes: {
+ slideUp: {
+ "0%": { opacity: "0", transform: "translateY(8px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ slideDown: {
+ "0%": { opacity: "0", transform: "translateY(-8px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ },
},
fontFamily: {
sans: ["Pretendard Variable", "Pretendard", "sans-serif"],