feat: Add BOM version initialization feature and enhance version handling

- Implemented a new endpoint to initialize BOM versions, automatically creating the first version and updating related details.
- Enhanced the BOM service to include logic for version name handling and duplication checks during version creation.
- Updated the BOM controller to support the new initialization functionality, improving BOM management capabilities.
- Improved the BOM version modal to allow users to specify version names during creation, enhancing user experience and flexibility.
This commit is contained in:
DDD1542 2026-02-26 20:48:56 +09:00
parent afc66a4971
commit 385a10e2e7
7 changed files with 321 additions and 82 deletions

View File

@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) {
const { bomId } = req.params; const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*"; const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || ""; const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { tableName, detailTable } = req.body || {}; const { tableName, detailTable, versionName } = req.body || {};
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable); const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName);
res.json({ success: true, data: result }); res.json({ success: true, data: result });
} catch (error: any) { } catch (error: any) {
logger.error("BOM 버전 생성 실패", { error: error.message }); logger.error("BOM 버전 생성 실패", { error: error.message });
@ -129,6 +129,20 @@ export async function activateBomVersion(req: Request, res: Response) {
} }
} }
export async function initializeBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 초기 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) { export async function deleteBomVersion(req: Request, res: Response) {
try { try {
const { bomId, versionId } = req.params; const { bomId, versionId } = req.params;

View File

@ -20,6 +20,7 @@ router.post("/:bomId/history", bomController.addBomHistory);
// 버전 // 버전
router.get("/:bomId/versions", bomController.getBomVersions); router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion); router.post("/:bomId/versions", bomController.createBomVersion);
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion); router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);

View File

@ -98,6 +98,7 @@ export async function getBomVersions(bomId: string, companyCode: string, tableNa
export async function createBomVersion( export async function createBomVersion(
bomId: string, companyCode: string, createdBy: string, bomId: string, companyCode: string, createdBy: string,
versionTableName?: string, detailTableName?: string, versionTableName?: string, detailTableName?: string,
inputVersionName?: string,
) { ) {
const vTable = safeTableName(versionTableName || "", "bom_version"); const vTable = safeTableName(versionTableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail"); const dTable = safeTableName(detailTableName || "", "bom_detail");
@ -107,17 +108,24 @@ export async function createBomVersion(
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0]; const bomData = bomRow.rows[0];
// 다음 버전 번호 결정 // 버전명: 사용자 입력 > 순번 자동 생성
const lastVersion = await client.query( let versionName = inputVersionName?.trim();
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`, if (!versionName) {
const countResult = await client.query(
`SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`,
[bomId], [bomId],
); );
let nextVersionNum = 1; versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
if (lastVersion.rows.length > 0) { }
const parsed = parseFloat(lastVersion.rows[0].version_name);
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1; // 중복 체크
const dupCheck = await client.query(
`SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`,
[bomId, versionName],
);
if (dupCheck.rows.length > 0) {
throw new Error(`이미 존재하는 버전명입니다: ${versionName}`);
} }
const versionName = `${nextVersionNum}.0`;
// 새 버전 레코드 생성 (snapshot_data 없이) // 새 버전 레코드 생성 (snapshot_data 없이)
const insertSql = ` const insertSql = `
@ -249,6 +257,68 @@ export async function activateBomVersion(bomId: string, versionId: string, table
}); });
} }
/**
* BOM 초기화: + version_id null인
* BOM version ( )
*/
export async function initializeBomVersion(
bomId: string, companyCode: string, createdBy: string,
) {
return transaction(async (client) => {
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0];
if (bomData.current_version_id) {
await client.query(
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
[bomData.current_version_id, bomId],
);
return { versionId: bomData.current_version_id, created: false };
}
// 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지)
const existingVersion = await client.query(
`SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`,
[bomId],
);
if (existingVersion.rows.length > 0) {
const existId = existingVersion.rows[0].id;
await client.query(
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
[existId, bomId],
);
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`,
[existId, bomId],
);
return { versionId: existId, created: false };
}
const versionName = bomData.version || "1.0";
const versionResult = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`,
[bomId, versionName, createdBy, companyCode],
);
const versionId = versionResult.rows[0].id;
const updated = await client.query(
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
[versionId, bomId],
);
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
[versionId, bomId],
);
logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount });
return { versionId, versionName, created: true };
});
}
/** /**
* 삭제: 해당 version_id의 bom_detail * 삭제: 해당 version_id의 bom_detail
*/ */

View File

@ -86,6 +86,7 @@ interface ItemSearchModalProps {
onClose: () => void; onClose: () => void;
onSelect: (items: ItemInfo[]) => void; onSelect: (items: ItemInfo[]) => void;
companyCode?: string; companyCode?: string;
existingItemIds?: Set<string>;
} }
function ItemSearchModal({ function ItemSearchModal({
@ -93,6 +94,7 @@ function ItemSearchModal({
onClose, onClose,
onSelect, onSelect,
companyCode, companyCode,
existingItemIds,
}: ItemSearchModalProps) { }: ItemSearchModalProps) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]); const [items, setItems] = useState<ItemInfo[]>([]);
@ -182,7 +184,7 @@ function ItemSearchModal({
</div> </div>
) : ( ) : (
<table className="w-full text-xs sm:text-sm"> <table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0"> <thead className="bg-muted sticky top-0 z-10">
<tr> <tr>
<th className="w-8 px-2 py-2 text-center"> <th className="w-8 px-2 py-2 text-center">
<Checkbox <Checkbox
@ -200,10 +202,13 @@ function ItemSearchModal({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map((item) => ( {items.map((item) => {
const alreadyAdded = existingItemIds?.has(item.id) || false;
return (
<tr <tr
key={item.id} key={item.id}
onClick={() => { onClick={() => {
if (alreadyAdded) return;
setSelectedItems((prev) => { setSelectedItems((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); if (next.has(item.id)) next.delete(item.id);
@ -212,14 +217,19 @@ function ItemSearchModal({
}); });
}} }}
className={cn( className={cn(
"cursor-pointer border-t transition-colors", "border-t transition-colors",
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", alreadyAdded
? "cursor-not-allowed opacity-40"
: "cursor-pointer",
!alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "",
)} )}
> >
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}> <td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={selectedItems.has(item.id)} checked={selectedItems.has(item.id)}
disabled={alreadyAdded}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (alreadyAdded) return;
setSelectedItems((prev) => { setSelectedItems((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (checked) next.add(item.id); if (checked) next.add(item.id);
@ -231,12 +241,14 @@ function ItemSearchModal({
</td> </td>
<td className="px-3 py-2 font-mono"> <td className="px-3 py-2 font-mono">
{item.item_number} {item.item_number}
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">()</span>}
</td> </td>
<td className="px-3 py-2">{item.item_name}</td> <td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td> <td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td> <td className="px-3 py-2">{item.unit}</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
)} )}
@ -739,37 +751,40 @@ export function BomItemEditorComponent({
[originalNotifyChange, markChanged], [originalNotifyChange, markChanged],
); );
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장 // EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
useEffect(() => { useEffect(() => {
if (isDesignMode || !bomId) return; if (isDesignMode || !bomId) return;
const handler = (e: Event) => { const handler = (e: Event) => {
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", { if (handleSaveAllRef.current) {
bomId,
treeDataLength: treeData.length,
hasRef: !!handleSaveAllRef.current,
});
if (treeData.length > 0 && handleSaveAllRef.current) {
const savePromise = handleSaveAllRef.current(); const savePromise = handleSaveAllRef.current();
if (detail?.pendingPromises) { if (detail?.pendingPromises) {
detail.pendingPromises.push(savePromise); detail.pendingPromises.push(savePromise);
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
} }
} }
}; };
window.addEventListener("beforeFormSave", handler); window.addEventListener("beforeFormSave", handler);
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
return () => window.removeEventListener("beforeFormSave", handler); return () => window.removeEventListener("beforeFormSave", handler);
}, [isDesignMode, bomId, treeData.length]); }, [isDesignMode, bomId]);
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
const handleSaveAll = useCallback(async () => { const handleSaveAll = useCallback(async () => {
if (!bomId) return; if (!bomId) return;
setSaving(true); setSaving(true);
try { try {
// 저장 시점에도 최신 version_id 조회 // version_id 확보: 없으면 서버에서 자동 초기화
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
if (!saveVersionId) {
try {
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
if (initRes.data?.success && initRes.data.data?.versionId) {
saveVersionId = initRes.data.data.versionId;
}
} catch (e) {
console.error("[BomItemEditor] 버전 초기화 실패:", e);
}
}
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => { const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
const result: any[] = []; const result: any[] = [];
@ -1338,6 +1353,18 @@ export function BomItemEditorComponent({
onClose={() => setItemSearchOpen(false)} onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect} onSelect={handleItemSelect}
companyCode={companyCode} companyCode={companyCode}
existingItemIds={useMemo(() => {
const ids = new Set<string>();
const collect = (nodes: BomItemNode[]) => {
for (const n of nodes) {
const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"];
if (fk) ids.add(fk);
collect(n.children);
}
};
collect(treeData);
return ids;
}, [treeData, cfg])}
/> />
</div> </div>
); );

View File

@ -13,6 +13,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -35,6 +42,20 @@ export function BomDetailEditModal({
}: BomDetailEditModalProps) { }: BomDetailEditModalProps) {
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [processOptions, setProcessOptions] = useState<{ value: string; label: string }[]>([]);
useEffect(() => {
if (open && !isRootNode) {
apiClient.get("/table-categories/bom_detail/process_type/values")
.then((res) => {
const values = res.data?.data || [];
if (values.length > 0) {
setProcessOptions(values.map((v: any) => ({ value: v.value_code, label: v.value_label })));
}
})
.catch(() => { /* 카테고리 없으면 빈 배열 유지 */ });
}
}, [open, isRootNode]);
useEffect(() => { useEffect(() => {
if (node && open) { if (node && open) {
@ -67,11 +88,15 @@ export function BomDetailEditModal({
try { try {
const targetTable = isRootNode ? "bom" : tableName; const targetTable = isRootNode ? "bom" : tableName;
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id; const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData); await apiClient.put(`/table-management/tables/${targetTable}/edit`, {
originalData: { id: realId },
updatedData: { id: realId, ...formData },
});
onSaved?.(); onSaved?.();
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
console.error("[BomDetailEdit] 저장 실패:", error); console.error("[BomDetailEdit] 저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -139,12 +164,28 @@ export function BomDetailEditModal({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>
{processOptions.length > 0 ? (
<Select
value={formData.process_type || ""}
onValueChange={(v) => handleChange("process_type", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="공정 선택" />
</SelectTrigger>
<SelectContent>
{processOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
value={formData.process_type} value={formData.process_type}
onChange={(e) => handleChange("process_type", e.target.value)} onChange={(e) => handleChange("process_type", e.target.value)}
placeholder="예: 조립공정" placeholder="예: 조립공정"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/> />
)}
</div> </div>
<div> <div>
<Label className="text-xs sm:text-sm"> (%)</Label> <Label className="text-xs sm:text-sm"> (%)</Label>

View File

@ -138,6 +138,23 @@ export function BomTreeComponent({
const showHistory = features.showHistory !== false; const showHistory = features.showHistory !== false;
const showVersion = features.showVersion !== false; const showVersion = features.showVersion !== false;
// 카테고리 라벨 캐시 (process_type 등)
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const loadLabels = async () => {
try {
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
}
} catch { /* 무시 */ }
};
loadLabels();
}, [detailTable]);
// ─── 데이터 로드 ─── // ─── 데이터 로드 ───
// BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
@ -168,7 +185,18 @@ export function BomTreeComponent({
setLoading(true); setLoading(true);
try { try {
const searchFilter: Record<string, any> = { [foreignKey]: bomId }; const searchFilter: Record<string, any> = { [foreignKey]: bomId };
const versionId = headerData?.current_version_id; let versionId = headerData?.current_version_id;
// version_id가 없으면 서버에서 자동 초기화
if (!versionId) {
try {
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
if (initRes.data?.success && initRes.data.data?.versionId) {
versionId = initRes.data.data.versionId;
}
} catch { /* 무시 */ }
}
if (versionId) { if (versionId) {
searchFilter.version_id = versionId; searchFilter.version_id = versionId;
} }
@ -461,6 +489,11 @@ export function BomTreeComponent({
return <span className="font-medium text-gray-900">{value || "-"}</span>; return <span className="font-medium text-gray-900">{value || "-"}</span>;
} }
if (col.key === "status") {
const statusMap: Record<string, string> = { active: "사용", inactive: "미사용", developing: "개발중" };
return <span>{statusMap[String(value)] || value || "-"}</span>;
}
if (col.key === "quantity" || col.key === "base_qty") { if (col.key === "quantity" || col.key === "base_qty") {
return ( return (
<span className="font-medium tabular-nums text-gray-800"> <span className="font-medium tabular-nums text-gray-800">
@ -469,6 +502,11 @@ export function BomTreeComponent({
); );
} }
if (col.key === "process_type" && value) {
const label = categoryLabels.process_type?.[String(value)] || String(value);
return <span>{label}</span>;
}
if (col.key === "loss_rate") { if (col.key === "loss_rate") {
const num = Number(value); const num = Number(value);
if (!num) return <span className="text-gray-300">-</span>; if (!num) return <span className="text-gray-300">-</span>;

View File

@ -43,6 +43,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [actionId, setActionId] = useState<string | null>(null); const [actionId, setActionId] = useState<string | null>(null);
const [newVersionName, setNewVersionName] = useState("");
const [showNewInput, setShowNewInput] = useState(false);
useEffect(() => { useEffect(() => {
if (open && bomId) loadVersions(); if (open && bomId) loadVersions();
@ -63,11 +65,26 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
const handleCreateVersion = async () => { const handleCreateVersion = async () => {
if (!bomId) return; if (!bomId) return;
const trimmed = newVersionName.trim();
if (!trimmed) {
alert("버전명을 입력해주세요.");
return;
}
setCreating(true); setCreating(true);
try { try {
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable }); const res = await apiClient.post(`/bom/${bomId}/versions`, {
if (res.data?.success) loadVersions(); tableName, detailTable, versionName: trimmed,
} catch (error) { });
if (res.data?.success) {
setNewVersionName("");
setShowNewInput(false);
loadVersions();
} else {
alert(res.data?.message || "버전 생성 실패");
}
} catch (error: any) {
const msg = error.response?.data?.message || "버전 생성 실패";
alert(msg);
console.error("[BomVersion] 생성 실패:", error); console.error("[BomVersion] 생성 실패:", error);
} finally { } finally {
setCreating(false); setCreating(false);
@ -230,15 +247,46 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
)} )}
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> {showNewInput && (
<div className="flex items-center gap-2 border-t pt-3">
<input
type="text"
value={newVersionName}
onChange={(e) => setNewVersionName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()}
placeholder="버전명 입력 (예: 2.0, B, 개선판)"
className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm"
autoFocus
/>
<Button <Button
onClick={handleCreateVersion} onClick={handleCreateVersion}
disabled={creating} disabled={creating}
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "생성"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowNewInput(false); setNewVersionName(""); }}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{!showNewInput && (
<Button
onClick={() => setShowNewInput(true)}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />} <Plus className="mr-1 h-4 w-4" />
</Button> </Button>
)}
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}