fix(WEB): 모바일 반응형 UI 개선 및 개소 정보 수정 모달 추가

- CalendarHeader: 모바일에서 주/월 버튼 2줄 레이아웃으로 분리
- MobileCard: 제목 텍스트 overflow 시 truncate 적용
- DetailActions: 모바일 하단 sticky 버튼 바 overflow 수정
- OrderDetailForm: 모바일 하단 sticky 버튼 바 overflow 수정
- LocationDetailPanel: 오픈사이즈 옆 수정 버튼에 모달 연결
- LocationListPanel: 개소 목록에 수정/삭제 버튼 추가
- LocationEditModal: 개소 정보 수정 팝업 신규 생성

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-27 11:28:12 +09:00
parent 6586db4996
commit 55e92bc7b4
8 changed files with 441 additions and 88 deletions

View File

@@ -121,17 +121,18 @@ export default function OrderDetailForm({
}, [orderId, router]);
// 커스텀 헤더 액션 (view 모드에서 발주서 보기, 복제 버튼)
// 모바일: 아이콘만, sm 이상: 아이콘 + 텍스트
const customHeaderActions = useMemo(() => {
if (!isViewMode) return null;
return (
<>
<Button variant="outline" onClick={handleViewDocument}>
<Eye className="h-4 w-4 mr-2" />
<Button variant="outline" onClick={handleViewDocument} size="sm" className="md:size-default">
<Eye className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
<Button variant="outline" onClick={handleDuplicate} disabled={isLoading}>
<Copy className="h-4 w-4 mr-2" />
<Button variant="outline" onClick={handleDuplicate} disabled={isLoading} size="sm" className="md:size-default">
<Copy className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
</>
);

View File

@@ -26,24 +26,75 @@ export function CalendarHeader({
{ value: 'month', label: '월' },
];
return (
<div className="flex flex-col gap-3 pb-3 border-b">
{/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */}
{/* 모바일: 타이틀 / 네비게이션 + 뷰전환 / 필터 (세 줄) */}
// 뷰 전환 버튼 렌더링 (재사용)
const renderViewTabs = (className?: string) => (
<div className={cn('flex rounded-md border', className)}>
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-3 py-1 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
>
{v.label}
</button>
))}
</div>
);
{/* 1줄(모바일) / 좌측(PC): 타이틀 */}
return (
<div className="flex flex-col gap-2 pb-3 border-b">
{/* 모바일 전용: 타이틀 */}
{titleSlot && (
<div className="xl:hidden text-base font-semibold text-foreground">
{titleSlot}
</div>
)}
{/* 2줄(모바일) / 전체(PC): 네비게이션 + 뷰전환 + 필터 */}
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
{/* 좌측: (PC에서만 타이틀) + 네비게이션 */}
{/* 모바일: 네비게이션 (1줄) + 뷰전환 (2줄) */}
<div className="flex xl:hidden flex-col gap-2">
{/* 1줄: 네비게이션 < 년월 > */}
<div className="flex items-center justify-center gap-1">
<Button
variant="outline"
size="icon"
className="h-7 w-7 shrink-0 hover:bg-primary/10"
onClick={onPrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-bold text-center whitespace-nowrap px-1">
{formatYearMonth(currentDate)}
</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7 shrink-0 hover:bg-primary/10"
onClick={onNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 2줄: 뷰 전환 탭 */}
<div className="flex justify-center">
{renderViewTabs()}
</div>
</div>
{/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */}
<div className="hidden xl:flex items-center justify-between">
{/* 좌측: 타이틀 + 네비게이션 */}
<div className="flex items-center gap-4">
{titleSlot && (
<span className="hidden xl:block text-base font-semibold text-foreground">
<span className="text-base font-semibold text-foreground">
{titleSlot}
</span>
)}
@@ -70,54 +121,19 @@ export function CalendarHeader({
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 모바일: 뷰 전환 탭 (네비게이션 옆) */}
<div className="flex xl:hidden rounded-md border">
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-4 py-1.5 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
>
{v.label}
</button>
))}
</div>
</div>
{/* 우측(PC만): 뷰 전환 + 필터 */}
<div className="hidden xl:flex items-center gap-3">
<div className="flex rounded-md border">
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-4 py-1.5 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
>
{v.label}
</button>
))}
</div>
{/* 우측: 뷰 전환 + 필터 */}
<div className="flex items-center gap-3">
{renderViewTabs('px-4 py-1.5')}
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
</div>
</div>
{/* 3줄(모바일만): 필터 */}
{/* 모바일 전용: 필터 */}
{filterSlot && (
<div className="flex xl:hidden items-center gap-2">{filterSlot}</div>
)}
</div>
);
}
}

View File

@@ -301,7 +301,7 @@ export function MobileCard({
)}
{/* 제목 */}
<h3 className="font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{title}
</h3>

