Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/api/test/organization.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ describe("organization routes", () => {
});
});

it("rejects organization business hours outside the 30-minute boundary", async () => {
const { prisma } = createFakePrisma();
const app = createApp({ prisma });

const response = await request(app)
.post("/api/organization")
.set("Authorization", authHeader())
.send({
name: "프래그먼트 카페",
businessHours: createBusinessHoursInput({
MON: {
openTime: "09:15",
},
}),
});

expect(response.status).toBe(400);
expect(response.body).toMatchObject({
errorCode: "VALIDATION_ERROR",
statusCode: 400,
});
});

it("creates an organization and derives overnight business hours", async () => {
const { prisma } = createFakePrisma();
const app = createApp({ prisma });
Expand Down
122 changes: 33 additions & 89 deletions apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import Link from "next/link";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { OrganizationDetail, ScheduleAssignment } from "@fragment/shared";
import { Button } from "@moyeorak/design-system";
import { Building2, CalendarX, Clock, Download, Pencil } from "lucide-react";
Expand All @@ -10,8 +10,11 @@ import { AdminPageShell } from "@/components/layout/admin-page-shell";
import { Badge } from "@/components/ui/badge";
import { useDashboardQuery } from "@/features/dashboard/queries/dashboard-queries";
import { useExportScheduleHistoryCsvMutation } from "@/features/schedule-history/queries/schedule-history-queries";
import {
ConfirmedScheduleCalendar,
type ConfirmedScheduleCalendarItem,
} from "@/features/schedules/components/confirmed-schedule-calendar";
import { getApiErrorMessage } from "@/lib/api-error-message";
import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display";

const DAY_LABELS = {
MON: "월",
Expand All @@ -22,38 +25,6 @@ const DAY_LABELS = {
SAT: "토",
SUN: "일",
} as const;
const WEEKDAY_LABELS = Object.values(DAY_LABELS);

function addDays(date: string, days: number) {
const [year, month, day] = date.split("-").map(Number);
const nextDate = new Date(Date.UTC(year, month - 1, day + days));
return nextDate.toISOString().slice(0, 10);
}

function createDateRange(startDate: string, endDate: string) {
const dates: string[] = [];
let currentDate = startDate;

while (currentDate <= endDate) {
dates.push(currentDate);
currentDate = addDays(currentDate, 1);
}

return dates;
}

function getMonday(date: string) {
const [year, month, day] = date.split("-").map(Number);
const targetDate = new Date(Date.UTC(year, month - 1, day));
const dayIndex = targetDate.getUTCDay();
const diff = dayIndex === 0 ? -6 : 1 - dayIndex;

return addDays(date, diff);
}

function createCalendarDates(startDate: string, endDate: string) {
return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6));
}

function formatScheduleRange(startDate: string, endDate: string) {
return `${startDate} ~ ${endDate}`;
Expand All @@ -75,6 +46,24 @@ function downloadBlob(blob: Blob, fileName: string) {
URL.revokeObjectURL(url);
}

function formatAssignmentTime(workDate: string, dateTime: string) {
const time = dateTime.slice(11, 16);

return dateTime.slice(0, 10) > workDate ? `${time}+1` : time;
}

function scheduleAssignmentToCalendarItem(
assignment: ScheduleAssignment,
): ConfirmedScheduleCalendarItem {
return {
date: assignment.workDate,
endTime: formatAssignmentTime(assignment.workDate, assignment.endsAt),
id: assignment.id,
startTime: formatAssignmentTime(assignment.workDate, assignment.startsAt),
workerName: assignment.workerNameSnapshot,
};
}

function formatBusinessHourRange(businessHour: OrganizationDetail["businessHours"][number]) {
if (!businessHour.openTime || !businessHour.closeTime) {
return "운영 시간 미설정";
Expand Down Expand Up @@ -127,9 +116,10 @@ export function MvpDashboardPage() {
const [exportMessage, setExportMessage] = useState("");
const organization = dashboardQuery.data?.organization;
const latestConfirmedSchedule = dashboardQuery.data?.latestConfirmedSchedule ?? null;
const calendarDates = latestConfirmedSchedule
? createCalendarDates(latestConfirmedSchedule.startDate, latestConfirmedSchedule.endDate)
: [];
const latestConfirmedSchedules = useMemo(
() => latestConfirmedSchedule?.assignments.map(scheduleAssignmentToCalendarItem) ?? [],
[latestConfirmedSchedule],
);
const organizationName = dashboardQuery.isPending
? "조직 정보를 불러오는 중입니다"
: dashboardQuery.isError
Expand Down Expand Up @@ -255,7 +245,7 @@ export function MvpDashboardPage() {
<div className="flex flex-col gap-4 border-b border-border px-6 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-base font-semibold text-foreground">확정 스케줄 달력</h2>
<h2 className="text-base font-semibold text-foreground">최근 확정 스케줄</h2>
</div>
{dashboardQuery.isPending ? (
<p className="mt-2 text-sm text-muted-foreground">
Expand Down Expand Up @@ -303,57 +293,11 @@ export function MvpDashboardPage() {
<p className="mt-2 text-sm text-muted-foreground">잠시 후 다시 시도해 주세요.</p>
</div>
) : latestConfirmedSchedule ? (
<div className="overflow-x-auto p-6">
<div className="grid min-w-3xl grid-cols-7 border-l border-t border-border">
{WEEKDAY_LABELS.map((weekday) => (
<div
key={weekday}
className="border-b border-r border-border bg-surface-secondary px-3 py-2 text-center text-xs font-semibold text-muted-foreground"
>
{weekday}
</div>
))}
{calendarDates.map((date) => {
const inRange =
date >= latestConfirmedSchedule.startDate &&
date <= latestConfirmedSchedule.endDate;
const dateSchedules = latestConfirmedSchedule.assignments.filter(
(assignment) => assignment.workDate === date,
);

return (
<div
key={date}
className={
inRange
? "min-h-36 border-b border-r border-border bg-card p-3"
: "min-h-36 border-b border-r border-border bg-surface-secondary p-3 opacity-45"
}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-bold text-foreground">{date.slice(8, 10)}</p>
{inRange ? (
<span className="text-xs text-muted-foreground">
{dateSchedules.length}명
</span>
) : null}
</div>
<div className="mt-3 space-y-1">
{dateSchedules.map((assignment: ScheduleAssignment) => (
<p
key={assignment.id}
className="truncate text-xs font-medium text-foreground"
>
{formatScheduleAssignmentTimeRange(assignment)}{" "}
{assignment.workerNameSnapshot}
</p>
))}
</div>
</div>
);
})}
</div>
</div>
<ConfirmedScheduleCalendar
startDate={latestConfirmedSchedule.startDate}
endDate={latestConfirmedSchedule.endDate}
schedules={latestConfirmedSchedules}
/>
) : (
<div className="px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">확정된 스케줄이 없습니다.</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function MvpOrganizationEditPage() {
cancelHref="/dashboard"
initialValues={formValues}
submitError={submitError}
submitLabel="변경사항 저장"
submitLabel="저장"
submittingLabel="저장 중"
onSubmit={async (request) => {
setSubmitError(null);
Expand Down
86 changes: 66 additions & 20 deletions apps/web/src/features/organization/components/organization-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
import Link from "next/link";
import { createOrganizationRequestSchema } from "@fragment/shared";
import { Button } from "@moyeorak/design-system";
import { type FieldErrors, type Resolver, useForm } from "react-hook-form";
import { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import {
createOrganizationRequestFromForm,
Expand All @@ -24,6 +31,21 @@ type OrganizationFormProps = {
submittingLabel: string;
};

const TIME_OPTION_STEP_MINUTES = 30;
const MINUTES_IN_DAY = 24 * 60;

const TIME_OPTIONS = Array.from(
{ length: MINUTES_IN_DAY / TIME_OPTION_STEP_MINUTES },
(_, index) => {
const minutes = index * TIME_OPTION_STEP_MINUTES;
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const value = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;

return { label: value, value };
},
);

Comment on lines +34 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

기존 저장 운영 시간이 편집 불가능해질 수 있습니다.

Line 37의 TIME_OPTIONS가 30분 단위로만 고정되면서, 기존 input[type="time"]나 API를 통해 저장된 09:15 같은 값은 수정 화면에서 SelectItem으로 표현되지 않습니다. 이 PR은 shared/API 스키마를 바꾸지 않았기 때문에, 현재는 합법적인 저장값을 웹 폼이 round-trip 하지 못하는 상태입니다.

최소한 기존 값이 옵션에 없으면 임시로 주입해서 표시·재저장 가능하게 하거나, 30분 제한을 도입하려면 shared schema/API validation/backfill까지 같이 맞춰야 합니다.

가능한 국소 수정 예시
 const TIME_OPTIONS = Array.from(
   { length: MINUTES_IN_DAY / TIME_OPTION_STEP_MINUTES },
   (_, index) => {
     const minutes = index * TIME_OPTION_STEP_MINUTES;
     const hour = Math.floor(minutes / 60);
     const minute = minutes % 60;
     const value = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;

     return { label: value, value };
   },
 );
+
+function getTimeOptions(currentValue?: string | null) {
+  if (!currentValue || TIME_OPTIONS.some((option) => option.value === currentValue)) {
+    return TIME_OPTIONS;
+  }
+
+  return [...TIME_OPTIONS, { label: currentValue, value: currentValue }].sort((a, b) =>
+    a.value.localeCompare(b.value),
+  );
+}
- {TIME_OPTIONS.map((option) => (
+ {getTimeOptions(field.value).map((option) => (
     <SelectItem key={option.value} value={option.value}>
       {option.label}
     </SelectItem>
   ))}

As per path instructions, "SPEC의 입력 검증(필수값, 이메일 형식, 비밀번호 확인, 날짜 범위, 시작/종료 시간, 1명 이상/1 이상의 숫자)을 UI와 schema가 함께 처리하는지 확인".

Also applies to: 221-263

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/features/organization/components/organization-form.tsx` around
lines 34 - 48, TIME_OPTIONS is fixed to 30-minute slots, so existing saved times
like 09:15 cannot be rendered or re-saved in the organization form. Update
organization-form.tsx around TIME_OPTIONS and the SelectItem usage so the
current value is always included even if it is not on the 30-minute grid, or
else align the shared/API schema and validation to enforce 30-minute increments
everywhere. Make sure the form round-trips existing values in the organization
form component without breaking editability.

Source: Path instructions

const organizationFormResolver: Resolver<OrganizationFormValues> = (values) => {
const request = createOrganizationRequestFromForm(values);
const result = createOrganizationRequestSchema.safeParse(request);
Expand Down Expand Up @@ -85,6 +107,7 @@ export function OrganizationForm({
submittingLabel,
}: OrganizationFormProps) {
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
register,
Expand Down Expand Up @@ -124,13 +147,6 @@ export function OrganizationForm({
className="rounded-xl border border-border bg-card p-6 shadow-card"
onSubmit={handleSubmit(submitForm)}
>
<div>
<h2 className="text-lg font-semibold text-foreground">조직 정보</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
휴무일로 지정한 요일은 운영 시간 입력이 비활성화됩니다.
</p>
</div>

<div className="mt-8 space-y-8">
<div className="space-y-2">
<Label htmlFor="organization-name">조직명</Label>
Expand Down Expand Up @@ -202,19 +218,49 @@ export function OrganizationForm({

<div>
<div className="grid gap-3 sm:grid-cols-2">
<Input
type="time"
step={1800}
disabled={closed}
aria-label={`${day.label} 운영 시작 시간`}
{...register(`businessHours.${day.value}.openTime`)}
<Controller
control={control}
name={`businessHours.${day.value}.openTime`}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
disabled={closed}
>
<SelectTrigger aria-label={`${day.label} 운영 시작 시간`}>
<SelectValue placeholder="시작 시간" />
</SelectTrigger>
<SelectContent>
{TIME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<Input
type="time"
step={1800}
disabled={closed}
aria-label={`${day.label} 운영 종료 시간`}
{...register(`businessHours.${day.value}.closeTime`)}
<Controller
control={control}
name={`businessHours.${day.value}.closeTime`}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
disabled={closed}
>
<SelectTrigger aria-label={`${day.label} 운영 종료 시간`}>
<SelectValue placeholder="종료 시간" />
</SelectTrigger>
<SelectContent>
{TIME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
{dayError ? <p className="mt-2 text-sm text-destructive">{dayError}</p> : null}
Expand Down
Loading