View File

@@ -34,6 +34,7 @@ import {
} from "../ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ItemSearchModal } from "./ItemSearchModal";
import { LocationEditModal } from "./LocationEditModal";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
@@ -117,6 +118,7 @@ export function LocationDetailPanel({
const [activeTab, setActiveTab] = useState("body");
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
// ---------------------------------------------------------------------------
// 계산된 값
@@ -257,7 +259,14 @@ export function LocationDetailPanel({
className="w-24 h-8 text-center font-bold"
/>
{!disabled && (
<Badge variant="secondary" className="text-xs"></Badge>
<Button
variant="secondary"
size="sm"
className="text-xs h-7"
onClick={() => setEditModalOpen(true)}
>
</Button>
)}
</div>
</div>
@@ -613,6 +622,16 @@ export function LocationDetailPanel({
}}
tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label}
/>
{/* 개소 정보 수정 모달 */}
<LocationEditModal
open={editModalOpen}
onOpenChange={setEditModalOpen}
location={location}
onSave={(locationId, updates) => {
onUpdateLocation(locationId, updates);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,282 @@
/**
* 개소 정보 수정 모달
*
* 발주 개소 목록에서 수정 버튼 클릭 시 표시
* - 개소 정보: 층, 부호
* - 오픈사이즈: 가로, 세로
* - 필수 설정: 가이드레일, 전원, 제어기
*/
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { NumberInput } from "../ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import type { LocationItem } from "./QuoteRegistrationV2";
// =============================================================================
// 상수
// =============================================================================
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
];
// =============================================================================
// Props
// =============================================================================
interface LocationEditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
location: LocationItem | null;
onSave: (locationId: string, updates: Partial<LocationItem>) => void;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationEditModal({
open,
onOpenChange,
location,
onSave,
}: LocationEditModalProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [formData, setFormData] = useState({
floor: "",
code: "",
openWidth: 0,
openHeight: 0,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
});
// location 변경 시 폼 데이터 초기화
useEffect(() => {
if (location) {
setFormData({
floor: location.floor,
code: location.code,
openWidth: location.openWidth,
openHeight: location.openHeight,
guideRailType: location.guideRailType,
motorPower: location.motorPower,
controller: location.controller,
});
}
}, [location]);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
const handleFieldChange = (field: string, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = () => {
if (!location) return;
onSave(location.id, {
floor: formData.floor,
code: formData.code,
openWidth: formData.openWidth,
openHeight: formData.openHeight,
guideRailType: formData.guideRailType,
motorPower: formData.motorPower,
controller: formData.controller,
});
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
if (!location) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 개소 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700"> </h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-600 mb-1 block"></label>
<Input
value={formData.floor}
onChange={(e) => handleFieldChange("floor", e.target.value)}
placeholder="1층"
/>
</div>
<div>
<label className="text-sm text-gray-600 mb-1 block"></label>
<Input
value={formData.code}
onChange={(e) => handleFieldChange("code", e.target.value)}
placeholder="FSS-01"
/>
</div>
</div>
</div>
{/* 오픈사이즈 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-600 mb-1 block"> (mm)</label>
<NumberInput
value={formData.openWidth}
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
placeholder="5000"
/>
</div>
<div>
<label className="text-sm text-gray-600 mb-1 block"> (mm)</label>
<NumberInput
value={formData.openHeight}
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
placeholder="3000"
/>
</div>
</div>
<p className="text-xs text-gray-500">
280mm를 .
</p>
</div>
{/* 필수 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700"> </h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
🔧
</label>
<Select
value={formData.guideRailType}
onValueChange={(value) => handleFieldChange("guideRailType", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
</label>
<Select
value={formData.motorPower}
onValueChange={(value) => handleFieldChange("motorPower", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MOTOR_POWERS.map((power) => (
<SelectItem key={power.value} value={power.value}>
{power.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
📦
</label>
<Select
value={formData.controller}
onValueChange={(value) => handleFieldChange("controller", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTROLLERS.map((ctrl) => (
<SelectItem key={ctrl.value} value={ctrl.value}>
{ctrl.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
{/* 버튼 */}
<div className="flex gap-3 pt-4 border-t">
<Button
variant="outline"
onClick={handleCancel}
className="flex-1"
>
</Button>
<Button
onClick={handleSave}
className="flex-1 bg-orange-500 hover:bg-orange-600"
>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,7 +9,7 @@
"use client";
import { useState, useCallback } from "react";
import { Plus, Upload, Download, Trash2 } from "lucide-react";
import { Plus, Upload, Download, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../ui/button";
@@ -32,6 +32,7 @@ import {
TableRow,
} from "../ui/table";
import { DeleteConfirmDialog } from "../ui/confirm-dialog";
import { LocationEditModal } from "./LocationEditModal";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
@@ -70,6 +71,7 @@ interface LocationListPanelProps {
onSelectLocation: (id: string) => void;
onAddLocation: (location: Omit<LocationItem, "id">) => void;
onDeleteLocation: (id: string) => void;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
onExcelUpload: (locations: Omit<LocationItem, "id">[]) => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
@@ -85,6 +87,7 @@ export function LocationListPanel({
onSelectLocation,
onAddLocation,
onDeleteLocation,
onUpdateLocation,
onExcelUpload,
finishedGoods,
disabled = false,
@@ -109,6 +112,9 @@ export function LocationListPanel({
// 삭제 확인 다이얼로그
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// 수정 모달
const [editTarget, setEditTarget] = useState<LocationItem | null>(null);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
@@ -346,17 +352,30 @@ export function LocationListPanel({
<TableCell className="text-center">{loc.quantity}</TableCell>
<TableCell className="text-center">
{!disabled && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(loc.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-500 hover:text-blue-600"
onClick={(e) => {
e.stopPropagation();
setEditTarget(loc);
}}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(loc.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</TableCell>
</TableRow>
@@ -521,6 +540,18 @@ export function LocationListPanel({
title="개소 삭제"
description="선택한 개소를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
/>
{/* 개소 정보 수정 모달 */}
<LocationEditModal
open={!!editTarget}
onOpenChange={(open) => !open && setEditTarget(null)}
location={editTarget}
onSave={(locationId, updates) => {
onUpdateLocation(locationId, updates);
setEditTarget(null);
toast.success("개소 정보가 수정되었습니다.");
}}
/>
</div>
);
}

View File

@@ -703,6 +703,7 @@ export function QuoteRegistrationV2({
onSelectLocation={setSelectedLocationId}
onAddLocation={handleAddLocation}
onDeleteLocation={handleDeleteLocation}
onUpdateLocation={handleUpdateLocation}
onExcelUpload={handleExcelUpload}
finishedGoods={finishedGoods}
disabled={isViewMode}

View File

@@ -103,33 +103,35 @@ export function DetailActions({
const actualSubmitLabel = submitLabel || (isCreateMode ? '등록' : '저장');
// Fixed 스타일: 화면 하단에 고정 (사이드바 상태에 따라 동적 계산)
// 사이드바 펼침: w-64(256px), 접힘: w-24(96px), 차이: 160px
// 모바일: 좌우 여백 16px (left-4 right-4)
// 태블릿/데스크탑: 사이드바 펼침(w-64=256px), 접힘(w-24=96px) + 여백 고려
const stickyStyles = sticky
? `fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300`
? `fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`
: '';
// 공통 레이아웃: 왼쪽(뒤로) | 오른쪽(액션들)
// 모바일: 아이콘만, 태블릿 이상: 아이콘 + 텍스트
return (
<div className={cn('flex items-center justify-between', stickyStyles, className)}>
<div className={cn('flex items-center justify-between gap-2', stickyStyles, className)}>
{/* 왼쪽: 목록으로 (view) 또는 취소 (edit/create) */}
{isViewMode ? (
showBack && onBack ? (
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
{backLabel}
<Button variant="outline" onClick={onBack} size="sm" className="md:size-default">
<ArrowLeft className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{backLabel}</span>
</Button>
) : (
<div />
)
) : (
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
{cancelLabel}
<Button variant="outline" onClick={onCancel} disabled={isSubmitting} size="sm" className="md:size-default">
<X className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{cancelLabel}</span>
</Button>
)}
{/* 오른쪽: 추가액션 + 삭제 + 수정/저장/등록 */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 md:gap-2">
{extraActions}
{/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제할 대상 없음) */}
@@ -138,26 +140,27 @@ export function DetailActions({
variant="outline"
onClick={onDelete}
disabled={isSubmitting}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
<Trash2 className="w-4 h-4 mr-2" />
{deleteLabel}
<Trash2 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{deleteLabel}</span>
</Button>
)}
{/* 수정 버튼: view 모드에서만 */}
{isViewMode && canEdit && showEdit && onEdit && (
<Button onClick={onEdit}>
<Edit className="w-4 h-4 mr-2" />
{editLabel}
<Button onClick={onEdit} size="sm" className="md:size-default">
<Edit className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{editLabel}</span>
</Button>
)}
{/* 저장/등록 버튼: edit, create 모드에서만 */}
{!isViewMode && showSave && onSubmit && (
<Button onClick={onSubmit} disabled={isSubmitting}>
<Save className="w-4 h-4 mr-2" />
{actualSubmitLabel}
<Button onClick={onSubmit} disabled={isSubmitting} size="sm" className="md:size-default">
<Save className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{actualSubmitLabel}</span>
</Button>
)}
</div>